diff --git a/README.md b/README.md index b796285..e02e0cd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ the Matrix-leave switch. DMs are hidden for the owner, while groups are left. Group invite/kick capabilities remain rejected in DMs; Reddit enforces group permissions and invitation limits. Ban/unban and role changes are unsupported. +FCM push registration is implemented for wrappers that provide a Reddit-project +token. Reddit's Android registration has no chat-only filter: unrelated Reddit +notifications may also wake the wrapper. Account notification settings are not +changed. Real FCM delivery and token health acknowledgements remain unverified; +see the [protocol findings](pkg/redditchat/README.md#push-registration). + ## Setup Install and log in to [`bbctl`](https://github.com/beeper/bridge-manager): diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 1bbccef..89bf44d 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -26,10 +26,12 @@ type RedditClient struct { userLogin *bridgev2.UserLogin userID networkid.UserID - rc *redditchat.Client - clientMu sync.RWMutex - session *redditchat.RedditSession - meta *UserLoginMetadata + rc *redditchat.Client + clientMu sync.RWMutex + session *redditchat.RedditSession + meta *UserLoginMetadata + refreshMu sync.Mutex + pushMu sync.Mutex syncMu sync.Mutex syncCancel context.CancelFunc @@ -165,8 +167,10 @@ func (r *RedditClient) saveMeta(ctx context.Context) error { var errRefreshAccountMismatch = errors.New("reddit session belongs to a different account; sign in again with the original account") // refreshChatToken re-mints the Reddit Matrix access token using the saved -// Reddit session cookies. Called on 401 from the Matrix homeserver. +// Reddit session cookies when chat or push registration needs fresh credentials. func (r *RedditClient) refreshChatToken(ctx context.Context) error { + r.refreshMu.Lock() + defer r.refreshMu.Unlock() if r.session == nil { return redditchat.ErrSessionUnavailable } diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 4c04ea8..256585c 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -29,6 +29,7 @@ type UserLoginMetadata struct { Credentials redditchat.Credentials `json:"credentials"` CookiesJSON string `json:"cookies_json,omitempty"` ChatTokenExpiry int64 `json:"chat_token_expiry,omitempty"` + PushDeviceID string `json:"push_device_id,omitempty"` NextBatch string `json:"next_batch,omitempty"` Username string `json:"username,omitempty"` RoomStateVersion int `json:"room_state_version,omitempty"` diff --git a/pkg/connector/push.go b/pkg/connector/push.go new file mode 100644 index 0000000..71a1a99 --- /dev/null +++ b/pkg/connector/push.go @@ -0,0 +1,65 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/reddit/pkg/redditchat" +) + +var _ bridgev2.PushableNetworkAPI = (*RedditClient)(nil) + +var pushConfig = &bridgev2.PushConfig{ + FCM: &bridgev2.FCMPushConfig{SenderID: redditchat.FCMSenderID}, +} + +func (r *RedditClient) GetPushConfigs() *bridgev2.PushConfig { + return pushConfig +} + +func (r *RedditClient) RegisterPushNotifications(ctx context.Context, pushType bridgev2.PushType, token string) error { + if pushType != bridgev2.PushTypeFCM { + return fmt.Errorf("unsupported push type: %s", pushType) + } + if !r.IsLoggedIn() { + return bridgev2.ErrNotLoggedIn + } + if strings.TrimSpace(token) == "" { + return errors.New("reddit push: FCM token is required") + } + if err := ctx.Err(); err != nil { + return err + } + r.pushMu.Lock() + defer r.pushMu.Unlock() + deviceID, err := r.pushDeviceID(ctx) + if err != nil { + return err + } + err = r.remote().RegisterFCMPush(ctx, token, deviceID) + if errors.Is(err, redditchat.ErrPushAuthentication) && r.session != nil { + if refreshErr := r.refreshChatToken(ctx); refreshErr != nil { + return fmt.Errorf("refresh Reddit credentials for push registration: %w", refreshErr) + } + err = r.remote().RegisterFCMPush(ctx, token, deviceID) + } + return err +} + +func (r *RedditClient) pushDeviceID(ctx context.Context) (string, error) { + r.saveMu.Lock() + defer r.saveMu.Unlock() + if r.meta.PushDeviceID == "" { + r.meta.PushDeviceID = uuid.NewString() + if err := r.userLogin.Save(ctx); err != nil { + r.meta.PushDeviceID = "" + return "", fmt.Errorf("save Reddit push device ID: %w", err) + } + } + return r.meta.PushDeviceID, nil +} diff --git a/pkg/connector/push_test.go b/pkg/connector/push_test.go new file mode 100644 index 0000000..5379cc2 --- /dev/null +++ b/pkg/connector/push_test.go @@ -0,0 +1,191 @@ +package connector + +import ( + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + "go.mau.fi/util/dbutil" + _ "go.mau.fi/util/dbutil/litestream" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + + "github.com/beeper/reddit/pkg/redditchat" +) + +type pushTransport func(*http.Request) (*http.Response, error) + +func (f pushTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestPushRegistrationFromWrapper(t *testing.T) { + r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected Matrix request") }) + r.meta.PushDeviceID = "persistent-bridge-device" + calls := 0 + r.matrix().Client.Transport = pushTransport(func(req *http.Request) (*http.Response, error) { + calls++ + var body struct { + Variables struct { + PushToken string `json:"pushToken"` + DeviceID string `json:"deviceId"` + } `json:"variables"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + wantToken := "first-token" + if calls == 2 { + wantToken = "rotated-token" + } + if body.Variables.PushToken != wantToken || body.Variables.DeviceID != "persistent-bridge-device" { + t.Fatal("wrong token or device changed on token rotation") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":{"registerMobilePushToken":{"ok":true}}}`))}, nil + }) + var pushable bridgev2.PushableNetworkAPI = r + cfg := pushable.GetPushConfigs() + if cfg.FCM == nil || cfg.FCM.SenderID != "933360113277" || cfg.Web != nil || cfg.APNs != nil || cfg.Native { + t.Fatalf("wrong relay configuration: %+v", cfg) + } + for _, token := range []string{"first-token", "rotated-token"} { + if err := pushable.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, token); err != nil { + t.Fatal(err) + } + } + if calls != 2 { + t.Fatalf("got %d registrations", calls) + } +} + +func TestPushRegistrationRejectsUnsupportedRequests(t *testing.T) { + r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected request") }) + for _, pushType := range []bridgev2.PushType{bridgev2.PushTypeUnknown, bridgev2.PushTypeWeb, bridgev2.PushTypeAPNs} { + if err := r.RegisterPushNotifications(t.Context(), pushType, "token"); err == nil { + t.Fatalf("accepted unsupported type: %s", pushType) + } + } + if err := r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, " "); err == nil { + t.Fatal("accepted blank token") + } + r.rc = nil + if err := r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "token"); !errors.Is(err, bridgev2.ErrNotLoggedIn) { + t.Fatalf("wrong missing-login error: %v", err) + } +} + +func TestPushRegistrationWithoutRenewableSession(t *testing.T) { + r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected Matrix request") }) + r.meta.PushDeviceID = "device" + calls := 0 + r.matrix().Client.Transport = pushTransport(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: http.StatusUnauthorized, Body: io.NopCloser(strings.NewReader(`{}`))}, nil + }) + err := r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "token") + if !errors.Is(err, redditchat.ErrPushAuthentication) || calls != 1 { + t.Fatalf("wrong auth failure: %v (%d calls)", err, calls) + } +} + +func pushTestDatabase(t *testing.T, r *RedditClient) { + t.Helper() + db, err := dbutil.NewWithDialect(filepath.Join(t.TempDir(), "push.db"), "sqlite3") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + r.main.Bridge.DB = database.New("", r.main.GetDBMetaTypes(), db) + if err = db.Upgrade(t.Context()); err != nil { + t.Fatal(err) + } + r.userLogin.Bridge = r.main.Bridge + r.userLogin.Metadata = r.meta + r.userLogin.UserMXID = "@test:example.com" + if err = r.main.Bridge.DB.User.Insert(t.Context(), &database.User{MXID: r.userLogin.UserMXID}); err != nil { + t.Fatal(err) + } + if err = r.main.Bridge.DB.UserLogin.Insert(t.Context(), r.userLogin.UserLogin); err != nil { + t.Fatal(err) + } +} + +func TestPushDeviceIDPersistenceAndSaveFailure(t *testing.T) { + r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected request") }) + pushTestDatabase(t, r) + deviceID, err := r.pushDeviceID(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, err = uuid.Parse(deviceID); err != nil { + t.Fatal(err) + } + stored, err := r.main.Bridge.DB.UserLogin.GetByID(t.Context(), r.userLogin.ID) + if err != nil { + t.Fatal(err) + } + if stored.Metadata.(*UserLoginMetadata).PushDeviceID != deviceID { + t.Fatal("push device ID was not persisted") + } + if err = r.main.Bridge.DB.Close(); err != nil { + t.Fatal(err) + } + if reused, err := r.pushDeviceID(t.Context()); err != nil || reused != deviceID { + t.Fatalf("existing device ID was not reused: %v", err) + } + r.meta.PushDeviceID = "" + err = r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "token") + if err == nil || r.meta.PushDeviceID != "" { + t.Fatal("failed save retained an unpersisted device ID") + } +} + +func TestPushRegistrationRefreshesExpiredCredentials(t *testing.T) { + r := testClient(t, func(http.ResponseWriter, *http.Request) { t.Fatal("unexpected request") }) + pushTestDatabase(t, r) + freshToken := "header." + base64.RawURLEncoding.EncodeToString([]byte(`{"lid":"t2_a"}`)) + ".signature" + registrationCalls, refreshCalls := 0, 0 + transport := pushTransport(func(req *http.Request) (*http.Response, error) { + status, body := http.StatusOK, "" + switch req.URL.Host + req.URL.Path { + case "gql-fed.reddit.com/": + registrationCalls++ + if registrationCalls == 1 { + status, body = http.StatusUnauthorized, `{}` + } else { + if req.Header.Get("Authorization") != "Bearer "+freshToken { + t.Fatal("registration retry used expired credentials") + } + body = `{"data":{"registerMobilePushToken":{"ok":true}}}` + } + case "www.reddit.com/svc/shreddit/token": + refreshCalls++ + body = `{"token":"` + freshToken + `","expires":1900000000}` + case "matrix.redditspace.com/_matrix/client/v3/account/whoami": + body = `{"device_id":"matrix-device"}` + default: + t.Fatalf("unexpected request: %s", req.URL) + } + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + previousTransport := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = previousTransport }) + r.matrix().Client.Transport = transport + var err error + r.session, err = redditchat.NewRedditSession(&http.Client{Transport: transport}, "", "") + if err != nil { + t.Fatal(err) + } + r.session.CSRFToken = "csrf" + if err = r.RegisterPushNotifications(t.Context(), bridgev2.PushTypeFCM, "push-token"); err != nil { + t.Fatal(err) + } + if registrationCalls != 2 || refreshCalls != 1 || r.meta.Credentials.AccessToken != freshToken { + t.Fatalf("incorrect refresh: registrations=%d refreshes=%d", registrationCalls, refreshCalls) + } +} diff --git a/pkg/redditchat/README.md b/pkg/redditchat/README.md index ac981d5..936ff6e 100644 --- a/pkg/redditchat/README.md +++ b/pkg/redditchat/README.md @@ -31,3 +31,76 @@ handled here and by the connector, without overriding bridgev2 delivery. Local protocol tests use synthetic responses and do not establish end-to-end acceptance. + +## Push registration + +Source: Reddit Android **2026.41.0**, build **2641021** (`com.reddit.frontpage`), +from the APKMirror bundle inspected on 2026-10-15. + +The Android app registers one FCM token with Reddit's general notification +service. It does not register a Matrix pusher for chat. `RegisterFCMPush` uses +the same persisted GraphQL operation: + +- FCM sender ID: `933360113277` (`res/values/strings.xml`, + `gcm_defaultSenderId`). Firebase project: `reddit-android`. +- Endpoint: `POST https://gql-fed.reddit.com/` with JSON and + `Authorization: Bearer `. +- Operation: `RegisterMobilePushToken`. +- Persisted query version: `1`; SHA-256: + `35313b81bfbb621c426b2f2ec276db3db476a5d8362a1ddbeec982a01dc74977`. +- Variables: `authTokens` (array of Reddit access tokens), `pushToken` (FCM + registration token), `deviceId` (installation ID), `timezoneName` (IANA zone), + `timestamp` (UTC ISO-8601 instant), and `language`. +- Success requires `data.registerMobilePushToken.ok == true` and no GraphQL + errors. HTTP 200 alone is insufficient. + +The operation is in `defpackage/fr90.java`; the request envelope is in +`defpackage/vf10.java`. The call chain is `PushUtil.fetchFreshAccessTokens` -> +`RedditPushTokenRepository.registerPushToken` -> +`RemoteGqlPushTokenDataSource.registerPushToken`. The app supplies access tokens +for its logged-in accounts; the bridge supplies only the current login's token. +The connector persists a separate `push_device_id`, reuses it when the FCM token +rotates, and retries once after refreshing expired Reddit credentials. The FCM +token is supplied by the wrapper/relay, not generated by the connector. + +### Chat-only scope + +There is no notification-category input in this registration operation. The +app's `UpdateNotificationPreferences` input contains account preferences, with +no device ID or push token. The bridge does not modify those settings, since +that would affect the user's Reddit app too. No separate chat-only registration +was found in this APK. + +A receiver/relay can filter the FCM data field `type`, case-insensitively, for +`CHAT_MESSAGE`, `CHAT_REQUEST`, and `CHAT_ACCEPT_INVITE`. The payload also includes +`account_id` and can include `chat_deeplink_uri`; chat event details can appear +inside the JSON string `extra_event_params`. Sources: +`notification/impl/controller/c.java`, `defpackage/{tw60,uw60,sw60}.java`, and +`PushNotificationExtrasMapper`. Filtering in the receiver prevents unrelated +wakeups; it does not prevent Reddit from sending those pushes to FCM. The current +bridgev2 FCM registration interface does not expose a relay filter, so this +connector advertises general Reddit FCM pushes, with `Native` left false. + +The receiver must also consider `PUSH_TOKEN_HEALTH_CHECK`: Android handles this +silently and submits its `encrypted_push_token` to the GraphQL mutation +`SetNotificationsPushTokenActive(input: {token: ...})`. Its persisted hash is +`e6358f27c52cdedf2dd023b24b2f6621473067a5ca4a181dfbc169b02d3793d9`. +Sources: `defpackage/d27.java`, `SendMailroomPingWorker`, and +`defpackage/iaf0.java`. This acknowledgement is not wired into the bridge's +registration-only callback. Long-term token health and deregistration on logout +still need verification with the receiving relay. + +### Verification boundary + +An existing test web session successfully refreshed a chat bearer token. Using +that token, the mobile endpoint recognized the persisted operation and returned +the expected validation errors for all six deliberately omitted variables +(HTTP 400). Without authentication the same request returned HTTP 401. These +checks did not register a token or change account preferences. + +Local tests cover the registration wire, response validation, authentication +failures, credential redaction, redirects, cancellation, and wrapper integration. +A real Reddit-project FCM token is still required to verify successful token +binding, push delivery, health checks, and background wakeup end to end. In +particular, endpoint authentication does not establish that the registration +resolver accepts a web-minted token for mobile delivery. diff --git a/pkg/redditchat/push.go b/pkg/redditchat/push.go new file mode 100644 index 0000000..fa46ec7 --- /dev/null +++ b/pkg/redditchat/push.go @@ -0,0 +1,109 @@ +package redditchat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const FCMSenderID = "933360113277" + +var ErrPushAuthentication = errors.New("reddit push: authentication required") + +// Reddit Android 2026.41.0 uses RegisterMobilePushToken, not Matrix pushers. +// This registration has no per-token notification category filter. +func (c *Client) RegisterFCMPush(ctx context.Context, pushToken, deviceID string) error { + if err := ctx.Err(); err != nil { + return err + } + if c == nil || c.Matrix == nil || strings.TrimSpace(c.Matrix.AccessToken) == "" { + return ErrPushAuthentication + } + if strings.TrimSpace(pushToken) == "" || strings.TrimSpace(deviceID) == "" { + return errors.New("reddit push: FCM token and device ID are required") + } + body, err := json.Marshal(map[string]any{ + "operationName": "RegisterMobilePushToken", + "variables": map[string]any{ + "authTokens": []string{c.Matrix.AccessToken}, + "pushToken": pushToken, + "deviceId": deviceID, + "timezoneName": "UTC", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "language": "en", + }, + "extensions": map[string]any{ + "persistedQuery": map[string]any{ + "version": 1, + "sha256Hash": "35313b81bfbb621c426b2f2ec276db3db476a5d8362a1ddbeec982a01dc74977", + }, + }, + }) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://gql-fed.reddit.com/", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Matrix.AccessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Reddit/Version 2026.41.0/Build 2641021/Android 15") + req.Header.Set("X-Reddit-Device-Id", deviceID) + // The body contains both credentials and a push token. Avoid the Matrix + // request logger and redirects, which could forward the body to another host. + httpClient := *c.Matrix.Client + httpClient.Jar = nil + if httpClient.Timeout == 0 { + httpClient.Timeout = 30 * time.Second + } + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + resp, err := httpClient.Do(req) + if err != nil { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return fmt.Errorf("reddit push: request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return ErrPushAuthentication + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("reddit push: registration returned HTTP %d", resp.StatusCode) + } + var result struct { + Data struct { + RegisterMobilePushToken struct { + OK bool `json:"ok"` + } `json:"registerMobilePushToken"` + } `json:"data"` + Errors []struct { + Extensions struct { + Code string `json:"code"` + } `json:"extensions"` + } `json:"errors"` + } + if err = json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&result); err != nil { + return errors.New("reddit push: invalid registration response") + } + for _, gqlErr := range result.Errors { + if gqlErr.Extensions.Code == "NOT_AUTHENTICATED" { + return ErrPushAuthentication + } + } + if len(result.Errors) > 0 || !result.Data.RegisterMobilePushToken.OK { + return errors.New("reddit push: Reddit rejected registration") + } + return nil +} diff --git a/pkg/redditchat/push_test.go b/pkg/redditchat/push_test.go new file mode 100644 index 0000000..6a9dec7 --- /dev/null +++ b/pkg/redditchat/push_test.go @@ -0,0 +1,131 @@ +package redditchat + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type pushTransport func(*http.Request) (*http.Response, error) + +func (f pushTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func pushTestClient(t *testing.T, transport pushTransport) *Client { + t.Helper() + c, err := New(Credentials{UserID: "@t2_test:reddit.com", AccessToken: "secret-access-token"}) + if err != nil { + t.Fatal(err) + } + c.SetHTTPClient(&http.Client{Transport: transport}) + return c +} + +func TestRegisterFCMPushWire(t *testing.T) { + calls := 0 + c := pushTestClient(t, func(req *http.Request) (*http.Response, error) { + calls++ + if req.Method != http.MethodPost || req.URL.String() != "https://gql-fed.reddit.com/" { + t.Fatalf("wrong endpoint: %s %s", req.Method, req.URL) + } + if req.Header.Get("Authorization") != "Bearer secret-access-token" || req.Header.Get("X-Reddit-Device-Id") != "bridge-device" || req.Header.Get("Content-Type") != "application/json" || !strings.Contains(req.UserAgent(), "Reddit/Version 2026.41.0/") { + t.Fatal("wrong registration headers") + } + var body struct { + OperationName string `json:"operationName"` + Variables map[string]json.RawMessage `json:"variables"` + Extensions struct { + PersistedQuery struct { + Version int `json:"version"` + Hash string `json:"sha256Hash"` + } `json:"persistedQuery"` + } `json:"extensions"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.OperationName != "RegisterMobilePushToken" || body.Extensions.PersistedQuery.Version != 1 || body.Extensions.PersistedQuery.Hash != "35313b81bfbb621c426b2f2ec276db3db476a5d8362a1ddbeec982a01dc74977" { + t.Fatal("wrong persisted operation") + } + if len(body.Variables) != 6 || string(body.Variables["authTokens"]) != `["secret-access-token"]` || string(body.Variables["pushToken"]) != `"secret-push-token"` || string(body.Variables["deviceId"]) != `"bridge-device"` || string(body.Variables["timezoneName"]) != `"UTC"` || string(body.Variables["language"]) != `"en"` { + t.Fatal("wrong registration variables") + } + var timestamp time.Time + if err := json.Unmarshal(body.Variables["timestamp"], ×tamp); err != nil || time.Since(timestamp).Abs() > time.Minute { + t.Fatalf("invalid registration timestamp: %v", err) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":{"registerMobilePushToken":{"ok":true}}}`))}, nil + }) + if err := c.RegisterFCMPush(t.Context(), "secret-push-token", "bridge-device"); err != nil || calls != 1 { + t.Fatalf("registration: %v (%d calls)", err, calls) + } +} + +func TestRegisterFCMPushFailures(t *testing.T) { + for _, tc := range []struct { + name string + status int + body string + auth bool + }{ + {"unauthorized", 401, `secret-access-token`, true}, + {"forbidden", 403, `secret-push-token`, false}, + {"rate limited", 429, `{}`, false}, + {"empty", 200, ``, false}, + {"malformed", 200, `secret-access-token`, false}, + {"null data", 200, `{"data":null}`, false}, + {"missing result", 200, `{"data":{}}`, false}, + {"rejected", 200, `{"data":{"registerMobilePushToken":{"ok":false}}}`, false}, + {"partial error", 200, `{"data":{"registerMobilePushToken":{"ok":true}},"errors":[{"message":"secret-push-token"}]}`, false}, + {"graphql auth", 200, `{"errors":[{"message":"secret-access-token","extensions":{"code":"NOT_AUTHENTICATED"}}]}`, true}, + {"redirect", 307, ``, false}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + c := pushTestClient(t, func(req *http.Request) (*http.Response, error) { + calls++ + if calls > 1 { + t.Fatal("registration followed a redirect or retried") + } + return &http.Response{StatusCode: tc.status, Header: http.Header{"Location": {"https://other.example/"}}, Body: io.NopCloser(strings.NewReader(tc.body)), Request: req}, nil + }) + err := c.RegisterFCMPush(t.Context(), "secret-push-token", "device") + if err == nil || errors.Is(err, ErrPushAuthentication) != tc.auth { + t.Fatalf("wrong error: %v", err) + } + if strings.Contains(err.Error(), "secret-") { + t.Fatal("error exposed a credential") + } + }) + } +} + +func TestRegisterFCMPushValidationAndCancellation(t *testing.T) { + c := pushTestClient(t, func(*http.Request) (*http.Response, error) { + t.Fatal("invalid registration made a request") + return nil, nil + }) + for _, args := range [][2]string{{"", "device"}, {" \n", "device"}, {"token", ""}} { + if err := c.RegisterFCMPush(t.Context(), args[0], args[1]); err == nil { + t.Fatal("accepted invalid registration") + } + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := c.RegisterFCMPush(ctx, "token", "device"); !errors.Is(err, context.Canceled) { + t.Fatalf("wrong cancellation error: %v", err) + } + if err := (*Client)(nil).RegisterFCMPush(t.Context(), "token", "device"); !errors.Is(err, ErrPushAuthentication) { + t.Fatalf("wrong missing-auth error: %v", err) + } + c.Matrix.Client.Transport = pushTransport(func(*http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + }) + if err := c.RegisterFCMPush(t.Context(), "token", "device"); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("transport error lost cause: %v", err) + } +}