Files
livekit/pkg/service/roommanager_test.go
e01a473d04 service: consolidate join grant-limit checks on the validate path (#5066)
* service: enforce participant name length on join

StartSession checked the token's metadata and attributes against the configured limits but not its name, so a participant could join with a name longer than limits.max_participant_name_length, which UpdateParticipant and the signal UpdateMetadata path reject.

* service: consolidate join grant-limit checks on the validate path

Extract checkGrantLimits and run it from ValidateConnectRequest and
WHIPService.validateCreate so the /rtc/validate endpoint and the WHIP
ingest path reject over-limit name, metadata and attributes the same way
a real join does. StartSession keeps its own checks as the backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* service: run the full join limit check in StartSession

StartSession now calls checkConnectLimits, so the RTC node checks
identity, room name, participant name, metadata and attributes itself.
ValidateConnectRequest runs the check after the publish suffix, so it
measures the same identity StartSession sees. checkConnectLimits returns
a plain error, and the HTTP callers map it to 400. The name-only
StartSession test is dropped for TestConnectLimits, which gains a
publish-suffix case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* service: test the StartSession limit check directly

TestStartSessionConnectLimits runs the same over-limit cases as
TestConnectLimits through StartSession on the RTC node. The cases move
into an exported ConnectLimitCases table so both test packages share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-26 17:16:51 +05:30

204 lines
6.2 KiB
Go

// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package service_test
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/rpc"
"github.com/livekit/protocol/utils"
"github.com/livekit/psrpc"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/routing/routingfakes"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/service"
"github.com/livekit/livekit-server/pkg/telemetry/telemetryfakes"
)
// fakeEgressLauncher records the egress start requests it receives.
type fakeEgressLauncher struct {
mu sync.Mutex
started []*rpc.StartEgressRequest
}
func (f *fakeEgressLauncher) StartEgress(_ context.Context, req *rpc.StartEgressRequest) (*livekit.EgressInfo, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.started = append(f.started, req)
return &livekit.EgressInfo{}, nil
}
func (f *fakeEgressLauncher) StopEgress(context.Context, *livekit.StopEgressRequest) (*livekit.EgressInfo, error) {
return &livekit.EgressInfo{}, nil
}
func (f *fakeEgressLauncher) roomCompositeRoomNames() []string {
f.mu.Lock()
defer f.mu.Unlock()
var names []string
for _, req := range f.started {
if rc := req.GetRoomComposite(); rc != nil {
names = append(names, rc.RoomName)
}
}
return names
}
func newTestRoomManager(t *testing.T, conf *config.Config, egressLauncher rtc.EgressLauncher) *service.RoomManager {
// no fixed RTC ports, so that the test does not bind the defaults
conf.RTC.TCPPort = 0
conf.RTC.UDPPort = rtcconfig.PortRange{}
node, err := routing.NewLocalNode(conf)
require.NoError(t, err)
router := &routingfakes.FakeRouter{}
router.GetNodeForRoomReturns(node.Clone(), nil)
store := service.NewLocalStore()
ra, err := service.NewRoomAllocator(conf, router, store)
require.NoError(t, err)
rm, err := service.NewLocalRoomManager(
conf,
store,
node,
router,
ra,
&telemetryfakes.FakeTelemetryService{},
nil,
store,
egressLauncher,
utils.NewDefaultTimedVersionGenerator(),
nil,
psrpc.NewLocalMessageBus(),
nil,
)
require.NoError(t, err)
t.Cleanup(rm.Stop)
return rm
}
func TestRoomCompositeEgressOnCreateRoom(t *testing.T) {
t.Run("from request", func(t *testing.T) {
conf, err := config.NewConfig("", true, nil, nil)
require.NoError(t, err)
launcher := &fakeEgressLauncher{}
rm := newTestRoomManager(t, conf, launcher)
_, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: "room-a",
Egress: &livekit.RoomEgress{
Room: &livekit.RoomCompositeEgressRequest{
Layout: "grid",
FileOutputs: []*livekit.EncodedFileOutput{{Filepath: "recordings/{room_name}.mp4"}},
},
},
})
require.NoError(t, err)
require.Equal(t, []string{"room-a"}, launcher.roomCompositeRoomNames())
})
t.Run("from room preset", func(t *testing.T) {
conf, err := config.NewConfig(roomPresetConfig, true, nil, nil)
require.NoError(t, err)
launcher := &fakeEgressLauncher{}
rm := newTestRoomManager(t, conf, launcher)
for _, name := range []string{"room-a", "room-b"} {
_, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: name,
RoomPreset: "record",
})
require.NoError(t, err)
}
// each room records itself, and the preset is left as configured
require.Equal(t, []string{"room-a", "room-b"}, launcher.roomCompositeRoomNames())
require.Empty(t, conf.Room.RoomConfigurations["record"].GetEgress().GetRoom().GetRoomName())
})
t.Run("from room preset when a session starts", func(t *testing.T) {
conf, err := config.NewConfig(roomPresetConfig, true, nil, nil)
require.NoError(t, err)
launcher := &fakeEgressLauncher{}
rm := newTestRoomManager(t, conf, launcher)
// the join and WHIP paths create the room through StartSession, with the
// token's room preset; without an identity only the room is created
err = rm.StartSession(context.Background(), routing.ParticipantInit{
CreateRoom: &livekit.CreateRoomRequest{
Name: "room-a",
RoomPreset: "record",
},
}, nil, nil, false)
require.NoError(t, err)
require.Equal(t, []string{"room-a"}, launcher.roomCompositeRoomNames())
})
t.Run("from room preset without an egress launcher", func(t *testing.T) {
conf, err := config.NewConfig(roomPresetConfig, true, nil, nil)
require.NoError(t, err)
rm := newTestRoomManager(t, conf, nil)
_, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: "room-a",
RoomPreset: "record",
})
require.ErrorIs(t, err, service.ErrEgressNotConnected)
})
}
// TestStartSessionConnectLimits checks that StartSession on the RTC node rejects the same
// over-limit joins as the HTTP join paths, without relying on the caller's validation.
func TestStartSessionConnectLimits(t *testing.T) {
for _, tc := range service.ConnectLimitCases() {
t.Run(tc.Name, func(t *testing.T) {
conf, err := config.NewConfig("", true, nil, nil)
require.NoError(t, err)
conf.Limit = tc.Limit
rm := newTestRoomManager(t, conf, nil)
g := tc.Grant()
err = rm.StartSession(context.Background(), routing.ParticipantInit{
Identity: livekit.ParticipantIdentity(g.Identity),
Grants: g,
Client: &livekit.ClientInfo{},
CreateRoom: &livekit.CreateRoomRequest{Name: g.Video.Room},
}, &routingfakes.FakeMessageSource{}, &routingfakes.FakeMessageSink{}, false)
require.ErrorIs(t, err, tc.Err)
})
}
}
const roomPresetConfig = `
room:
room_configurations:
record:
egress:
room:
layout: grid
file_outputs:
- filepath: recordings/{room_name}.mp4
`