From 44d12bef8037de88f50150a5795e0b183924e1d6 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:46:56 +0000 Subject: [PATCH] [MM-66243] Omit sanitized last_viewed_at/last_update_at instead of returning -1 for other users (#37505) * Omit sanitized channel member timestamps from JSON The channel member sanitization introduced in #33835 replaced other users' LastViewedAt and LastUpdateAt with -1, which clients decode as Dec 31 1969. Serialize the sanitized sentinel as an absent field instead so the API no longer returns an invalid timestamp for other users. Co-authored-by: mattermost-code * Add tests and API docs for omitted sanitized member timestamps Verify at the JSON layer that last_viewed_at and last_update_at are omitted for other users' memberships (across the channel and user endpoints) while remaining present for the requester, including a legitimate zero timestamp. Document the omission in the API spec. Co-authored-by: mattermost-code * Strengthen sanitized-timestamp test coverage Cover the NDJSON streaming branch of getChannelMembersForUser and the getChannelMembersForTeamForUser endpoint, assert the requester's own timestamps are valid (not the sentinel), use the sanitizedTimestamp constant, and note the ChannelMemberForExport marshaling footgun. Co-authored-by: mattermost-code * Marshal team data via a typed struct in ChannelMemberWithTeamData Co-authored-by: mattermost-code * Avoid shadowing err in ChannelMemberWithTeamData.MarshalJSON Co-authored-by: mattermost-code * Use omitzero tags to omit sanitized member timestamps Replace the custom ChannelMember/ChannelMemberWithTeamData MarshalJSON round-trip with the Go 1.24 omitzero tag on LastViewedAt/LastUpdateAt. SanitizeForCurrentUser now zeroes another user's timestamps so they are omitted from API responses, per reviewer feedback. * Give current user a real last_viewed_at in sanitization test With omitzero, a zero last_viewed_at is legitimately omitted. Have user2 post an unread message and the current user view the channel so the current-user assertions verify a genuine timestamp survives sanitization. * Use -1 sentinel for sanitized member timestamps with single-pass marshal A last_viewed_at of 0 legitimately means "never viewed", so it cannot double as the sanitization sentinel. Restore the -1 sentinel and omit it during serialization via shadowing pointer fields, avoiding the previous marshal/unmarshal/marshal round-trip. * Clarify ChannelMember.MarshalJSON doc comment per review feedback * Address PR feedback: 0 answered, 4 resolved, 0 declined - Simplify sanitizedTimestamp and SanitizeForCurrentUser doc comments per review - Document that new ChannelMemberWithTeamData fields must be added to MarshalJSON - Add round-trip test guarding against fields dropped by MarshalJSON * Address PR feedback: remove round-trip MarshalJSON test The round-trip test did not guard against forgetting to add a new field to MarshalJSON, since the same field would also be missing from the test. * Address PR feedback: assert legitimate zero last_update_at is serialized * Mark sanitized channel member timestamp fields as nullable in OpenAPI spec --------- Co-authored-by: Cursor Agent Co-authored-by: mattermost-code Co-authored-by: Mattermost Build --- api/v4/source/definitions.yaml | 12 +- server/channels/api4/channel_test.go | 198 +++++++++++++++------ server/public/model/channel_member.go | 72 +++++++- server/public/model/channel_member_test.go | 130 +++++++++++++- 4 files changed, 346 insertions(+), 66 deletions(-) diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index b5d512632e0..a4ff44c4830 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -243,9 +243,13 @@ components: roles: type: string last_viewed_at: - description: The time in milliseconds the channel was last viewed by the user + description: >- + The time in milliseconds the channel was last viewed by the user. + This field is omitted when the membership belongs to a user other + than the requester, as the value is private to that user. type: integer format: int64 + nullable: true msg_count: type: integer mention_count: @@ -253,9 +257,13 @@ components: notify_props: $ref: "#/components/schemas/ChannelNotifyProps" last_update_at: - description: The time in milliseconds the channel member was last updated + description: >- + The time in milliseconds the channel member was last updated. This + field is omitted when the membership belongs to a user other than + the requester, as the value is private to that user. type: integer format: int64 + nullable: true ChannelMemberWithTeamData: allOf: - $ref: "#/components/schemas/ChannelMember" diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index f88f1380102..2be8d2ff74b 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strings" "testing" @@ -7505,73 +7506,162 @@ func TestChannelMemberSanitization(t *testing.T) { _, _, err := client.AddChannelMember(context.Background(), channel.Id, user2.Id) require.NoError(t, err) - t.Run("getChannelMembers sanitizes LastViewedAt and LastUpdateAt for other users", func(t *testing.T) { - members, _, err := client.GetChannelMembers(context.Background(), channel.Id, 0, 60, "") - require.NoError(t, err) + // Give the current user a real, non-zero last_viewed_at: user2 posts a + // message so the channel is unread for the current user, who then views it. + // This keeps the current-user assertions realistic; the requester's own + // timestamps are never sanitized, so a genuine 0 would still be serialized. + user2Client := th.CreateClient() + _, _, err = user2Client.Login(context.Background(), user2.Email, user2.Password) + require.NoError(t, err) + _, _, err = user2Client.CreatePost(context.Background(), &model.Post{ChannelId: channel.Id, Message: "unread message"}) + require.NoError(t, err) + _, _, err = client.ViewChannel(context.Background(), user.Id, &model.ChannelView{ChannelId: channel.Id}) + require.NoError(t, err) + + // decodeRawMembers reads the raw JSON body of a channel member response so the + // test can assert whether the timestamp fields are present or omitted, which a + // typed model.ChannelMember cannot distinguish from a zero value. + decodeRawMembers := func(resp *http.Response, err error) []map[string]json.RawMessage { + require.NoError(t, err) + defer resp.Body.Close() + + var raw json.RawMessage + require.NoError(t, json.NewDecoder(resp.Body).Decode(&raw)) + + var members []map[string]json.RawMessage + if decodeErr := json.Unmarshal(raw, &members); decodeErr != nil { + var single map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &single)) + members = []map[string]json.RawMessage{single} + } + return members + } + + // decodeNDJSONMembers reads a newline-delimited JSON stream, as returned by + // getChannelMembersForUser when page=-1. + decodeNDJSONMembers := func(resp *http.Response, err error) []map[string]json.RawMessage { + require.NoError(t, err) + defer resp.Body.Close() + + var members []map[string]json.RawMessage + decoder := json.NewDecoder(resp.Body) + for { + var member map[string]json.RawMessage + decodeErr := decoder.Decode(&member) + if decodeErr == io.EOF { + break + } + require.NoError(t, decodeErr) + members = append(members, member) + } + return members + } + + userIDOf := func(t *testing.T, member map[string]json.RawMessage) string { + t.Helper() + var id string + require.NoError(t, json.Unmarshal(member["user_id"], &id)) + return id + } + + // assertTimestamps verifies that the current user's memberships expose valid + // timestamps while other users' timestamps are omitted entirely. + assertTimestamps := func(t *testing.T, members []map[string]json.RawMessage) { + t.Helper() for _, member := range members { - if member.UserId == user.Id { - // Current user should see their own timestamps - assert.NotEqual(t, int64(-1), member.LastViewedAt, "Current user should see their LastViewedAt") - assert.NotEqual(t, int64(-1), member.LastUpdateAt, "Current user should see their LastUpdateAt") + rawLastViewedAt, hasLastViewedAt := member["last_viewed_at"] + rawLastUpdateAt, hasLastUpdateAt := member["last_update_at"] + + if userIDOf(t, member) == user.Id { + require.True(t, hasLastViewedAt, "Current user should see their last_viewed_at") + require.True(t, hasLastUpdateAt, "Current user should see their last_update_at") + + var lastViewedAt, lastUpdateAt int64 + require.NoError(t, json.Unmarshal(rawLastViewedAt, &lastViewedAt)) + require.NoError(t, json.Unmarshal(rawLastUpdateAt, &lastUpdateAt)) + assert.GreaterOrEqual(t, lastViewedAt, int64(0), "Current user's last_viewed_at should be a valid timestamp, not the sentinel") + assert.GreaterOrEqual(t, lastUpdateAt, int64(0), "Current user's last_update_at should be a valid timestamp, not the sentinel") } else { - // Other users' timestamps should be sanitized - assert.Equal(t, int64(-1), member.LastViewedAt, "Other users' LastViewedAt should be sanitized") - assert.Equal(t, int64(-1), member.LastUpdateAt, "Other users' LastUpdateAt should be sanitized") + assert.False(t, hasLastViewedAt, "Other users' last_viewed_at should be omitted, not returned as an invalid value") + assert.False(t, hasLastUpdateAt, "Other users' last_update_at should be omitted, not returned as an invalid value") } } - }) + } - t.Run("getChannelMember sanitizes LastViewedAt and LastUpdateAt for other users", func(t *testing.T) { - // Get other user's membership data - member, _, err := client.GetChannelMember(context.Background(), channel.Id, user2.Id, "") - require.NoError(t, err) - - // Should be sanitized since it's not the current user - assert.Equal(t, int64(-1), member.LastViewedAt, "Other user's LastViewedAt should be sanitized") - assert.Equal(t, int64(-1), member.LastUpdateAt, "Other user's LastUpdateAt should be sanitized") - - // Get current user's membership data - currentMember, _, err := client.GetChannelMember(context.Background(), channel.Id, user.Id, "") - require.NoError(t, err) - - // Should not be sanitized since it's the current user - assert.NotEqual(t, int64(-1), currentMember.LastViewedAt, "Current user should see their LastViewedAt") - assert.NotEqual(t, int64(-1), currentMember.LastUpdateAt, "Current user should see their LastUpdateAt") - }) - - t.Run("getChannelMembersByIds sanitizes data appropriately", func(t *testing.T) { - userIds := []string{user.Id, user2.Id} - members, _, err := client.GetChannelMembersByIds(context.Background(), channel.Id, userIds) - require.NoError(t, err) + t.Run("getChannelMembers omits last_viewed_at and last_update_at for other users", func(t *testing.T) { + members := decodeRawMembers(client.DoAPIGet(context.Background(), "/channels/"+channel.Id+"/members?page=0&per_page=60", "")) require.Len(t, members, 2) - - for _, member := range members { - if member.UserId == user.Id { - // Current user should see their own timestamps - assert.NotEqual(t, int64(-1), member.LastViewedAt, "Current user should see their LastViewedAt") - assert.NotEqual(t, int64(-1), member.LastUpdateAt, "Current user should see their LastUpdateAt") - } else { - // Other users' timestamps should be sanitized - assert.Equal(t, int64(-1), member.LastViewedAt, "Other users' LastViewedAt should be sanitized") - assert.Equal(t, int64(-1), member.LastUpdateAt, "Other users' LastUpdateAt should be sanitized") - } - } + assertTimestamps(t, members) }) - t.Run("addChannelMember sanitizes returned member data", func(t *testing.T) { + t.Run("getChannelMember omits timestamps for other users but keeps them for the current user", func(t *testing.T) { + otherMembers := decodeRawMembers(client.DoAPIGet(context.Background(), "/channels/"+channel.Id+"/members/"+user2.Id, "")) + require.Len(t, otherMembers, 1) + _, hasLastViewedAt := otherMembers[0]["last_viewed_at"] + _, hasLastUpdateAt := otherMembers[0]["last_update_at"] + assert.False(t, hasLastViewedAt, "Other user's last_viewed_at should be omitted") + assert.False(t, hasLastUpdateAt, "Other user's last_update_at should be omitted") + + currentMembers := decodeRawMembers(client.DoAPIGet(context.Background(), "/channels/"+channel.Id+"/members/"+user.Id, "")) + require.Len(t, currentMembers, 1) + assertTimestamps(t, currentMembers) + }) + + t.Run("getChannelMembersByIds omits timestamps for other users", func(t *testing.T) { + members := decodeRawMembers(client.DoAPIPostJSON(context.Background(), "/channels/"+channel.Id+"/members/ids", []string{user.Id, user2.Id})) + require.Len(t, members, 2) + assertTimestamps(t, members) + }) + + assertOtherUserMembersOmitted := func(t *testing.T, members []map[string]json.RawMessage, expectTeamData bool) { + t.Helper() + require.NotEmpty(t, members) + for _, member := range members { + assert.Equal(t, user2.Id, userIDOf(t, member)) + assert.NotContains(t, member, "last_viewed_at", "Other user's last_viewed_at should be omitted") + assert.NotContains(t, member, "last_update_at", "Other user's last_update_at should be omitted") + if expectTeamData { + assert.Contains(t, member, "team_name", "Team data should still be present") + } + } + } + + t.Run("getChannelMembersForUser (paginated) omits timestamps for other users", func(t *testing.T) { + // Querying another user's channel members requires the edit_other_users + // permission, so use the system admin client. + members := decodeRawMembers(th.SystemAdminClient.DoAPIGet(context.Background(), "/users/"+user2.Id+"/channel_members?page=0", "")) + assertOtherUserMembersOmitted(t, members, true) + }) + + t.Run("getChannelMembersForUser (NDJSON stream) omits timestamps for other users", func(t *testing.T) { + // page=-1 switches the endpoint to the newline-delimited streaming path, + // which sanitizes members through a separate code path. + members := decodeNDJSONMembers(th.SystemAdminClient.DoAPIGet(context.Background(), "/users/"+user2.Id+"/channel_members?page=-1", "")) + assertOtherUserMembersOmitted(t, members, true) + }) + + t.Run("getChannelMembersForTeamForUser omits timestamps for other users", func(t *testing.T) { + // Querying another user's memberships requires manage_system, so use the + // system admin client. + members := decodeRawMembers(th.SystemAdminClient.DoAPIGet(context.Background(), "/users/"+user2.Id+"/teams/"+th.BasicTeam.Id+"/channels/members", "")) + assertOtherUserMembersOmitted(t, members, false) + }) + + t.Run("addChannelMember omits timestamps in the returned member data", func(t *testing.T) { newUser := th.CreateUser(t) th.LinkUserToTeam(t, newUser, th.BasicTeam) - // Add new user and check returned member data - returnedMember, _, err := client.AddChannelMember(context.Background(), channel.Id, newUser.Id) - require.NoError(t, err) + members := decodeRawMembers(client.DoAPIPostJSON(context.Background(), "/channels/"+channel.Id+"/members", map[string]string{"user_id": newUser.Id})) + require.Len(t, members, 1) - // The returned member should be sanitized since it's not the current user - assert.Equal(t, int64(-1), returnedMember.LastViewedAt, "Returned member LastViewedAt should be sanitized") - assert.Equal(t, int64(-1), returnedMember.LastUpdateAt, "Returned member LastUpdateAt should be sanitized") - assert.Equal(t, newUser.Id, returnedMember.UserId, "UserId should be preserved") - assert.Equal(t, channel.Id, returnedMember.ChannelId, "ChannelId should be preserved") + assert.NotContains(t, members[0], "last_viewed_at", "Returned member last_viewed_at should be omitted") + assert.NotContains(t, members[0], "last_update_at", "Returned member last_update_at should be omitted") + assert.Equal(t, newUser.Id, userIDOf(t, members[0]), "UserId should be preserved") + + var channelID string + require.NoError(t, json.Unmarshal(members[0]["channel_id"], &channelID)) + assert.Equal(t, channel.Id, channelID, "ChannelId should be preserved") }) } diff --git a/server/public/model/channel_member.go b/server/public/model/channel_member.go index fa3630e5e41..3a5992e83c9 100644 --- a/server/public/model/channel_member.go +++ b/server/public/model/channel_member.go @@ -4,6 +4,7 @@ package model import ( + "encoding/json" "fmt" "net/http" "strings" @@ -90,19 +91,53 @@ func (o *ChannelMember) Auditable() map[string]any { } } -// SanitizeForCurrentUser sanitizes channel member data based on whether -// it's the current user's own membership or another user's membership +// sanitizedTimestamp marks a LastViewedAt/LastUpdateAt field that belongs to +// another user and must be hidden. MarshalJSON omits any field holding this +// sentinel rather than serializing an invalid value +const sanitizedTimestamp int64 = -1 + +// SanitizeForCurrentUser hides another user's private timestamp fields by +// marking them with the sanitized sentinel, which MarshalJSON then omits from +// API responses. The requester's own values are left untouched. func (o *ChannelMember) SanitizeForCurrentUser(currentUserId string) { - // If this is not the current user's own membership, - // sanitize sensitive timestamp fields if o.UserId != currentUserId { - o.LastViewedAt = -1 - o.LastUpdateAt = -1 + o.LastViewedAt = sanitizedTimestamp + o.LastUpdateAt = sanitizedTimestamp } } +// timestampOrNil returns nil for the sanitized sentinel so that the omitempty +// tag drops the field, and a pointer to the real value otherwise (including a +// legitimate 0). +func timestampOrNil(ts int64) *int64 { + if ts == sanitizedTimestamp { + return nil + } + return &ts +} + +// MarshalJSON serializes the channel member in a single pass, omitting +// last_viewed_at and/or last_update_at when they hold the sanitized sentinel +// written by SanitizeForCurrentUser. The shadowing pointer fields allow for a +// direct marshal with the sanitized values removed if needed. +func (o ChannelMember) MarshalJSON() ([]byte, error) { + type alias ChannelMember + return json.Marshal(&struct { + *alias + LastViewedAt *int64 `json:"last_viewed_at,omitempty"` + LastUpdateAt *int64 `json:"last_update_at,omitempty"` + }{ + alias: (*alias)(&o), + LastViewedAt: timestampOrNil(o.LastViewedAt), + LastUpdateAt: timestampOrNil(o.LastUpdateAt), + }) +} + // ChannelMemberWithTeamData contains ChannelMember appended with extra team information // as well. +// +// Any new non-embedded field added here must also be added to MarshalJSON below, +// otherwise it will be silently dropped from the JSON output. type ChannelMemberWithTeamData struct { ChannelMember TeamDisplayName string `json:"team_display_name"` @@ -110,10 +145,35 @@ type ChannelMemberWithTeamData struct { TeamUpdateAt int64 `json:"team_update_at"` } +// MarshalJSON flattens the embedded ChannelMember together with the team fields +// in a single pass. It is required because ChannelMember's MarshalJSON would +// otherwise be promoted and drop the team fields entirely. +func (o ChannelMemberWithTeamData) MarshalJSON() ([]byte, error) { + type alias ChannelMember + return json.Marshal(&struct { + *alias + LastViewedAt *int64 `json:"last_viewed_at,omitempty"` + LastUpdateAt *int64 `json:"last_update_at,omitempty"` + TeamDisplayName string `json:"team_display_name"` + TeamName string `json:"team_name"` + TeamUpdateAt int64 `json:"team_update_at"` + }{ + alias: (*alias)(&o.ChannelMember), + LastViewedAt: timestampOrNil(o.LastViewedAt), + LastUpdateAt: timestampOrNil(o.LastUpdateAt), + TeamDisplayName: o.TeamDisplayName, + TeamName: o.TeamName, + TeamUpdateAt: o.TeamUpdateAt, + }) +} + type ChannelMembers []ChannelMember type ChannelMembersWithTeamData []ChannelMemberWithTeamData +// ChannelMemberForExport is only converted field-by-field for export and is +// never JSON-marshaled. If that changes, it must define its own MarshalJSON; +// otherwise ChannelMember's promoted MarshalJSON drops ChannelName and Username. type ChannelMemberForExport struct { ChannelMember ChannelName string diff --git a/server/public/model/channel_member_test.go b/server/public/model/channel_member_test.go index 26ad8122893..3fcb4013bf9 100644 --- a/server/public/model/channel_member_test.go +++ b/server/public/model/channel_member_test.go @@ -4,6 +4,7 @@ package model import ( + "encoding/json" "strings" "testing" @@ -93,8 +94,8 @@ func TestChannelMemberSanitizeForCurrentUser(t *testing.T) { member.SanitizeForCurrentUser(currentUserId) - assert.Equal(t, int64(-1), member.LastViewedAt, "LastViewedAt should be sanitized for other users") - assert.Equal(t, int64(-1), member.LastUpdateAt, "LastUpdateAt should be sanitized for other users") + assert.Equal(t, sanitizedTimestamp, member.LastViewedAt, "LastViewedAt should be marked sanitized for other users") + assert.Equal(t, sanitizedTimestamp, member.LastUpdateAt, "LastUpdateAt should be marked sanitized for other users") }) t.Run("should preserve other fields when sanitizing", func(t *testing.T) { @@ -120,8 +121,8 @@ func TestChannelMemberSanitizeForCurrentUser(t *testing.T) { member.SanitizeForCurrentUser(currentUserId) - assert.Equal(t, int64(-1), member.LastViewedAt, "LastViewedAt should be sanitized") - assert.Equal(t, int64(-1), member.LastUpdateAt, "LastUpdateAt should be sanitized") + assert.Equal(t, sanitizedTimestamp, member.LastViewedAt, "LastViewedAt should be marked sanitized") + assert.Equal(t, sanitizedTimestamp, member.LastUpdateAt, "LastUpdateAt should be marked sanitized") assert.Equal(t, originalRoles, member.Roles, "Roles should be preserved") assert.Equal(t, originalMsgCount, member.MsgCount, "MsgCount should be preserved") assert.Equal(t, originalMentionCount, member.MentionCount, "MentionCount should be preserved") @@ -129,3 +130,124 @@ func TestChannelMemberSanitizeForCurrentUser(t *testing.T) { assert.Equal(t, originalSchemeAdmin, member.SchemeAdmin, "SchemeAdmin should be preserved") }) } + +func TestChannelMemberMarshalJSON(t *testing.T) { + currentUserId := NewId() + otherUserId := NewId() + + newMember := func(userId string) ChannelMember { + return ChannelMember{ + ChannelId: NewId(), + UserId: userId, + Roles: "channel_user", + LastViewedAt: 1234567890000, + MsgCount: 100, + LastUpdateAt: 1234567890000, + NotifyProps: GetDefaultChannelNotifyProps(), + } + } + + decode := func(t *testing.T, member ChannelMember) map[string]any { + t.Helper() + data, err := json.Marshal(member) + require.NoError(t, err) + + fields := map[string]any{} + require.NoError(t, json.Unmarshal(data, &fields)) + return fields + } + + t.Run("keeps timestamps for the current user's own membership", func(t *testing.T) { + member := newMember(currentUserId) + member.SanitizeForCurrentUser(currentUserId) + + fields := decode(t, member) + assert.EqualValues(t, 1234567890000, fields["last_viewed_at"]) + assert.EqualValues(t, 1234567890000, fields["last_update_at"]) + }) + + t.Run("keeps a legitimate zero timestamp for the requester", func(t *testing.T) { + member := newMember(currentUserId) + member.LastViewedAt = 0 + member.LastUpdateAt = 0 + member.SanitizeForCurrentUser(currentUserId) + + fields := decode(t, member) + assert.Contains(t, fields, "last_viewed_at", "the requester's own last_viewed_at of 0 (never viewed) must be serialized") + assert.EqualValues(t, 0, fields["last_viewed_at"]) + assert.Contains(t, fields, "last_update_at", "the requester's own last_update_at of 0 must be serialized") + assert.EqualValues(t, 0, fields["last_update_at"]) + }) + + t.Run("omits sanitized timestamps for another user's membership", func(t *testing.T) { + member := newMember(otherUserId) + member.SanitizeForCurrentUser(currentUserId) + + fields := decode(t, member) + assert.NotContains(t, fields, "last_viewed_at", "sanitized last_viewed_at must be omitted") + assert.NotContains(t, fields, "last_update_at", "sanitized last_update_at must be omitted") + + assert.Equal(t, member.ChannelId, fields["channel_id"]) + assert.Equal(t, otherUserId, fields["user_id"]) + assert.Equal(t, "channel_user", fields["roles"]) + assert.EqualValues(t, 100, fields["msg_count"]) + assert.Contains(t, fields, "notify_props") + }) +} + +func TestChannelMemberWithTeamDataMarshalJSON(t *testing.T) { + currentUserId := NewId() + otherUserId := NewId() + + newMember := func(userId string) ChannelMemberWithTeamData { + return ChannelMemberWithTeamData{ + ChannelMember: ChannelMember{ + ChannelId: NewId(), + UserId: userId, + Roles: "channel_user", + LastViewedAt: 1234567890000, + LastUpdateAt: 1234567890000, + NotifyProps: GetDefaultChannelNotifyProps(), + }, + TeamDisplayName: "Test Team", + TeamName: "test-team", + TeamUpdateAt: 987654321, + } + } + + decode := func(t *testing.T, member ChannelMemberWithTeamData) map[string]any { + t.Helper() + data, err := json.Marshal(member) + require.NoError(t, err) + + fields := map[string]any{} + require.NoError(t, json.Unmarshal(data, &fields)) + return fields + } + + t.Run("preserves team data and timestamps for the current user", func(t *testing.T) { + member := newMember(currentUserId) + member.SanitizeForCurrentUser(currentUserId) + + fields := decode(t, member) + assert.EqualValues(t, 1234567890000, fields["last_viewed_at"]) + assert.EqualValues(t, 1234567890000, fields["last_update_at"]) + assert.Equal(t, "Test Team", fields["team_display_name"]) + assert.Equal(t, "test-team", fields["team_name"]) + assert.EqualValues(t, 987654321, fields["team_update_at"]) + }) + + t.Run("omits sanitized timestamps but keeps team data for another user", func(t *testing.T) { + member := newMember(otherUserId) + member.SanitizeForCurrentUser(currentUserId) + + fields := decode(t, member) + assert.NotContains(t, fields, "last_viewed_at", "sanitized last_viewed_at must be omitted") + assert.NotContains(t, fields, "last_update_at", "sanitized last_update_at must be omitted") + + assert.Equal(t, "Test Team", fields["team_display_name"]) + assert.Equal(t, "test-team", fields["team_name"]) + assert.EqualValues(t, 987654321, fields["team_update_at"]) + assert.Equal(t, otherUserId, fields["user_id"]) + }) +}