Files
team/server/public/model/session_test.go
fe8025c7f0 Route Calls pushes through a VoIP token if present (#36726)
* Route Calls pushes through a VoIP token if present

Add VoIP device id support so calls notifications can be delivered as iOS
PushKit pushes, while standard pushes continue using the regular device
token.

* New SessionPropVoIPDeviceId + IsValidVoIPDeviceID validator
* PushNotifyAppleReactNativeVoIP platform constant
* voip_device_id accepted on PUT /users/sessions/device and on login
* DoLogin refactored to accept LoginOptions struct; all four call sites
  (api4/user, channels/web/{saml,magic_link,oauth}) converted
* sendPushNotificationToAllSessions: when SubType=calls, prefer
  Session.Props[voip_device_id]; fall back to standard DeviceId

Tests cover the validator, the device-props endpoint (accept/reject), and
the SubType=calls routing matrix.

* Skip session per Calls plugin "answered_elsewhere" convention

When the Calls plugin fans out a cancel-ring push to a user's other
VoIP devices after they answer a call from one of them, it can't tell
core which session to skip because the public plugin API doesn't expose
a skip-session-id parameter. It encodes the auth-session-id on SenderId
together with Category="answered_elsewhere". Core honors the convention
and clears SenderId on the per-session copy before forwarding so the
session id never reaches the proxy or device. Category is preserved on
the wire so the mobile client can pick CXCallEndedReason.answeredElsewhere.

The matching side lives in mattermost-plugin-calls' push_notifications.go;
both sides cross-reference the constant.

* delay voip token audit until session props are set

* capture voip audit failed case

* Promote VoIP token to a column; dispatch by Transport; harden revocation

* ai feedback review

* feedback review

* feedback review

* fix tests

* update openAPI

* update serialized session model

---------

Co-authored-by: Jesse Hallam <[email protected]>
2026-06-11 16:34:53 -03:00

244 lines
8.0 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSessionIsValid(t *testing.T) {
tcs := []struct {
name string
input Session
expectedError string
}{
{
"Invalid Id",
Session{},
"model.session.is_valid.id.app_error",
},
{
"Invalid UserId",
Session{
Id: NewId(),
},
"model.session.is_valid.user_id.app_error",
},
{
"Invalid CreateAt",
Session{
Id: NewId(),
UserId: NewId(),
},
"model.session.is_valid.create_at.app_error",
},
{
"Invalid Roles",
Session{
Id: NewId(),
UserId: NewId(),
CreateAt: 1000,
Roles: strings.Repeat("a", UserRolesMaxLength+1),
},
"model.session.is_valid.roles_limit.app_error",
},
{
"Valid",
Session{
Id: NewId(),
UserId: NewId(),
CreateAt: 1000,
Roles: strings.Repeat("a", UserRolesMaxLength),
},
"",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
appErr := tc.input.IsValid()
if tc.expectedError != "" {
require.NotNil(t, appErr)
require.Equal(t, tc.expectedError, appErr.Id)
} else {
require.Nil(t, appErr)
}
})
}
}
func TestSessionDeepCopy(t *testing.T) {
sessionId := NewId()
userId := NewId()
mapKey := "key"
mapValue := "val"
session := &Session{Id: sessionId, Props: map[string]string{mapKey: mapValue}, TeamMembers: []*TeamMember{{UserId: userId, TeamId: "someteamId"}}}
copySession := session.DeepCopy()
copySession.Id = "changed"
copySession.Props[mapKey] = "changed"
copySession.TeamMembers[0].UserId = "changed"
assert.Equal(t, sessionId, session.Id)
assert.Equal(t, mapValue, session.Props[mapKey])
assert.Equal(t, userId, session.TeamMembers[0].UserId)
session = &Session{Id: sessionId}
copySession = session.DeepCopy()
assert.Equal(t, sessionId, copySession.Id)
session = &Session{TeamMembers: []*TeamMember{}}
copySession = session.DeepCopy()
assert.Empty(t, copySession.TeamMembers)
}
func TestSessionCSRF(t *testing.T) {
s := Session{}
token := s.GetCSRF()
assert.Empty(t, token)
token = s.GenerateCSRF()
assert.NotEmpty(t, token)
token2 := s.GetCSRF()
assert.NotEmpty(t, token2)
assert.Equal(t, token, token2)
}
func TestSessionIsOAuthUser(t *testing.T) {
testCases := []struct {
Description string
Session Session
isOAuthUser bool
}{
{"False on empty props", Session{}, false},
{"True when key is set to true", Session{Props: StringMap{UserAuthServiceIsOAuth: strconv.FormatBool(true)}}, true},
{"False when key is set to false", Session{Props: StringMap{UserAuthServiceIsOAuth: strconv.FormatBool(false)}}, false},
{"Not affected by Session.IsOAuth being true", Session{IsOAuth: true}, false},
{"Not affected by Session.IsOAuth being false", Session{IsOAuth: false, Props: StringMap{UserAuthServiceIsOAuth: strconv.FormatBool(true)}}, true},
}
for _, tc := range testCases {
t.Run(tc.Description, func(t *testing.T) {
require.Equal(t, tc.isOAuthUser, tc.Session.IsOAuthUser())
})
}
}
func TestIsIntegration(t *testing.T) {
testCases := []struct {
Description string
Session Session
IsIntegration bool
}{
{"False on empty props", Session{}, false},
{"True when is OAuth App", Session{IsOAuth: true}, true},
{"True when session is bot", Session{Props: StringMap{SessionPropIsBot: SessionPropIsBotValue}}, true},
{"True when session is user access token", Session{Props: StringMap{SessionPropType: SessionTypeUserAccessToken}}, true},
{"Not affected by Props[UserAuthServiceIsOAuth]", Session{Props: StringMap{UserAuthServiceIsOAuth: strconv.FormatBool(true)}}, false},
}
for _, tc := range testCases {
t.Run(tc.Description, func(t *testing.T) {
require.Equal(t, tc.IsIntegration, tc.Session.IsIntegration())
})
}
}
func TestIsValidVoIPDeviceId(t *testing.T) {
testCases := []struct {
Description string
Value string
Valid bool
}{
{"empty string", "", false},
{"missing token", PushNotifyAppleReactNative + ":", false},
{"missing separator", PushNotifyAppleReactNative, false},
{"missing platform", ":abcd", false},
{"android is not VoIP-capable yet", PushNotifyAndroidReactNative + ":abcd", false},
{"legacy bare apple prefix not allowed", "apple:abcd", false},
{"valid apple_rn", PushNotifyAppleReactNative + ":abcd", true},
{"valid apple_rnbeta", PushNotifyAppleReactNative + "beta:abcd", true},
{"apple_rn-v2 tolerated", PushNotifyAppleReactNative + "-v2:abcd", true},
{"apple_rn-v0 tolerated", PushNotifyAppleReactNative + "-v0:abcd", true},
{"apple_rn-v999 tolerated", PushNotifyAppleReactNative + "-v999:abcd", true},
{"apple_rn-voip is not stripped and fails allowlist", PushNotifyAppleReactNative + "-voip:abcd", false},
{"apple_rn-vabc is not stripped and fails allowlist", PushNotifyAppleReactNative + "-vabc:abcd", false},
{"apple_rn-v with empty version is not stripped and fails allowlist", PushNotifyAppleReactNative + "-v:abcd", false},
{"apple_rn-v-2 with negative version is not stripped and fails allowlist", PushNotifyAppleReactNative + "-v-2:abcd", false},
}
for _, tc := range testCases {
t.Run(tc.Description, func(t *testing.T) {
assert.Equal(t, tc.Valid, IsValidVoIPDeviceId(tc.Value))
})
}
}
func TestIsValidStandardDeviceId(t *testing.T) {
testCases := []struct {
Description string
Value string
Valid bool
}{
{"empty string", "", false},
{"missing token", PushNotifyAppleReactNative + ":", false},
{"missing separator", PushNotifyAppleReactNative, false},
{"missing platform", ":abcd", false},
{"legacy bare apple prefix not allowed", "apple:abcd", false},
{"legacy bare android prefix not allowed", "android:abcd", false},
{"valid apple_rn", PushNotifyAppleReactNative + ":abcd", true},
{"valid apple_rnbeta", PushNotifyAppleReactNative + "beta:abcd", true},
{"valid android_rn", PushNotifyAndroidReactNative + ":abcd", true},
// Mobile encodes the app-version it speaks in a "-v<N>" suffix on
// the platform (proxy strips it). Validator must tolerate it.
{"apple_rn-v2", PushNotifyAppleReactNative + "-v2:abcd", true},
{"apple_rnbeta-v2", PushNotifyAppleReactNative + "beta-v2:abcd", true},
{"android_rn-v2", PushNotifyAndroidReactNative + "-v2:abcd", true},
{"unknown platform with -v2", "foo_rn-v2:abcd", false},
{"apple_rn-v0", PushNotifyAppleReactNative + "-v0:abcd", true},
{"apple_rn-v999", PushNotifyAppleReactNative + "-v999:abcd", true},
{"apple_rn-voip not stripped, fails allowlist", PushNotifyAppleReactNative + "-voip:abcd", false},
{"apple_rn-vabc not stripped, fails allowlist", PushNotifyAppleReactNative + "-vabc:abcd", false},
{"apple_rn-v with empty version not stripped", PushNotifyAppleReactNative + "-v:abcd", false},
{"apple_rn-v-2 with negative version not stripped", PushNotifyAppleReactNative + "-v-2:abcd", false},
}
for _, tc := range testCases {
t.Run(tc.Description, func(t *testing.T) {
assert.Equal(t, tc.Valid, IsValidStandardDeviceId(tc.Value))
})
}
}
func TestRedactDeviceId(t *testing.T) {
testCases := []struct {
Name string
Input string
Expected string
}{
{"empty", "", ""},
{"no colon", "apple_rn", "apple_rn"},
{"empty token after colon", "apple_rn:", "apple_rn"},
{"short token passes through", PushNotifyAppleReactNative + ":1234", PushNotifyAppleReactNative + ":1234"},
{"16-char token passes through", PushNotifyAppleReactNative + ":0123456789abcdef", PushNotifyAppleReactNative + ":0123456789abcdef"},
{"17-char token truncated", PushNotifyAppleReactNative + ":0123456789abcdefg", PushNotifyAppleReactNative + ":0123456789abcdef…"},
{"long token truncated", PushNotifyAndroidReactNative + ":abcdef0123456789cafebabe1234", PushNotifyAndroidReactNative + ":abcdef0123456789…"},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
assert.Equal(t, tc.Expected, RedactDeviceId(tc.Input))
})
}
}