Browse Source

Add support for PIN locking rooms to the server.

pull/112/head
Lance Cooper 11 years ago committed by Simon Eisenmann
parent
commit
31e4f2c0c0
  1. 57
      doc/CHANNELING-API.txt
  2. 16
      src/app/spreed-webrtc-server/channeling.go
  3. 7
      src/app/spreed-webrtc-server/channelling_api.go
  4. 18
      src/app/spreed-webrtc-server/channelling_api_test.go
  5. 43
      src/app/spreed-webrtc-server/common_test.go
  6. 42
      src/app/spreed-webrtc-server/room_manager.go
  7. 12
      src/app/spreed-webrtc-server/room_manager_test.go
  8. 71
      src/app/spreed-webrtc-server/roomworker.go
  9. 124
      src/app/spreed-webrtc-server/roomworker_test.go

57
doc/CHANNELING-API.txt

@ -157,7 +157,8 @@ Special purpose documents for channling
Hello: { Hello: {
Version: "1.0.0", Version: "1.0.0",
Ua: "Test client 1.0", Ua: "Test client 1.0",
Id: "" Id: "",
"Credentials": {...}
} }
} }
@ -169,13 +170,29 @@ Special purpose documents for channling
Keys under Hello: Keys under Hello:
Version : Channel protocol version (string). Version : Channel protocol version (string).
Ua : User agent description (string). Ua : User agent description (string).
Id : Room id. The default Room has the empty string Id ("") (string). Id : Room id. The default Room has the empty string Id ("") (string).
Credentials : An optional RoomCredentials document containing room
authentication information. See the Room document for
information on how such credentials should be handled after
a Welcome is received for the requested room. Note that
providing credentials while joining an existing room which
does not require them is an error, such requests should be
retried without credentials. In contrast, joining a
non-existent room with credentials will create the room
using the given credentials. Note that an error with a code
of authorization_not_required or invalid_credentials shall
cause the client to discard any cached room credentials.
Error codes: Error codes:
default_room_disabled : Joining the room "" is not allowed by this server. default_room_disabled : Joining the room "" is not allowed by this
server.
authorization_required : Joining the given room requires credentials.
authorization_not_required : No credentials should be provided for this
room.
invalid_credentials : The provided credentials are incorrect.
Welcome Welcome
@ -197,11 +214,27 @@ Special purpose documents for channling
Users: Contains the user list for the room, see the description of Users: Contains the user list for the room, see the description of
the Users document for more details. the Users document for more details.
RoomCredentials
{
"PIN": "my-super-sekrit-code"
}
RoomCredentials contains room authentication information, and is used as a
child document when joining or updating a room.
Keys under RoomCredentials:
PIN : A password string which may be used by clients to authenticate
themselves. Note that acceptable characters for this field may be
constrained by the server based upon its configuration.
Room Room
{ {
"Type": "Room", "Type": "Room",
"Name": "room-name-here" "Name": "room-name-here"
"Credentials": {...}
} }
Clients may send a Room document in order to update all room properties Clients may send a Room document in order to update all room properties
@ -214,7 +247,19 @@ Special purpose documents for channling
Keys under Room: Keys under Room:
Name: The human readable ID of the room, currently must be globally unique. Name : The human readable ID of the room, currently must be globally
unique.
Credentials : Optional authentication information for the room, see the
documentation of the RoomCredentials document for more
details. This field shall only be present when sending or
receiving an update which alters room authentication data.
It should only be inferred that authentication is not
required if joining a room succeeds without credentials and
no updates containing credentials has been received.
Authentication may be disabled by sending a Room document
containing a RoomCredentials document with only empty
fields. Clients shall discard any cached authentication
information upon receiving such an update.
Error codes: Error codes:

16
src/app/spreed-webrtc-server/channeling.go

@ -31,10 +31,15 @@ func (err *DataError) Error() string {
return err.Message return err.Message
} }
type DataRoomCredentials struct {
PIN string
}
type DataHello struct { type DataHello struct {
Version string Version string
Ua string Ua string
Id string Id string
Credentials *DataRoomCredentials
} }
type DataWelcome struct { type DataWelcome struct {
@ -44,8 +49,9 @@ type DataWelcome struct {
} }
type DataRoom struct { type DataRoom struct {
Type string Type string
Name string Name string
Credentials *DataRoomCredentials
} }
type DataOffer struct { type DataOffer struct {

7
src/app/spreed-webrtc-server/channelling_api.go

@ -87,14 +87,13 @@ func (api *channellingAPI) OnIncoming(c ResponseSender, session *Session, msg *D
// NOTE(lcooper): Iid filtered for compatibility's sake. // NOTE(lcooper): Iid filtered for compatibility's sake.
// Evaluate sending unconditionally when supported by all clients. // Evaluate sending unconditionally when supported by all clients.
if api.CanJoinRoom(msg.Hello.Id) { if room, err := api.JoinRoom(msg.Hello.Id, msg.Hello.Credentials, session, c); err == nil {
session.Hello = true session.Hello = true
session.Roomid = msg.Hello.Id session.Roomid = msg.Hello.Id
api.JoinRoom(session, c)
if msg.Iid != "" { if msg.Iid != "" {
c.Reply(msg.Iid, &DataWelcome{ c.Reply(msg.Iid, &DataWelcome{
Type: "Welcome", Type: "Welcome",
Room: &DataRoom{Name: msg.Hello.Id}, Room: room,
Users: api.RoomUsers(session), Users: api.RoomUsers(session),
}) })
} }
@ -102,7 +101,7 @@ func (api *channellingAPI) OnIncoming(c ResponseSender, session *Session, msg *D
} else { } else {
session.Hello = false session.Hello = false
if msg.Iid != "" { if msg.Iid != "" {
c.Reply(msg.Iid, &DataError{Type: "Error", Code: "default_room_disabled", Message: "The default room is not enabled"}) c.Reply(msg.Iid, err)
} }
} }
case "Offer": case "Offer":

18
src/app/spreed-webrtc-server/channelling_api_test.go

@ -22,6 +22,7 @@
package main package main
import ( import (
"errors"
"testing" "testing"
) )
@ -45,27 +46,24 @@ func (fake *fakeClient) Reply(iid string, msg interface{}) {
} }
type fakeRoomManager struct { type fakeRoomManager struct {
disallowJoin bool
joinedRoomID string joinedRoomID string
leftRoomID string leftRoomID string
roomUsers []*DataSession roomUsers []*DataSession
joinedID string joinedID string
joinError error
leftID string leftID string
broadcasts []interface{} broadcasts []interface{}
updatedRoom *DataRoom updatedRoom *DataRoom
updateError error updateError error
} }
func (fake *fakeRoomManager) CanJoinRoom(roomID string) bool {
return !fake.disallowJoin
}
func (fake *fakeRoomManager) RoomUsers(session *Session) []*DataSession { func (fake *fakeRoomManager) RoomUsers(session *Session) []*DataSession {
return fake.roomUsers return fake.roomUsers
} }
func (fake *fakeRoomManager) JoinRoom(session *Session, _ Sender) { func (fake *fakeRoomManager) JoinRoom(id string, _ *DataRoomCredentials, session *Session, _ Sender) (*DataRoom, error) {
fake.joinedID = session.Roomid fake.joinedID = id
return &DataRoom{Name: id}, fake.joinError
} }
func (fake *fakeRoomManager) LeaveRoom(session *Session) { func (fake *fakeRoomManager) LeaveRoom(session *Session) {
@ -159,7 +157,7 @@ func Test_ChannellingAPI_OnIncoming_HelloMessage_LeavesAnyPreviouslyJoinedRooms(
func Test_ChannellingAPI_OnIncoming_HelloMessage_DoesNotJoinIfNotPermitted(t *testing.T) { func Test_ChannellingAPI_OnIncoming_HelloMessage_DoesNotJoinIfNotPermitted(t *testing.T) {
api, client, session, roomManager := NewTestChannellingAPI() api, client, session, roomManager := NewTestChannellingAPI()
roomManager.disallowJoin = true roomManager.joinError = errors.New("Can't enter this room")
api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Hello: &DataHello{}}) api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Hello: &DataHello{}})
@ -201,11 +199,11 @@ func Test_ChannellingAPI_OnIncoming_HelloMessageWithAnIid_RespondsWithAWelcome(t
func Test_ChannellingAPI_OnIncoming_HelloMessageWithAnIid_RespondsWithAnErrorIfTheRoomCannotBeJoined(t *testing.T) { func Test_ChannellingAPI_OnIncoming_HelloMessageWithAnIid_RespondsWithAnErrorIfTheRoomCannotBeJoined(t *testing.T) {
iid := "foo" iid := "foo"
api, client, session, roomManager := NewTestChannellingAPI() api, client, session, roomManager := NewTestChannellingAPI()
roomManager.disallowJoin = true roomManager.joinError = &DataError{Type: "Error", Code: "bad_join"}
api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Iid: iid, Hello: &DataHello{}}) api.OnIncoming(client, session, &DataIncoming{Type: "Hello", Iid: iid, Hello: &DataHello{}})
assertErrorReply(t, client, iid, "default_room_disabled") assertErrorReply(t, client, iid, "bad_join")
} }
func Test_ChannellingAPI_OnIncoming_RoomMessage_RespondsWithAndBroadcastsTheUpdatedRoom(t *testing.T) { func Test_ChannellingAPI_OnIncoming_RoomMessage_RespondsWithAndBroadcastsTheUpdatedRoom(t *testing.T) {

43
src/app/spreed-webrtc-server/common_test.go

@ -0,0 +1,43 @@
/*
* Spreed WebRTC.
* Copyright (C) 2013-2014 struktur AG
*
* This file is part of Spreed WebRTC.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package main
import (
"testing"
)
func assertDataError(t *testing.T, err error, code string) {
if err == nil {
t.Error("Expected an error, but none was returned")
return
}
dataError, ok := err.(*DataError)
if !ok {
t.Errorf("Expected error %#v to be a *DataError", err)
return
}
if code != dataError.Code {
t.Errorf("Expected error code to be %v, but was %v", code, dataError.Code)
}
}

42
src/app/spreed-webrtc-server/room_manager.go

@ -27,9 +27,8 @@ import (
) )
type RoomStatusManager interface { type RoomStatusManager interface {
CanJoinRoom(id string) bool
RoomUsers(*Session) []*DataSession RoomUsers(*Session) []*DataSession
JoinRoom(*Session, Sender) JoinRoom(string, *DataRoomCredentials, *Session, Sender) (*DataRoom, error)
LeaveRoom(*Session) LeaveRoom(*Session)
UpdateRoom(*Session, *DataRoom) (*DataRoom, error) UpdateRoom(*Session, *DataRoom) (*DataRoom, error)
} }
@ -66,20 +65,24 @@ func NewRoomManager(config *Config, encoder OutgoingEncoder) RoomManager {
} }
} }
func (rooms *roomManager) CanJoinRoom(id string) bool {
return id != "" || rooms.defaultRoomEnabled
}
func (rooms *roomManager) RoomUsers(session *Session) []*DataSession { func (rooms *roomManager) RoomUsers(session *Session) []*DataSession {
return <-rooms.GetOrCreate(session).GetUsers() if room, ok := rooms.Get(session.Roomid); ok {
return room.GetUsers()
}
// TODO(lcooper): This should return an error.
return []*DataSession{}
} }
func (rooms *roomManager) JoinRoom(session *Session, sender Sender) { func (rooms *roomManager) JoinRoom(id string, credentials *DataRoomCredentials, session *Session, sender Sender) (*DataRoom, error) {
rooms.GetOrCreate(session).Join(session, sender) if id == "" && !rooms.defaultRoomEnabled {
return nil, &DataError{Type: "Error", Code: "default_room_disabled", Message: "The default room is not enabled"}
}
return rooms.GetOrCreate(id, credentials).Join(credentials, session, sender)
} }
func (rooms *roomManager) LeaveRoom(session *Session) { func (rooms *roomManager) LeaveRoom(session *Session) {
if room, ok := rooms.Get(session); ok { if room, ok := rooms.Get(session.Roomid); ok {
room.Leave(session) room.Leave(session)
} }
} }
@ -91,6 +94,10 @@ func (rooms *roomManager) UpdateRoom(session *Session, room *DataRoom) (*DataRoo
// XXX(lcooper): We'll process and send documents without this field // XXX(lcooper): We'll process and send documents without this field
// correctly, however clients cannot not handle it currently. // correctly, however clients cannot not handle it currently.
room.Type = "Room" room.Type = "Room"
if roomWorker, ok := rooms.Get(session.Roomid); ok {
return room, roomWorker.Update(room)
}
// TODO(lcooper): We should almost certainly return an error in this case.
return room, nil return room, nil
} }
@ -113,8 +120,10 @@ func (rooms *roomManager) Broadcast(session *Session, m interface{}) {
room.Broadcast(session, message) room.Broadcast(session, message)
} }
rooms.RUnlock() rooms.RUnlock()
} else if room, ok := rooms.Get(id); ok {
room.Broadcast(session, message)
} else { } else {
rooms.GetOrCreate(session).Broadcast(session, message) log.Printf("No room named %s found for broadcast message %#v", id, m)
} }
message.Decref() message.Decref()
} }
@ -133,23 +142,22 @@ func (rooms *roomManager) RoomInfo(includeSessions bool) (count int, sessionInfo
return return
} }
func (rooms *roomManager) Get(session *Session) (room RoomWorker, ok bool) { func (rooms *roomManager) Get(id string) (room RoomWorker, ok bool) {
rooms.RLock() rooms.RLock()
room, ok = rooms.roomTable[session.Roomid] room, ok = rooms.roomTable[id]
rooms.RUnlock() rooms.RUnlock()
return return
} }
func (rooms *roomManager) GetOrCreate(session *Session) RoomWorker { func (rooms *roomManager) GetOrCreate(id string, credentials *DataRoomCredentials) RoomWorker {
room, ok := rooms.Get(session) room, ok := rooms.Get(id)
if !ok { if !ok {
id := session.Roomid
rooms.Lock() rooms.Lock()
// Need to re-check, another thread might have created the room // Need to re-check, another thread might have created the room
// while we waited for the lock. // while we waited for the lock.
room, ok = rooms.roomTable[id] room, ok = rooms.roomTable[id]
if !ok { if !ok {
room = NewRoomWorker(rooms, id) room = NewRoomWorker(rooms, id, credentials)
rooms.roomTable[id] = room rooms.roomTable[id] = room
rooms.Unlock() rooms.Unlock()
go func() { go func() {

12
src/app/spreed-webrtc-server/room_manager_test.go

@ -29,18 +29,6 @@ func NewTestRoomManager() RoomManager {
return NewRoomManager(&Config{}, nil) return NewRoomManager(&Config{}, nil)
} }
func assertDataError(t *testing.T, err error, code string) {
dataError, ok := err.(*DataError)
if !ok {
t.Errorf("Expected error %#v to be a *DataError", err)
return
}
if code != dataError.Code {
t.Errorf("Expected error code to be %v, but was %v", code, dataError.Code)
}
}
func Test_RoomManager_UpdateRoom_ReturnsAnErrorIfNoRoomHasBeenJoined(t *testing.T) { func Test_RoomManager_UpdateRoom_ReturnsAnErrorIfNoRoomHasBeenJoined(t *testing.T) {
roomManager := NewTestRoomManager() roomManager := NewTestRoomManager()
_, err := roomManager.UpdateRoom(&Session{}, nil) _, err := roomManager.UpdateRoom(&Session{}, nil)

71
src/app/spreed-webrtc-server/roomworker.go

@ -22,6 +22,7 @@
package main package main
import ( import (
"crypto/subtle"
"log" "log"
"sync" "sync"
"time" "time"
@ -36,9 +37,10 @@ type RoomWorker interface {
Start() Start()
SessionIDs() []string SessionIDs() []string
Users() []*roomUser Users() []*roomUser
GetUsers() <-chan []*DataSession Update(*DataRoom) error
GetUsers() []*DataSession
Broadcast(*Session, Buffer) Broadcast(*Session, Buffer)
Join(*Session, Sender) Join(*DataRoomCredentials, *Session, Sender) (*DataRoom, error)
Leave(*Session) Leave(*Session)
} }
@ -54,7 +56,8 @@ type roomWorker struct {
mutex sync.RWMutex mutex sync.RWMutex
// Metadata. // Metadata.
Id string Id string
credentials *DataRoomCredentials
} }
type roomUser struct { type roomUser struct {
@ -62,7 +65,7 @@ type roomUser struct {
Sender Sender
} }
func NewRoomWorker(manager *roomManager, id string) RoomWorker { func NewRoomWorker(manager *roomManager, id string, credentials *DataRoomCredentials) RoomWorker {
log.Printf("Creating worker for room '%s'\n", id) log.Printf("Creating worker for room '%s'\n", id)
@ -74,6 +77,10 @@ func NewRoomWorker(manager *roomManager, id string) RoomWorker {
users: make(map[string]*roomUser), users: make(map[string]*roomUser),
} }
if credentials != nil && len(credentials.PIN) > 0 {
r.credentials = credentials
}
// Create expire timer. // Create expire timer.
r.timer = time.AfterFunc(roomExpiryDuration, func() { r.timer = time.AfterFunc(roomExpiryDuration, func() {
r.expired <- true r.expired <- true
@ -148,7 +155,25 @@ func (r *roomWorker) Run(f func()) bool {
} }
func (r *roomWorker) GetUsers() <-chan []*DataSession { func (r *roomWorker) Update(room *DataRoom) error {
fault := make(chan error, 1)
worker := func() {
r.mutex.Lock()
if room.Credentials != nil {
if len(room.Credentials.PIN) > 0 {
r.credentials = room.Credentials
} else {
r.credentials = nil
}
}
r.mutex.Unlock()
fault <- nil
}
r.Run(worker)
return <-fault
}
func (r *roomWorker) GetUsers() []*DataSession {
out := make(chan []*DataSession, 1) out := make(chan []*DataSession, 1)
worker := func() { worker := func() {
var sl []*DataSession var sl []*DataSession
@ -185,7 +210,7 @@ func (r *roomWorker) GetUsers() <-chan []*DataSession {
} }
r.Run(worker) r.Run(worker)
return out return <-out
} }
func (r *roomWorker) Broadcast(session *Session, message Buffer) { func (r *roomWorker) Broadcast(session *Session, message Buffer) {
@ -209,13 +234,43 @@ func (r *roomWorker) Broadcast(session *Session, message Buffer) {
} }
func (r *roomWorker) Join(session *Session, sender Sender) { type joinResult struct {
*DataRoom
error
}
func (r *roomWorker) Join(credentials *DataRoomCredentials, session *Session, sender Sender) (*DataRoom, error) {
results := make(chan joinResult, 1)
worker := func() { worker := func() {
r.mutex.Lock() r.mutex.Lock()
defer r.mutex.Unlock() if r.credentials == nil && credentials != nil {
results <- joinResult{nil, &DataError{"Error", "authorization_not_required", "No credentials may be provided for this room"}}
r.mutex.Unlock()
return
} else if r.credentials != nil {
if credentials == nil {
results <- joinResult{nil, &DataError{"Error", "authorization_required", "Valid credentials are required to join this room"}}
r.mutex.Unlock()
return
}
if len(r.credentials.PIN) != len(credentials.PIN) || subtle.ConstantTimeCompare([]byte(r.credentials.PIN), []byte(credentials.PIN)) != 1 {
results <- joinResult{nil, &DataError{"Error", "invalid_credentials", "The provided credentials are incorrect"}}
r.mutex.Unlock()
return
}
}
r.users[session.Id] = &roomUser{session, sender} r.users[session.Id] = &roomUser{session, sender}
// NOTE(lcooper): Needs to be a copy, else we risk races with
// a subsequent modification of room properties.
result := joinResult{&DataRoom{Name: r.Id}, nil}
r.mutex.Unlock()
results <- result
} }
r.Run(worker) r.Run(worker)
result := <-results
return result.DataRoom, result.error
} }
func (r *roomWorker) Leave(session *Session) { func (r *roomWorker) Leave(session *Session) {

124
src/app/spreed-webrtc-server/roomworker_test.go

@ -0,0 +1,124 @@
/*
* Spreed WebRTC.
* Copyright (C) 2013-2014 struktur AG
*
* This file is part of Spreed WebRTC.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package main
import (
"testing"
)
const (
testRoomName string = "a-room-name"
)
func NewTestRoomWorker() RoomWorker {
worker := NewRoomWorker(&roomManager{}, testRoomName, nil)
go worker.Start()
return worker
}
func NewTestRoomWorkerWithPIN(t *testing.T) (RoomWorker, string) {
pin := "asdf"
worker := NewRoomWorker(&roomManager{}, testRoomName, &DataRoomCredentials{PIN: pin})
go worker.Start()
return worker, pin
}
func Test_RoomWorker_Join_SucceedsWhenNoCredentialsAreRequired(t *testing.T) {
worker := NewTestRoomWorker()
_, err := worker.Join(nil, &Session{}, nil)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
if userCount := len(worker.GetUsers()); userCount != 1 {
t.Errorf("Expected join to have been accepted but room contains %d users", userCount)
}
}
func Test_RoomWorker_Join_FailsIfCredentialsAreGivenWhenUnneeded(t *testing.T) {
worker := NewTestRoomWorker()
_, err := worker.Join(&DataRoomCredentials{}, &Session{}, nil)
assertDataError(t, err, "authorization_not_required")
if userCount := len(worker.GetUsers()); userCount != 0 {
t.Errorf("Expected join to have been rejected but room contains %d users", userCount)
}
}
func Test_RoomWorker_Join_FailsIfNoCredentialsAreGiven(t *testing.T) {
worker, _ := NewTestRoomWorkerWithPIN(t)
_, err := worker.Join(nil, &Session{}, nil)
assertDataError(t, err, "authorization_required")
if userCount := len(worker.GetUsers()); userCount != 0 {
t.Errorf("Expected join to have been rejected but room contains %d users", userCount)
}
}
func Test_RoomWorker_Join_FailsIfIncorrectCredentialsAreGiven(t *testing.T) {
worker, _ := NewTestRoomWorkerWithPIN(t)
_, err := worker.Join(&DataRoomCredentials{PIN: "adfs"}, &Session{}, nil)
assertDataError(t, err, "invalid_credentials")
if userCount := len(worker.GetUsers()); userCount != 0 {
t.Errorf("Expected join to have been rejected but room contains %d users", userCount)
}
}
func Test_RoomWorker_Join_SucceedsWhenTheCorrectPINIsGiven(t *testing.T) {
worker, pin := NewTestRoomWorkerWithPIN(t)
if _, err := worker.Join(&DataRoomCredentials{PIN: pin}, &Session{}, nil); err != nil {
t.Fatalf("Unexpected error %v", err)
}
if len(worker.GetUsers()) < 1 {
t.Error("Expected join to have been accepted but room contains no users")
}
}
func Test_RoomWorker_Update_AllowsClearingCredentials(t *testing.T) {
worker, _ := NewTestRoomWorkerWithPIN(t)
if err := worker.Update(&DataRoom{Credentials: &DataRoomCredentials{PIN: ""}}); err != nil {
t.Fatalf("Failed to update room: %v", err)
}
_, err := worker.Join(&DataRoomCredentials{}, &Session{}, nil)
assertDataError(t, err, "authorization_not_required")
}
func Test_RoomWorker_Update_RetainsCredentialsWhenOtherPropertiesAreUpdated(t *testing.T) {
worker, pin := NewTestRoomWorkerWithPIN(t)
if err := worker.Update(&DataRoom{}); err != nil {
t.Fatalf("Failed to update room: %v", err)
}
if _, err := worker.Join(&DataRoomCredentials{PIN: pin}, &Session{}, nil); err != nil {
t.Fatalf("Unexpected error joining room %v", err)
}
}
Loading…
Cancel
Save