Files
team/server/channels/app/platform/session_test.go
Carlos GarciaandGitHub 8bcc9baa29 Mm 68291 send wipe (#36945)
* sends wipe signal ro mobile devices when revoking user sessions

there is a session notification type that can be used with no content
to indicate the session has been revoked and data should be wiped client
side. This complements the use of session typed notifications to indicate
expiry of user sessions carrying containing some message.

empty push session typed notifications are sent to mobile devices on
revoking sessions only when mobile ephemeral mode is enabled.

* move send wipe push notifications to goroutines

* wraps goroutine launch in Srv().Go() for proper shutdown

* query active sessions to wipe only when ephemeral mode is enabled

* address review comments

* guard to prevent sending wipe notification to removed tokens
2026-06-26 10:28:05 +02:00

333 lines
11 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
const (
dayInMillis = 86400000
grace = 5 * 1000
thirtyDays = dayInMillis * 30
)
func TestCache(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
session := &model.Session{
Id: model.NewId(),
Token: model.NewId(),
UserId: model.NewId(),
}
session2 := &model.Session{
Id: model.NewId(),
Token: model.NewId(),
UserId: model.NewId(),
}
err := th.Service.sessionCache.SetWithExpiry(session.Token, session, 5*time.Minute)
require.NoError(t, err)
err = th.Service.sessionCache.SetWithExpiry(session2.Token, session2, 5*time.Minute)
require.NoError(t, err)
var keys []string
err = th.Service.sessionCache.Scan(func(in []string) error {
keys = append(keys, in...)
return nil
})
require.NoError(t, err)
require.NotEmpty(t, keys)
th.Service.ClearUserSessionCache(session.UserId)
var rkeys []string
err = th.Service.sessionCache.Scan(func(in []string) error {
rkeys = append(rkeys, in...)
return nil
})
require.NoError(t, err)
require.Lenf(t, rkeys, len(keys)-1, "should have one less: %d - %d != 1", len(keys), len(rkeys))
require.NotEmpty(t, rkeys)
clear(rkeys)
rkeys = []string{}
err = th.Service.ClearAllUsersSessionCache()
require.NoError(t, err)
err = th.Service.sessionCache.Scan(func(in []string) error {
rkeys = append(rkeys, in...)
return nil
})
require.NoError(t, err)
require.Len(t, rkeys, 0)
}
func TestSetSessionExpireInHours(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
now := model.GetMillis()
createAt := now - (dayInMillis * 20)
tests := []struct {
name string
extend bool
create bool
days int
want int64
}{
{name: "zero days, extend", extend: true, create: true, days: 0, want: now},
{name: "zero days, extend", extend: true, create: false, days: 0, want: now},
{name: "zero days, no extend", extend: false, create: true, days: 0, want: createAt},
{name: "zero days, no extend", extend: false, create: false, days: 0, want: now},
{name: "thirty days, extend", extend: true, create: true, days: 30, want: now + thirtyDays},
{name: "thirty days, extend", extend: true, create: false, days: 30, want: now + thirtyDays},
{name: "thirty days, no extend", extend: false, create: true, days: 30, want: createAt + thirtyDays},
{name: "thirty days, no extend", extend: false, create: false, days: 30, want: now + thirtyDays},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ExtendSessionLengthWithActivity = tt.extend
})
var create int64
if tt.create {
create = createAt
}
session := &model.Session{
CreateAt: create,
ExpiresAt: model.GetMillis() + dayInMillis,
}
th.Service.SetSessionExpireInHours(session, tt.days*24)
// must be within 5 seconds of expected time.
require.GreaterOrEqual(t, session.ExpiresAt, tt.want-grace)
require.LessOrEqual(t, session.ExpiresAt, tt.want+grace)
})
}
}
func TestOAuthRevokeAccessToken(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
err := th.Service.RevokeAccessToken(th.Context, model.NewRandomString(16))
require.Error(t, err, "Should have failed due to an incorrect token")
session := &model.Session{}
session.CreateAt = model.GetMillis()
session.UserId = model.NewId()
session.Token = model.NewId()
session.Roles = model.SystemUserRoleId
th.Service.SetSessionExpireInHours(session, 24)
session, _ = th.Service.CreateSession(th.Context, session)
err = th.Service.RevokeAccessToken(th.Context, session.Token)
require.Error(t, err, "Should have failed does not have an access token")
accessData := &model.AccessData{}
accessData.Token = session.Token
accessData.UserId = session.UserId
accessData.RedirectUri = "http://example.com"
accessData.ClientId = model.NewId()
accessData.ExpiresAt = session.ExpiresAt
_, nErr := th.Service.Store.OAuth().SaveAccessData(accessData)
require.NoError(t, nErr)
err = th.Service.RevokeAccessToken(th.Context, accessData.Token)
require.NoError(t, err)
}
func TestUpdateSessionsIsGuest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("Test session is demoted", func(t *testing.T) {
user := th.CreateUserOrGuest(t, false)
session := &model.Session{}
session.CreateAt = model.GetMillis()
session.UserId = user.Id
session.Token = model.NewId()
session.Roles = "fake_role"
th.Service.SetSessionExpireInHours(session, 24)
session, _ = th.Service.CreateSession(th.Context, session)
demotedUser, err := th.Service.Store.User().DemoteUserToGuest(user.Id)
require.NoError(t, err)
require.Equal(t, model.SystemGuestRoleId, demotedUser.Roles)
err = th.Service.UpdateSessionsIsGuest(th.Context, demotedUser, true)
require.NoError(t, err)
session, err = th.Service.GetSession(th.Context, session.Id)
require.NoError(t, err)
require.Equal(t, model.SystemGuestRoleId, session.Roles)
require.Equal(t, "true", session.Props[model.SessionPropIsGuest])
})
t.Run("Test session is promoted", func(t *testing.T) {
user := th.CreateUserOrGuest(t, true)
session := &model.Session{}
session.CreateAt = model.GetMillis()
session.UserId = user.Id
session.Token = model.NewId()
session.Roles = "fake_role"
th.Service.SetSessionExpireInHours(session, 24)
session, _ = th.Service.CreateSession(th.Context, session)
err := th.Service.Store.User().PromoteGuestToUser(user.Id)
require.NoError(t, err)
promotedUser, err := th.Service.Store.User().Get(th.Context.Context(), user.Id)
require.NoError(t, err)
err = th.Service.UpdateSessionsIsGuest(th.Context, promotedUser, false)
require.NoError(t, err)
session, err = th.Service.GetSession(th.Context, session.Id)
require.NoError(t, err)
require.Equal(t, model.SystemUserRoleId, session.Roles)
require.Equal(t, "false", session.Props[model.SessionPropIsGuest])
})
}
func TestRevokeSessionsForDeviceTokens(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Both revoke variants are structurally identical (one matches DeviceId,
// the other VoIPDeviceId), and the invariants they must hold are the
// same. Drive them through a single table.
type variant struct {
name string
setToken func(s *model.Session, token string)
revoke func(userID, token, currentSessionID string) error
emptyTokenErr error
}
variants := []variant{
{
name: "RevokeOtherSessionsForDeviceId",
setToken: func(s *model.Session, token string) { s.DeviceId = token },
revoke: func(userID, token, currentSessionID string) error {
return th.Service.RevokeOtherSessionsForDeviceId(th.Context, userID, token, currentSessionID)
},
emptyTokenErr: ErrEmptyDeviceId,
},
{
name: "RevokeOtherSessionsForVoIPDeviceId",
setToken: func(s *model.Session, token string) { s.VoIPDeviceId = token },
revoke: func(userID, token, currentSessionID string) error {
return th.Service.RevokeOtherSessionsForVoIPDeviceId(th.Context, userID, token, currentSessionID)
},
emptyTokenErr: ErrEmptyVoIPDeviceId,
},
}
createSession := func(t *testing.T, v variant, userID, token string) *model.Session {
t.Helper()
s := &model.Session{UserId: userID, Roles: model.SystemUserRoleId}
v.setToken(s, token)
saved, err := th.Service.CreateSession(th.Context, s)
require.NoError(t, err)
return saved
}
sessionExists := func(t *testing.T, id string) bool {
t.Helper()
_, err := th.Service.GetSessionByID(th.Context, id)
return err == nil
}
for _, v := range variants {
t.Run(v.name, func(t *testing.T) {
t.Run("revokes the matching session", func(t *testing.T) {
token := "apple_rn:revokes-matching-" + v.name
target := createSession(t, v, th.BasicUser.Id, token)
require.NoError(t, v.revoke(th.BasicUser.Id, token, ""))
assert.False(t, sessionExists(t, target.Id), "matching session must be revoked")
})
t.Run("does not revoke the session passed as currentSessionId", func(t *testing.T) {
// Self-skip: a session re-attaching its own token must not
// revoke itself.
token := "apple_rn:self-skip-" + v.name
self := createSession(t, v, th.BasicUser.Id, token)
require.NoError(t, v.revoke(th.BasicUser.Id, token, self.Id))
assert.True(t, sessionExists(t, self.Id), "currentSessionId must be exempt from revocation")
})
t.Run("does not revoke sessions belonging to other users", func(t *testing.T) {
// The function scopes by userID — a session with a matching
// token on a different user must stay alive.
token := "apple_rn:cross-user-" + v.name
otherUserSession := createSession(t, v, th.BasicUser2.Id, token)
require.NoError(t, v.revoke(th.BasicUser.Id, token, ""))
assert.True(t, sessionExists(t, otherUserSession.Id),
"session on a different user must not be revoked")
})
t.Run("empty token returns an error and revokes nothing", func(t *testing.T) {
webSession := createSession(t, v, th.BasicUser.Id, "")
err := v.revoke(th.BasicUser.Id, "", "")
require.ErrorIs(t, err, v.emptyTokenErr)
assert.True(t, sessionExists(t, webSession.Id),
"empty-token revoke must not match sessions with no token in this slot")
})
t.Run("leaves siblings of the same user with different tokens alone", func(t *testing.T) {
targetToken := "apple_rn:target-" + v.name
siblingToken := "apple_rn:sibling-" + v.name
target := createSession(t, v, th.BasicUser.Id, targetToken)
sibling := createSession(t, v, th.BasicUser.Id, siblingToken)
require.NoError(t, v.revoke(th.BasicUser.Id, targetToken, ""))
assert.False(t, sessionExists(t, target.Id))
assert.True(t, sessionExists(t, sibling.Id),
"session with a different token must stay alive")
})
})
}
}
func TestRevokeAllSessionsReturnsRevokedSessions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
userId := model.NewId()
s1 := &model.Session{UserId: userId, ExpiresAt: model.GetMillis() + dayInMillis, DeviceId: model.NewId()}
s1, _ = th.Service.CreateSession(th.Context, s1)
s2 := &model.Session{UserId: userId, ExpiresAt: model.GetMillis() + dayInMillis}
s2, _ = th.Service.CreateSession(th.Context, s2)
sessions, err := th.Service.RevokeAllSessions(th.Context, userId)
require.NoError(t, err)
require.Len(t, sessions, 2)
require.ElementsMatch(t, []string{s1.Id, s2.Id}, []string{sessions[0].Id, sessions[1].Id})
remaining, err := th.Service.Store.Session().GetSessions(th.Context, userId)
require.NoError(t, err)
require.Empty(t, remaining)
}