- Add LiveKit client setup and token generation for room access. - Implement room management functions including CreateRoom, OpenRoom, ListRooms, and DeleteRoom. - Introduce participant management with ListParticipants and RemoveParticipant functions. - Create tests for room and participant management, ensuring proper authentication and authorization. - Add error handling for LiveKit API interactions. - Define data structures for room metadata and token responses. - Implement status endpoint for service availability check.
326 lines
12 KiB
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")
|
|
}
|
|
}
|