Files
encore-test/videochat/livekit_test.go

326 lines
12 KiB
Go

package videochat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
authsvc "encore.app/auth"
"encore.dev/beta/errs"
"encore.dev/et"
lkauth "github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
"github.com/twitchtv/twirp"
"google.golang.org/protobuf/proto"
)
func TestJoinTokenPermissions(t *testing.T) {
token, err := signJoinToken("test-key", "test-secret", "user-123", "demo", true)
if err != nil {
t.Fatal(err)
}
verifier, err := lkauth.ParseAPIToken(token)
if err != nil {
t.Fatal(err)
}
claims, grants, err := verifier.Verify("test-secret")
if err != nil {
t.Fatal(err)
}
if verifier.APIKey() != "test-key" || grants.Identity != "user-123" {
t.Fatal("unexpected token issuer or identity")
}
g := grants.Video
if g == nil || g.Room != "demo" || !g.RoomJoin || !g.GetCanPublish() || !g.GetCanSubscribe() || !g.GetCanPublishData() || g.GetCanUpdateOwnMetadata() {
t.Fatalf("unexpected participant grants: %+v", g)
}
if g.RoomAdmin || g.RoomCreate || g.RoomList || g.RoomRecord {
t.Fatal("participant must not receive administrative grants")
}
if remaining := time.Until(claims.ExpiresAt.Time); remaining < tokenTTL-5*time.Second || remaining > tokenTTL {
t.Fatalf("unexpected token lifetime: %v", remaining)
}
if _, _, err := verifier.Verify("wrong-secret"); err == nil {
t.Fatal("token must reject an incorrect signing secret")
}
}
func TestValidation(t *testing.T) {
for _, name := range []string{"", " ", " demo", "demo ", "a/b", "a\\b", "a\x00b", strings.Repeat("a", 129)} {
if err := validateRoomName(name); errs.Code(err) != errs.InvalidArgument {
t.Errorf("expected invalid room name %q, got %v", name, err)
}
}
if err := validateRoomName("demo-123"); err != nil {
t.Fatal(err)
}
for _, raw := range []string{"", "example.com", "ftp://example.com", "https://user:pass@example.com", "https://example.com?key=secret"} {
if _, err := websocketURL(raw); err == nil {
t.Errorf("accepted invalid URL %q", raw)
}
}
for raw, want := range map[string]string{"https://example.com/": "wss://example.com", "http://localhost:7880": "ws://localhost:7880", "wss://example.com": "wss://example.com"} {
if got, err := websocketURL(raw); err != nil || got != want {
t.Errorf("websocketURL(%q) = %q, %v; want %q", raw, got, err, want)
}
}
}
func TestManagementRequiresAuthentication(t *testing.T) {
defer et.OverrideAuthInfo("", nil)
et.OverrideAuthInfo("", nil)
ctx := context.Background()
_, createErr := CreateRoom(ctx, &CreateRoomParams{Name: "demo"})
_, listErr := ListRooms(ctx)
deleteErr := DeleteRoom(ctx, "demo")
_, participantsErr := ListParticipants(ctx, "demo")
removeErr := RemoveParticipant(ctx, "demo", "user-456")
for _, err := range []error{createErr, listErr, deleteErr, participantsErr, removeErr} {
if errs.Code(err) != errs.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
}
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
if _, err := CreateRoom(ctx, &CreateRoomParams{Name: "demo"}); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("only administrators can create rooms: %v", err)
}
}
func TestViewerTokenCannotPublish(t *testing.T) {
token, err := signJoinToken("test-key", "test-secret", "viewer", "demo", false)
if err != nil {
t.Fatal(err)
}
verifier, err := lkauth.ParseAPIToken(token)
if err != nil {
t.Fatal(err)
}
_, grants, err := verifier.Verify("test-secret")
if err != nil {
t.Fatal(err)
}
g := grants.Video
if g.GetCanPublish() || !g.GetCanPublishData() || g.GetCanUpdateOwnMetadata() || !g.GetCanSubscribe() || g.RoomAdmin || g.RoomCreate {
t.Fatalf("unsafe viewer permissions: %+v", g)
}
}
func TestCreateTokenChecksRoomAndIdentity(t *testing.T) {
original := secrets
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
roomExists := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/twirp/livekit.RoomService/ListRooms" {
t.Errorf("unexpected LiveKit operation: %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
var request livekit.ListRoomsRequest
if err != nil || proto.Unmarshal(body, &request) != nil || len(request.Names) != 1 || request.Names[0] != "demo" {
t.Error("room lookup must filter by the requested name")
}
verifier, err := lkauth.ParseAPIToken(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if err != nil {
t.Error(err)
w.WriteHeader(http.StatusUnauthorized)
return
}
_, grants, err := verifier.Verify("test-secret")
if err != nil || grants.Video == nil || !grants.Video.RoomList {
t.Error("missing signed room-list permission")
}
w.Header().Set("Content-Type", "application/protobuf")
result := &livekit.ListRoomsResponse{}
if roomExists {
result.Rooms = []*livekit.Room{{Name: "demo", Metadata: `{"mode":"broadcast","owner_id":"owner-123","display_name":"Demo"}`}}
}
payload, err := proto.Marshal(result)
if err != nil {
t.Error(err)
}
_, _ = w.Write(payload)
}))
defer server.Close()
secrets.LiveKitURL = server.URL
secrets.LiveKitAPIKey = "test-key"
secrets.LiveKitAPISecret = "test-secret"
ctx := context.Background()
et.OverrideAuthInfo("", nil)
if _, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}); errs.Code(err) != errs.Unauthenticated {
t.Fatalf("expected unauthenticated, got %v", err)
}
et.OverrideAuthInfo("user-123", &authsvc.AuthData{Role: "user"})
response, err := CreateToken(ctx, &TokenParams{RoomName: "demo"})
if err != nil {
t.Fatal(err)
}
if response.Role != "viewer" || response.OwnerID != "owner-123" || response.Identity != "user-123" || response.ExpiresIn != 600 || !strings.HasPrefix(response.ServerURL, "ws://") {
t.Fatalf("unexpected token response: %+v", response)
}
verifier, err := lkauth.ParseAPIToken(response.ParticipantToken)
if err != nil {
t.Fatal(err)
}
_, grants, err := verifier.Verify("test-secret")
if err != nil || grants.Identity != "user-123" || grants.Video.Room != "demo" || grants.Video.GetCanPublish() {
t.Fatal("token must be bound to the authenticated user and requested room")
}
for _, role := range []string{"user", "admin"} {
if role == "admin" {
et.OverrideAuthInfo("other-admin", &authsvc.AuthData{Role: "admin"})
}
if err := requireRoomOwner(ctx, mustTestClient(t), "demo"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner must not manage rooms: %v", err)
}
if err := DeleteRoom(ctx, "demo"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner delete: %v", err)
}
if _, err := ListParticipants(ctx, "demo"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner participants: %v", err)
}
if err := RemoveParticipant(ctx, "demo", "viewer"); errs.Code(err) != errs.PermissionDenied {
t.Fatalf("non-owner removal: %v", err)
}
}
et.OverrideAuthInfo("owner-123", &authsvc.AuthData{Role: "admin"})
if err := requireRoomOwner(ctx, mustTestClient(t), "demo"); err != nil {
t.Fatal(err)
}
ownerResponse, err := CreateToken(ctx, &TokenParams{RoomName: "demo"})
if err != nil || ownerResponse.Role != "owner" {
t.Fatalf("owner token: %v", err)
}
ownerVerifier, _ := lkauth.ParseAPIToken(ownerResponse.ParticipantToken)
_, ownerGrants, err := ownerVerifier.Verify("test-secret")
if err != nil || !ownerGrants.Video.GetCanPublish() {
t.Fatal("owner must be able to publish")
}
roomExists = false
if _, err := CreateToken(ctx, &TokenParams{RoomName: "demo"}); errs.Code(err) != errs.NotFound {
t.Fatalf("expected missing room, got %v", err)
}
}
func TestLiveKitErrorMapping(t *testing.T) {
for rpc, want := range map[twirp.ErrorCode]errs.ErrCode{twirp.NotFound: errs.NotFound, twirp.InvalidArgument: errs.InvalidArgument, twirp.Unavailable: errs.Unavailable} {
if err := liveKitError(twirp.NewError(rpc, "upstream error")); errs.Code(err) != want {
t.Errorf("expected %v, got %v", want, err)
}
}
}
func mustTestClient(t *testing.T) *lksdk.RoomServiceClient {
t.Helper()
client, _, err := liveKitClient()
if err != nil {
t.Fatal(err)
}
return client
}
func TestLegacyRoomsHaveNoInferredOwner(t *testing.T) {
for _, metadata := range []string{"", "{}", `{"mode":"broadcast"}`, `{"mode":"meeting","owner_id":"admin","display_name":"demo"}`} {
if _, ok := readBroadcast(&livekit.Room{Metadata: metadata}); ok {
t.Fatalf("accepted unmanaged room: %s", metadata)
}
}
}
// Exercise creation, listing, ownership and moderation against the real SDK's HTTP transport.
func TestBroadcastLifecycle(t *testing.T) {
useTestProfilesDB(t)
original := secrets
defer func() { secrets = original; et.OverrideAuthInfo("", nil) }()
var stored *livekit.Room
removed := ""
deleted := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
var response proto.Message
switch r.URL.Path {
case "/twirp/livekit.RoomService/CreateRoom":
var request livekit.CreateRoomRequest
if err := proto.Unmarshal(data, &request); err != nil {
t.Error(err)
}
stored = &livekit.Room{Sid: "sid-1", Name: request.Name, Metadata: request.Metadata, MaxParticipants: request.MaxParticipants}
response = stored
case "/twirp/livekit.RoomService/ListRooms":
result := &livekit.ListRoomsResponse{}
if stored != nil {
result.Rooms = []*livekit.Room{stored, {Name: "legacy-room"}}
}
response = result
case "/twirp/livekit.RoomService/ListParticipants":
response = &livekit.ListParticipantsResponse{Participants: []*livekit.ParticipantInfo{{Sid: "viewer-sid", Identity: "viewer"}}}
case "/twirp/livekit.RoomService/RemoveParticipant":
var request livekit.RoomParticipantIdentity
if err := proto.Unmarshal(data, &request); err != nil {
t.Error(err)
}
removed = request.Identity
response = &livekit.RemoveParticipantResponse{}
case "/twirp/livekit.RoomService/DeleteRoom":
stored = nil
deleted = true
response = &livekit.DeleteRoomResponse{}
default:
t.Errorf("unexpected operation: %s", r.URL.Path)
w.WriteHeader(404)
return
}
w.Header().Set("Content-Type", "application/protobuf")
payload, _ := proto.Marshal(response)
_, _ = w.Write(payload)
}))
defer server.Close()
secrets.LiveKitURL, secrets.LiveKitAPIKey, secrets.LiveKitAPISecret = server.URL, "test-key", "test-secret"
ctx := context.Background()
et.OverrideAuthInfo("owner", &authsvc.AuthData{Role: "admin"})
created, err := CreateRoom(ctx, &CreateRoomParams{Name: "My broadcast", MaxParticipants: 20})
if err != nil {
t.Fatal(err)
}
if created.OwnerID != "owner" || created.DisplayName != "My broadcast" || created.Mode != "broadcast" || !strings.HasPrefix(created.Name, "broadcast-") || created.MaxParticipants != 20 {
t.Fatalf("invalid created room: %+v", created)
}
firstName := created.Name
created, err = CreateRoom(ctx, &CreateRoomParams{Name: "My broadcast"})
if err != nil || created.Name == firstName {
t.Fatal("duplicate titles must have distinct room identities")
}
et.OverrideAuthInfo("viewer", &authsvc.AuthData{Role: "user"})
listed, err := ListRooms(ctx)
if err != nil || len(listed.Rooms) != 1 || listed.Rooms[0].OwnerID != "owner" {
t.Fatalf("viewer must see broadcasts only: %+v, %v", listed, err)
}
if err := DeleteRoom(ctx, created.Name); errs.Code(err) != errs.PermissionDenied || deleted {
t.Fatal("viewer deleted the room")
}
if err := RemoveParticipant(ctx, created.Name, "owner"); errs.Code(err) != errs.PermissionDenied || removed != "" {
t.Fatal("viewer removed the owner")
}
et.OverrideAuthInfo("owner", &authsvc.AuthData{Role: "admin"})
members, err := ListParticipants(ctx, created.Name)
if err != nil || len(members.Participants) != 1 {
t.Fatalf("owner list participants: %v", err)
}
if err := RemoveParticipant(ctx, created.Name, "owner"); errs.Code(err) != errs.InvalidArgument {
t.Fatal("owner self-removal must be rejected")
}
if err := RemoveParticipant(ctx, created.Name, "viewer"); err != nil || removed != "viewer" {
t.Fatalf("owner remove viewer: %v", err)
}
if err := DeleteRoom(ctx, created.Name); err != nil || !deleted {
t.Fatalf("owner close: %v", err)
}
if _, err := CreateToken(ctx, &TokenParams{RoomName: created.Name}); errs.Code(err) != errs.NotFound {
t.Fatal("closed room must not receive new tokens")
}
}