package clientapi_test import ( "net/http" "strings" "testing" ) // accountDataPath builds the global account data path for a user. func accountDataPath(userID, dataType string) string { return "/_matrix/client/v3/user/" + userID + "/account_data/" + dataType } // roomAccountDataPath builds the per-room variant. func roomAccountDataPath(userID, roomID, dataType string) string { return "/_matrix/client/v3/user/" + userID + "/rooms/" + roomID + "/account_data/" + dataType } func TestAccountDataRoundTrip(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") path := accountDataPath(acc.UserID, "test.key") expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND") rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: map[string]any{"value": "first"}}) expectStatus(t, rec, http.StatusOK) rec = ts.get(path, acc.AccessToken) expectStatus(t, rec, http.StatusOK) if got := jsonBody(t, rec)["value"]; got != "first" { t.Errorf("value = %v, want first", got) } rec = ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: map[string]any{"value": "second"}}) expectStatus(t, rec, http.StatusOK) rec = ts.get(path, acc.AccessToken) if got := jsonBody(t, rec)["value"]; got != "second" { t.Errorf("value after overwriting = %v, want second", got) } } func TestRoomAccountDataRoundTrip(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") const roomID = "!room:warp.example" path := roomAccountDataPath(acc.UserID, roomID, "test.key") rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: map[string]any{"value": "room first"}}) expectStatus(t, rec, http.StatusOK) rec = ts.get(path, acc.AccessToken) expectStatus(t, rec, http.StatusOK) if got := jsonBody(t, rec)["value"]; got != "room first" { t.Errorf("value = %v, want %q", got, "room first") } // Room account data is its own key space: the global entry of the same // type is untouched. expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND") } func TestAccountDataIsPrivate(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "superuser") bob := ts.register("bob", "bobspassword") rec := ts.do(request{method: http.MethodPut, path: accountDataPath(alice.UserID, "test.key"), token: alice.AccessToken, body: map[string]any{"value": "secret"}}) expectStatus(t, rec, http.StatusOK) // Not even an admin: this is the user's own client configuration, not // something the server operator has business reading. ts.makeAdmin(bob) for _, method := range []string{http.MethodGet, http.MethodPut} { rec := ts.do(request{method: method, path: accountDataPath(alice.UserID, "test.key"), token: bob.AccessToken, body: map[string]any{"value": "nosy"}}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } } func TestAccountDataRequiresAToken(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") expectError(t, ts.get(accountDataPath(acc.UserID, "test.key"), ""), http.StatusUnauthorized, "M_MISSING_TOKEN") } // TestAccountDataRejectsFullyRead covers the one type the spec reserves: it // has to travel with a read receipt, so writing it directly is refused. func TestAccountDataRejectsFullyRead(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.do(request{method: http.MethodPut, path: roomAccountDataPath(acc.UserID, "!room:warp.example", "m.fully_read"), token: acc.AccessToken, body: map[string]any{"event_id": "$x"}}) expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON") } func TestAccountDataRejectsNonObjects(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") path := accountDataPath(acc.UserID, "test.key") for name, body := range map[string]any{ "an array": []any{1, 2, 3}, "a string": "just a string", "a number": 42, "a boolean": true, } { t.Run(name, func(t *testing.T) { rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: body}) expectError(t, rec, http.StatusBadRequest, "M_BAD_JSON") }) } } func TestAccountDataRejectsOversizedContent(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.do(request{method: http.MethodPut, path: accountDataPath(acc.UserID, "test.key"), token: acc.AccessToken, body: map[string]any{"value": strings.Repeat("x", 70*1024)}}) expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE") } func TestRoomAccountDataRejectsMalformedRoomID(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.do(request{method: http.MethodPut, path: roomAccountDataPath(acc.UserID, "not-a-room-id", "test.key"), token: acc.AccessToken, body: map[string]any{"value": "x"}}) expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM") } // TestAccountDataIsReturnedVerbatim matters because account data is the // client's own JSON: a server that re-serialises it can reorder keys or // change number formatting under a client that is comparing bytes. func TestAccountDataIsReturnedVerbatim(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") path := accountDataPath(acc.UserID, "test.key") const content = `{"b":1,"a":{"nested":[1,2,3]},"big":10000000000000001}` rec := ts.do(request{method: http.MethodPut, path: path, token: acc.AccessToken, body: rawJSON(content)}) expectStatus(t, rec, http.StatusOK) rec = ts.get(path, acc.AccessToken) expectStatus(t, rec, http.StatusOK) if got := rec.Body.String(); got != content { t.Errorf("account data came back as %s, want %s", got, content) } } // rawJSON is marshalled as-is, so a test can send bytes of its choosing. type rawJSON string func (r rawJSON) MarshalJSON() ([]byte, error) { return []byte(r), nil }