Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2774 lines
108 KiB
Go
2774 lines
108 KiB
Go
package signal
|
|
|
|
// Ported from tests/server-signal.test.ts (baseline b20fd88): the 43 cases
|
|
// of describe("WebSocket signaling"). The harness mirrors startHarness: a
|
|
// memory room.Store, short timer options (authentication 500 ms, viewer
|
|
// grace 50 ms, heartbeat disabled at 60 s), an httptest.Server whose every
|
|
// request reaches Server.ServeHTTP, real coder/websocket clients and a
|
|
// MessageInbox port. Room access updates call the server directly where the
|
|
// TS went through HTTP; the site-access cookie is a stub predicate.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/TNTcraftHIM/Screener/internal/server/config"
|
|
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
|
|
"github.com/TNTcraftHIM/Screener/internal/server/room"
|
|
"github.com/TNTcraftHIM/Screener/internal/server/sfu"
|
|
"github.com/TNTcraftHIM/Screener/internal/server/sfu/sfutest"
|
|
)
|
|
|
|
const (
|
|
allowedOrigin = "http://allowed.test"
|
|
siteAccessCookie = "screener-site-access=granted"
|
|
defaultWait = 2 * time.Second
|
|
)
|
|
|
|
func TestCreateRoomReleasesLockWhenDerivationOrCommitPanics(t *testing.T) {
|
|
for _, password := range []string{"", "room-password"} {
|
|
t.Run(password, func(t *testing.T) {
|
|
store, err := room.New(room.Options{
|
|
LeaseMs: 86_400_000, MaxRooms: 1, MaxViewersPerRoom: 1,
|
|
Random: func(int) []byte { panic("test random source") },
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := &Server{store: store}
|
|
func() {
|
|
defer func() {
|
|
if value := recover(); value != "test random source" {
|
|
t.Errorf("panic = %v, want original random-source panic", value)
|
|
}
|
|
}()
|
|
_, _ = server.CreateRoom(protocol.CodeEntryOpen, &password, "")
|
|
}()
|
|
if !server.mu.TryLock() {
|
|
t.Fatal("CreateRoom left the server locked after a recovered panic")
|
|
}
|
|
server.mu.Unlock()
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// harness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type harnessOptions struct {
|
|
authenticationTimeoutMs int
|
|
viewerDisconnectGraceMs int
|
|
maxViewersPerRoom int
|
|
maxSignalConnections int
|
|
maxUnauthenticatedSignalConnections int
|
|
siteAccessPassword string
|
|
endpointMediaCopyCapacity int
|
|
stunURLs []string
|
|
natPredictionEnabled bool
|
|
now func() int64
|
|
cleanupIntervalMs int
|
|
store *room.Store
|
|
room *room.CreatedRoom
|
|
afterFunc func(time.Duration, func()) func() bool
|
|
sfu *SfuFallback
|
|
}
|
|
|
|
type harness struct {
|
|
t *testing.T
|
|
server *Server
|
|
http *httptest.Server
|
|
store *room.Store
|
|
room room.CreatedRoom
|
|
wsURL string
|
|
closed bool
|
|
}
|
|
|
|
func orValue(value, fallback int) int {
|
|
if value == 0 {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func newStore(t *testing.T, maxViewersPerRoom int, now func() int64, database *room.Database) *room.Store {
|
|
t.Helper()
|
|
store, err := room.New(room.Options{
|
|
LeaseMs: 86_400_000,
|
|
MaxRooms: room.Capacity,
|
|
MaxViewersPerRoom: maxViewersPerRoom,
|
|
Now: now,
|
|
Database: database,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("room.New: %v", err)
|
|
}
|
|
if database != nil {
|
|
if err := store.Initialize(); err != nil {
|
|
t.Fatalf("store.Initialize: %v", err)
|
|
}
|
|
}
|
|
return store
|
|
}
|
|
|
|
// createStoreRoom is roomStore.createRoom() on a store no server guards yet.
|
|
func createStoreRoom(t *testing.T, store *room.Store, policy protocol.CodeEntryPolicy, password string) room.CreatedRoom {
|
|
t.Helper()
|
|
lease, err := store.BeginCreateRoom()
|
|
if err != nil {
|
|
t.Fatalf("BeginCreateRoom: %v", err)
|
|
}
|
|
var material []byte
|
|
var derived error
|
|
if password != "" {
|
|
material, derived = store.DeriveViewerPasswordMaterial(password, nil)
|
|
}
|
|
created, err := store.CreateRoom(policy, material, derived, "", lease)
|
|
if err != nil {
|
|
t.Fatalf("CreateRoom: %v", err)
|
|
}
|
|
return created
|
|
}
|
|
|
|
func startHarness(t *testing.T, options harnessOptions) *harness {
|
|
t.Helper()
|
|
maxViewersPerRoom := orValue(options.maxViewersPerRoom, 8)
|
|
store := options.store
|
|
if store == nil {
|
|
store = newStore(t, maxViewersPerRoom, options.now, nil)
|
|
}
|
|
var created room.CreatedRoom
|
|
if options.room != nil {
|
|
created = *options.room
|
|
} else {
|
|
created = createStoreRoom(t, store, protocol.CodeEntryOpen, "")
|
|
}
|
|
publicBaseURL, err := url.Parse("https://share.example.test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server, err := New(Options{
|
|
Store: store,
|
|
EndpointMediaCopyCapacity: orValue(options.endpointMediaCopyCapacity, 2),
|
|
SfuFallback: options.sfu,
|
|
Ice: config.IceConfig(config.Config{
|
|
STUNURLs: options.stunURLs,
|
|
NATPredictionEnabled: options.natPredictionEnabled,
|
|
}),
|
|
NATPredictionEnabled: options.natPredictionEnabled,
|
|
AllowedOrigins: map[string]struct{}{allowedOrigin: {}},
|
|
SiteAccessAtUpgrade: func(request *http.Request) bool {
|
|
return options.siteAccessPassword == "" ||
|
|
strings.Contains(request.Header.Get("Cookie"), siteAccessCookie)
|
|
},
|
|
PublicBaseURL: publicBaseURL,
|
|
Now: options.now,
|
|
AuthenticationTimeoutMs: orValue(options.authenticationTimeoutMs, 500),
|
|
ViewerDisconnectGraceMs: orValue(options.viewerDisconnectGraceMs, 50),
|
|
HeartbeatIntervalMs: 60_000,
|
|
CleanupIntervalMs: orValue(options.cleanupIntervalMs, 60_000),
|
|
MaxConnections: options.maxSignalConnections,
|
|
MaxUnauthenticatedConnections: options.maxUnauthenticatedSignalConnections,
|
|
AfterFunc: options.afterFunc,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("signal.New: %v", err)
|
|
}
|
|
httpServer := httptest.NewServer(server)
|
|
h := &harness{
|
|
t: t,
|
|
server: server,
|
|
http: httpServer,
|
|
store: store,
|
|
room: created,
|
|
wsURL: "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/signal",
|
|
}
|
|
t.Cleanup(h.close)
|
|
return h
|
|
}
|
|
|
|
// close is runningServer.close(): the signaling server first, then the
|
|
// listener and the store. It is idempotent so tests may close early.
|
|
func (h *harness) close() {
|
|
if h.closed {
|
|
return
|
|
}
|
|
h.closed = true
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := h.server.Close(ctx); err != nil {
|
|
h.t.Errorf("server.Close: %v", err)
|
|
}
|
|
h.http.Close()
|
|
if err := h.store.Close(); err != nil {
|
|
h.t.Errorf("store.Close: %v", err)
|
|
}
|
|
}
|
|
|
|
// locked runs fn under the server's mutex, which is how a test may touch the
|
|
// store while the server runs (the TS store was shared on one thread).
|
|
func (h *harness) locked(fn func()) {
|
|
h.server.mu.Lock()
|
|
defer h.server.mu.Unlock()
|
|
fn()
|
|
}
|
|
|
|
func (h *harness) sessionCount() int {
|
|
var count int
|
|
h.locked(func() { count = h.server.sessions.Len() })
|
|
return count
|
|
}
|
|
|
|
func (h *harness) storeSize() int {
|
|
var size int
|
|
h.locked(func() { size = h.store.Size() })
|
|
return size
|
|
}
|
|
|
|
func (h *harness) updateRoomAccess(request protocol.RoomAccessUpdateRequest) protocol.RoomAccessUpdateResponse {
|
|
h.t.Helper()
|
|
response, err := h.server.UpdateRoomAccess(h.room.RoomID, h.room.HostToken, request)
|
|
if err != nil {
|
|
h.t.Fatalf("UpdateRoomAccess: %v", err)
|
|
}
|
|
return response
|
|
}
|
|
|
|
func (h *harness) createRoom(policy protocol.CodeEntryPolicy, password string) room.CreatedRoom {
|
|
h.t.Helper()
|
|
var roomPassword *string
|
|
if password != "" {
|
|
roomPassword = &password
|
|
}
|
|
created, err := h.server.CreateRoom(policy, roomPassword, "")
|
|
if err != nil {
|
|
h.t.Fatalf("CreateRoom: %v", err)
|
|
}
|
|
return created
|
|
}
|
|
|
|
func waitFor(t *testing.T, timeout time.Duration, condition func() bool) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
if condition() {
|
|
return true
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
return condition()
|
|
}
|
|
|
|
// expectGoroutinesSettled is the close tests' stand-in for the race detector
|
|
// (unavailable without cgo): every goroutine the server started (readers,
|
|
// writers, pings, pump drivers, drains, timer callbacks) must have exited,
|
|
// so the count returns to what it was before the harness started.
|
|
func expectGoroutinesSettled(t *testing.T, before int) {
|
|
t.Helper()
|
|
if waitFor(t, defaultWait, func() bool { return runtime.NumGoroutine() <= before }) {
|
|
return
|
|
}
|
|
stacks := make([]byte, 1<<20)
|
|
t.Fatalf("goroutines: %d before, %d after close\n%s",
|
|
before, runtime.NumGoroutine(), stacks[:runtime.Stack(stacks, true)])
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type inbound struct {
|
|
typ string
|
|
raw []byte
|
|
}
|
|
|
|
type closeEvent struct {
|
|
code int
|
|
reason string
|
|
}
|
|
|
|
// testClient is MessageInbox plus the socket.
|
|
type testClient struct {
|
|
t *testing.T
|
|
conn *websocket.Conn
|
|
|
|
mu sync.Mutex
|
|
messages []inbound
|
|
ignored map[string]bool
|
|
decodeErrors []string
|
|
notify chan struct{}
|
|
closed chan closeEvent
|
|
}
|
|
|
|
func dialClient(t *testing.T, wsURL, origin, cookie string) (*websocket.Conn, *http.Response, error) {
|
|
t.Helper()
|
|
header := http.Header{}
|
|
header.Set("Origin", origin)
|
|
if cookie != "" {
|
|
header.Set("Cookie", cookie)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: header})
|
|
}
|
|
|
|
func openClient(t *testing.T, h *harness) *testClient {
|
|
t.Helper()
|
|
return openClientWithCookie(t, h, "")
|
|
}
|
|
|
|
func openClientWithCookie(t *testing.T, h *harness, cookie string) *testClient {
|
|
t.Helper()
|
|
conn, _, err := dialClient(t, h.wsURL, allowedOrigin, cookie)
|
|
if err != nil {
|
|
t.Fatalf("websocket.Dial: %v", err)
|
|
}
|
|
return newTestClient(t, conn)
|
|
}
|
|
|
|
func newTestClient(t *testing.T, conn *websocket.Conn) *testClient {
|
|
t.Helper()
|
|
conn.SetReadLimit(1 << 20)
|
|
client := &testClient{
|
|
t: t,
|
|
conn: conn,
|
|
ignored: map[string]bool{},
|
|
notify: make(chan struct{}, 1),
|
|
closed: make(chan closeEvent, 1),
|
|
}
|
|
go client.readLoop()
|
|
t.Cleanup(func() {
|
|
client.mu.Lock()
|
|
defer client.mu.Unlock()
|
|
for _, failure := range client.decodeErrors {
|
|
t.Error(failure)
|
|
}
|
|
})
|
|
return client
|
|
}
|
|
|
|
func (c *testClient) readLoop() {
|
|
for {
|
|
_, data, err := c.conn.Read(context.Background())
|
|
if err != nil {
|
|
event := closeEvent{code: -1, reason: err.Error()}
|
|
var closeError websocket.CloseError
|
|
if errors.As(err, &closeError) {
|
|
event = closeEvent{code: int(closeError.Code), reason: closeError.Reason}
|
|
}
|
|
c.closed <- event
|
|
return
|
|
}
|
|
var probe struct {
|
|
Type string `json:"type"`
|
|
}
|
|
_ = json.Unmarshal(data, &probe)
|
|
c.mu.Lock()
|
|
// TS: decodeServerMessage(data) validates every server message.
|
|
if _, err := protocol.DecodeServerMessage(data); err != nil {
|
|
c.decodeErrors = append(c.decodeErrors,
|
|
fmt.Sprintf("server message %q failed the wire schema: %v", probe.Type, err))
|
|
}
|
|
if !c.ignored[probe.Type] {
|
|
c.messages = append(c.messages, inbound{typ: probe.Type, raw: data})
|
|
}
|
|
c.mu.Unlock()
|
|
select {
|
|
case c.notify <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *testClient) sendText(text string) {
|
|
c.t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := c.conn.Write(ctx, websocket.MessageText, []byte(text)); err != nil {
|
|
c.t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
|
|
func (c *testClient) sendJSON(message map[string]any) {
|
|
c.t.Helper()
|
|
encoded, err := json.Marshal(message)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
c.sendText(string(encoded))
|
|
}
|
|
|
|
func (c *testClient) tryNext(typ string, timeout time.Duration) (inbound, bool) {
|
|
deadline := time.Now().Add(timeout)
|
|
for {
|
|
c.mu.Lock()
|
|
for index, message := range c.messages {
|
|
if message.typ == typ {
|
|
c.messages = slices.Delete(c.messages, index, index+1)
|
|
c.mu.Unlock()
|
|
return message, true
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
remaining := time.Until(deadline)
|
|
if remaining <= 0 {
|
|
return inbound{}, false
|
|
}
|
|
select {
|
|
case <-c.notify:
|
|
case <-time.After(remaining):
|
|
}
|
|
}
|
|
}
|
|
|
|
// next is inbox.next(type): the first buffered message of that type, or the
|
|
// next one to arrive.
|
|
func (c *testClient) next(typ string) inbound {
|
|
c.t.Helper()
|
|
return c.nextWithin(typ, defaultWait)
|
|
}
|
|
|
|
func (c *testClient) nextWithin(typ string, timeout time.Duration) inbound {
|
|
c.t.Helper()
|
|
message, ok := c.tryNext(typ, timeout)
|
|
if !ok {
|
|
c.t.Fatalf("Timed out waiting for %s", typ)
|
|
}
|
|
return message
|
|
}
|
|
|
|
// expectNone is inbox.expectNone(timeoutMs).
|
|
func (c *testClient) expectNone(timeout time.Duration) {
|
|
c.t.Helper()
|
|
check := func() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if len(c.messages) > 0 {
|
|
c.t.Fatalf("Unexpected message: %s", c.messages[0].typ)
|
|
}
|
|
}
|
|
check()
|
|
time.Sleep(timeout)
|
|
check()
|
|
}
|
|
|
|
// ignore is inbox.ignore(type).
|
|
func (c *testClient) ignore(typ string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.ignored[typ] = true
|
|
c.messages = slices.DeleteFunc(c.messages, func(message inbound) bool { return message.typ == typ })
|
|
}
|
|
|
|
// closeCode waits for the socket's close event.
|
|
func (c *testClient) closeCode() closeEvent {
|
|
c.t.Helper()
|
|
select {
|
|
case event := <-c.closed:
|
|
c.closed <- event
|
|
return event
|
|
case <-time.After(defaultWait):
|
|
c.t.Fatalf("Timed out waiting for the socket to close")
|
|
return closeEvent{}
|
|
}
|
|
}
|
|
|
|
func (c *testClient) isClosed() bool {
|
|
select {
|
|
case event := <-c.closed:
|
|
c.closed <- event
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// closeClient is closeClient(): a client-initiated close that also waits
|
|
// for the server to have run handleDisconnect, which the single-threaded TS
|
|
// tests sequenced through later messages.
|
|
func (h *harness) closeClient(c *testClient) {
|
|
h.t.Helper()
|
|
if c.isClosed() {
|
|
return
|
|
}
|
|
before := h.sessionCount()
|
|
_ = c.conn.Close(websocket.StatusNormalClosure, "")
|
|
c.closeCode()
|
|
if !waitFor(h.t, defaultWait, func() bool { return h.sessionCount() < before }) {
|
|
h.t.Fatalf("server did not release the closed session")
|
|
}
|
|
}
|
|
|
|
func rejectedUpgradeStatus(t *testing.T, h *harness, origin string) int {
|
|
t.Helper()
|
|
conn, response, err := dialClient(t, h.wsURL, origin, "")
|
|
if err == nil {
|
|
_ = conn.CloseNow()
|
|
t.Fatalf("WebSocket upgrade unexpectedly succeeded")
|
|
}
|
|
if response == nil {
|
|
t.Fatalf("upgrade failed without an HTTP response: %v", err)
|
|
}
|
|
return response.StatusCode
|
|
}
|
|
|
|
func rawUpgradeResponse(t *testing.T, h *harness, requestTarget string) string {
|
|
t.Helper()
|
|
parsed, err := url.Parse(h.wsURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn, err := net.DialTimeout("tcp", parsed.Host, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
|
request := strings.Join([]string{
|
|
"GET " + requestTarget + " HTTP/1.1",
|
|
"Host: " + parsed.Host,
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
|
"Sec-WebSocket-Version: 13",
|
|
"Origin: " + allowedOrigin,
|
|
"",
|
|
"",
|
|
}, "\r\n")
|
|
if _, err := conn.Write([]byte(request)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
response, _ := io.ReadAll(conn)
|
|
return string(response)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON assertions (expect(...).toEqual / toMatchObject on decoded JSON)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func jsonValue(t *testing.T, raw []byte) any {
|
|
t.Helper()
|
|
var value any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
t.Fatalf("invalid JSON %s: %v", raw, err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func jsonObject(t *testing.T, raw []byte) map[string]any {
|
|
t.Helper()
|
|
object, ok := jsonValue(t, raw).(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected a JSON object, got %s", raw)
|
|
}
|
|
return object
|
|
}
|
|
|
|
// matches is toMatchObject: objects are subset-matched recursively, arrays
|
|
// must have equal length with matching elements, scalars must be equal.
|
|
func matches(actual, expected any) bool {
|
|
switch expected := expected.(type) {
|
|
case map[string]any:
|
|
actualObject, ok := actual.(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for key, value := range expected {
|
|
actualValue, present := actualObject[key]
|
|
if !present || !matches(actualValue, value) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case []any:
|
|
actualList, ok := actual.([]any)
|
|
if !ok || len(actualList) != len(expected) {
|
|
return false
|
|
}
|
|
for index := range expected {
|
|
if !matches(actualList[index], expected[index]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return reflect.DeepEqual(actual, expected)
|
|
}
|
|
|
|
func expectEqual(t *testing.T, raw []byte, expected string) {
|
|
t.Helper()
|
|
if !reflect.DeepEqual(jsonValue(t, raw), jsonValue(t, []byte(expected))) {
|
|
t.Fatalf("expected exactly %s\ngot %s", expected, raw)
|
|
}
|
|
}
|
|
|
|
func expectMatch(t *testing.T, raw []byte, expected string) {
|
|
t.Helper()
|
|
if !matches(jsonValue(t, raw), jsonValue(t, []byte(expected))) {
|
|
t.Fatalf("expected a match of %s\ngot %s", expected, raw)
|
|
}
|
|
}
|
|
|
|
func hasKey(t *testing.T, raw []byte, key string) bool {
|
|
t.Helper()
|
|
_, present := jsonObject(t, raw)[key]
|
|
return present
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// typed views of the messages the tests inspect
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type authenticatedMessage struct {
|
|
PeerID string `json:"peerId"`
|
|
HostOnline bool `json:"hostOnline"`
|
|
HostPaused *bool `json:"hostPaused"`
|
|
ConnectionID *string `json:"connectionId"`
|
|
RoutePolicy protocol.RoutePolicy `json:"routePolicy"`
|
|
RouteRevision int64 `json:"routeRevision"`
|
|
RouteAssignment struct {
|
|
Upstream protocol.MediaRouteUpstream `json:"upstream"`
|
|
} `json:"routeAssignment"`
|
|
QualitySettings json.RawMessage `json:"qualitySettings"`
|
|
ViewerAuthorizationGeneration string `json:"viewerAuthorizationGeneration"`
|
|
MaxViewers int `json:"maxViewers"`
|
|
ShareGeneration *string `json:"shareGeneration"`
|
|
Role string `json:"role"`
|
|
CodeEntryPolicy string `json:"codeEntryPolicy"`
|
|
IceConfig json.RawMessage `json:"iceConfig"`
|
|
raw []byte
|
|
}
|
|
|
|
func parseAuthenticated(t *testing.T, raw []byte) authenticatedMessage {
|
|
t.Helper()
|
|
var message authenticatedMessage
|
|
if err := json.Unmarshal(raw, &message); err != nil {
|
|
t.Fatalf("authenticated: %v (%s)", err, raw)
|
|
}
|
|
message.raw = raw
|
|
return message
|
|
}
|
|
|
|
type routeUpdate struct {
|
|
Revision int64 `json:"revision"`
|
|
Phase string `json:"phase"`
|
|
Assignment struct {
|
|
Upstream protocol.MediaRouteUpstream `json:"upstream"`
|
|
ChildPeerIDs []string `json:"childPeerIds"`
|
|
} `json:"assignment"`
|
|
Candidate struct {
|
|
ChildPeerID string `json:"childPeerId"`
|
|
ConnectionID string `json:"connectionId"`
|
|
Transport string `json:"transport"`
|
|
QualityProbe bool `json:"qualityProbe"`
|
|
} `json:"candidate"`
|
|
raw []byte
|
|
}
|
|
|
|
func parseRouteUpdate(t *testing.T, raw []byte) routeUpdate {
|
|
t.Helper()
|
|
var message routeUpdate
|
|
if err := json.Unmarshal(raw, &message); err != nil {
|
|
t.Fatalf("route-update: %v (%s)", err, raw)
|
|
}
|
|
message.raw = raw
|
|
return message
|
|
}
|
|
|
|
type presenceEntry struct {
|
|
Role string `json:"role"`
|
|
PeerID string `json:"peerId"`
|
|
DisplayName string `json:"displayName"`
|
|
Upstream protocol.MediaRouteUpstream `json:"upstream"`
|
|
MediaReady *bool `json:"mediaReady,omitempty"`
|
|
}
|
|
|
|
type presenceMessage struct {
|
|
Viewers []presenceEntry `json:"viewers"`
|
|
raw []byte
|
|
}
|
|
|
|
func parsePresence(t *testing.T, raw []byte) presenceMessage {
|
|
t.Helper()
|
|
var message presenceMessage
|
|
if err := json.Unmarshal(raw, &message); err != nil {
|
|
t.Fatalf("viewer-presence: %v (%s)", err, raw)
|
|
}
|
|
message.raw = raw
|
|
return message
|
|
}
|
|
|
|
func viewerPresenceEntries(message presenceMessage) []presenceEntry {
|
|
var viewers []presenceEntry
|
|
for _, entry := range message.Viewers {
|
|
if entry.Role == protocol.RoleViewer {
|
|
viewers = append(viewers, entry)
|
|
}
|
|
}
|
|
return viewers
|
|
}
|
|
|
|
// viewerEntriesJSON re-encodes viewerPresenceEntries(message) so the TS
|
|
// assertions on that filtered list can use the JSON matchers.
|
|
func viewerEntriesJSON(t *testing.T, message presenceMessage) []byte {
|
|
t.Helper()
|
|
entries := viewerPresenceEntries(message)
|
|
if entries == nil {
|
|
entries = []presenceEntry{}
|
|
}
|
|
encoded, err := json.Marshal(entries)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
func hostPresenceEntry(message presenceMessage) *presenceEntry {
|
|
for _, entry := range message.Viewers {
|
|
if entry.Role == protocol.RoleHost {
|
|
return &entry
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func displayNames(entries []presenceEntry) []string {
|
|
names := []string{}
|
|
for _, entry := range entries {
|
|
names = append(names, entry.DisplayName)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func peerIDs(entries []presenceEntry) []string {
|
|
ids := []string{}
|
|
for _, entry := range entries {
|
|
ids = append(ids, entry.PeerID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func isMediaReady(entry presenceEntry) bool {
|
|
return entry.MediaReady != nil && *entry.MediaReady
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// protocol helpers (authenticate, routes, presence, evidence)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type presenceOptions struct {
|
|
displayName string
|
|
viewerPresence bool
|
|
viewerPassword string
|
|
codeOnly bool
|
|
sharingPaused *bool
|
|
qualitySettings map[string]any
|
|
routePolicy *protocol.RoutePolicy
|
|
}
|
|
|
|
// authenticate ports the authenticate() helper. relayCapacity -1 is the TS
|
|
// null (no relay-capacity message after the authenticated reply).
|
|
func authenticate(
|
|
t *testing.T,
|
|
client *testClient,
|
|
rm room.CreatedRoom,
|
|
role protocol.Role,
|
|
clientID string,
|
|
relayCapacity int,
|
|
shareGeneration string,
|
|
presence presenceOptions,
|
|
) authenticatedMessage {
|
|
t.Helper()
|
|
message := map[string]any{
|
|
"type": "authenticate",
|
|
"protocol": protocol.SignalingProtocol,
|
|
"roomId": rm.RoomID,
|
|
"role": role,
|
|
"clientId": clientID,
|
|
}
|
|
if role == protocol.RoleHost {
|
|
message["token"] = rm.HostToken
|
|
if shareGeneration != "" {
|
|
message["shareGeneration"] = shareGeneration
|
|
}
|
|
if presence.sharingPaused != nil {
|
|
message["sharingPaused"] = *presence.sharingPaused
|
|
}
|
|
if presence.qualitySettings != nil {
|
|
message["qualitySettings"] = presence.qualitySettings
|
|
}
|
|
if presence.routePolicy != nil {
|
|
message["routePolicy"] = *presence.routePolicy
|
|
}
|
|
if presence.viewerPresence {
|
|
message["viewerPresence"] = true
|
|
}
|
|
if presence.displayName != "" {
|
|
message["displayName"] = presence.displayName
|
|
}
|
|
} else {
|
|
if presence.viewerPassword != "" {
|
|
message["viewerPassword"] = presence.viewerPassword
|
|
} else if !presence.codeOnly && rm.ViewerGrant != "" {
|
|
message["viewerGrant"] = rm.ViewerGrant
|
|
}
|
|
if presence.displayName != "" {
|
|
message["displayName"] = presence.displayName
|
|
}
|
|
if presence.viewerPresence {
|
|
message["viewerPresence"] = true
|
|
}
|
|
}
|
|
client.sendJSON(message)
|
|
authenticated := parseAuthenticated(t, client.next("authenticated").raw)
|
|
if role == protocol.RoleViewer && relayCapacity >= 0 {
|
|
client.sendJSON(map[string]any{"type": "relay-capacity", "downstreamEdges": relayCapacity})
|
|
}
|
|
return authenticated
|
|
}
|
|
|
|
func boolPointer(value bool) *bool { return &value }
|
|
|
|
func nextPreparedRoute(t *testing.T, client *testClient) routeUpdate {
|
|
t.Helper()
|
|
for {
|
|
message := parseRouteUpdate(t, client.next("route-update").raw)
|
|
if message.Phase == "prepare" {
|
|
return message
|
|
}
|
|
}
|
|
}
|
|
|
|
func nextActiveRouteRevision(t *testing.T, client *testClient, revision int64) routeUpdate {
|
|
t.Helper()
|
|
for {
|
|
message := parseRouteUpdate(t, client.next("route-update").raw)
|
|
if message.Phase == "active" && message.Revision == revision {
|
|
return message
|
|
}
|
|
}
|
|
}
|
|
|
|
func nextActiveRouteAfter(t *testing.T, client *testClient, revision int64) routeUpdate {
|
|
t.Helper()
|
|
for {
|
|
message := parseRouteUpdate(t, client.next("route-update").raw)
|
|
if message.Phase == "active" && message.Revision > revision {
|
|
return message
|
|
}
|
|
}
|
|
}
|
|
|
|
func commitPreparedRoute(t *testing.T, client *testClient) routeUpdate {
|
|
t.Helper()
|
|
prepared := nextPreparedRoute(t, client)
|
|
client.sendJSON(map[string]any{
|
|
"type": "route-transport-connected",
|
|
"revision": prepared.Revision,
|
|
"connectionId": prepared.Candidate.ConnectionID,
|
|
})
|
|
client.sendJSON(map[string]any{
|
|
"type": "route-ready",
|
|
"revision": prepared.Revision,
|
|
"phase": "prepare",
|
|
})
|
|
return nextActiveRouteRevision(t, client, prepared.Revision)
|
|
}
|
|
|
|
func nextViewerPresenceMatching(t *testing.T, client *testClient, predicate func(presenceMessage) bool) presenceMessage {
|
|
t.Helper()
|
|
for attempt := 0; attempt < 20; attempt++ {
|
|
message := parsePresence(t, client.next("viewer-presence").raw)
|
|
if predicate(message) {
|
|
return message
|
|
}
|
|
}
|
|
t.Fatalf("Viewer presence did not reach the expected state")
|
|
return presenceMessage{}
|
|
}
|
|
|
|
func expectNoViewerPresenceMatching(t *testing.T, client *testClient, predicate func(presenceMessage) bool, timeout time.Duration) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
message, ok := client.tryNext("viewer-presence", time.Until(deadline))
|
|
if !ok {
|
|
return
|
|
}
|
|
if predicate(parsePresence(t, message.raw)) {
|
|
t.Fatalf("Viewer presence reached a forbidden state: %s", message.raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func viewerQualityEvidenceMessage(connectionID string, routeRevision, sequence, presentationEpoch int64) map[string]any {
|
|
return map[string]any{
|
|
"type": "viewer-quality-evidence",
|
|
"guard": map[string]any{
|
|
"connectionId": connectionID,
|
|
"routeRevision": routeRevision,
|
|
"presentationEpoch": presentationEpoch,
|
|
},
|
|
"sequence": sequence,
|
|
"windowMs": 2_000,
|
|
"metrics": map[string]any{
|
|
"natTraversalPath": "ordinary",
|
|
"width": 1_920,
|
|
"height": 1_080,
|
|
"framesPerSecond": 60,
|
|
"bitrateKbps": 7_500,
|
|
"packetsReceivedDelta": 1_500,
|
|
"packetsLostDelta": 2,
|
|
"rttMs": 18,
|
|
"jitterMs": 3.5,
|
|
"framesDecodedDelta": 120,
|
|
"framesDroppedDelta": 1,
|
|
"decodeMsPerFrame": 2.4,
|
|
"freezeCountDelta": 0,
|
|
"freezeDurationMsDelta": 0,
|
|
"pauseCountDelta": 0,
|
|
"pauseDurationMsDelta": 0,
|
|
"codec": "video/VP8",
|
|
"codecProfile": nil,
|
|
"codecParameters": nil,
|
|
"audioBitrateKbps": 192,
|
|
"audioPacketLossPercent": 0.2,
|
|
"audioJitterMs": 2.5,
|
|
"audioVideoPlayoutDeltaMs": -12.5,
|
|
"videoJitterBufferDelayMs": 24,
|
|
"audioJitterBufferDelayMs": 18,
|
|
"audioConcealedSamplesPercent": 1,
|
|
"audioConcealmentEventsDelta": 3,
|
|
"audioCodec": "audio/opus",
|
|
},
|
|
}
|
|
}
|
|
|
|
func signalDescription(targetPeerID, connectionID, descriptionType string) map[string]any {
|
|
return map[string]any{
|
|
"type": "signal",
|
|
"targetPeerId": targetPeerID,
|
|
"payload": map[string]any{
|
|
"kind": "description",
|
|
"connectionId": connectionID,
|
|
"description": map[string]any{"type": descriptionType, "sdp": "v=0\r\n"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func signalCandidate(targetPeerID, connectionID string) map[string]any {
|
|
return map[string]any{
|
|
"type": "signal",
|
|
"targetPeerId": targetPeerID,
|
|
"payload": map[string]any{
|
|
"kind": "candidate",
|
|
"connectionId": connectionID,
|
|
"candidate": nil,
|
|
},
|
|
}
|
|
}
|
|
|
|
func utcMillis(year int, month time.Month, day, hour int) int64 {
|
|
return time.Date(year, month, day, hour, 0, 0, 0, time.UTC).UnixMilli()
|
|
}
|
|
|
|
type clock struct{ ms atomic.Int64 }
|
|
|
|
func newClock(start int64) *clock {
|
|
c := &clock{}
|
|
c.ms.Store(start)
|
|
return c
|
|
}
|
|
|
|
func (c *clock) now() int64 { return c.ms.Load() }
|
|
func (c *clock) advance(deltaMs int64) { c.ms.Add(deltaMs) }
|
|
|
|
func expectClose(t *testing.T, client *testClient, code int, reason string) {
|
|
t.Helper()
|
|
event := client.closeCode()
|
|
if event.code != code || event.reason != reason {
|
|
t.Fatalf("expected close {%d %q}, got {%d %q}", code, reason, event.code, event.reason)
|
|
}
|
|
}
|
|
|
|
func expectCloseCode(t *testing.T, client *testClient, code int) {
|
|
t.Helper()
|
|
if event := client.closeCode(); event.code != code {
|
|
t.Fatalf("expected close code %d, got {%d %q}", code, event.code, event.reason)
|
|
}
|
|
}
|
|
|
|
func expectString(t *testing.T, name, actual, expected string) {
|
|
t.Helper()
|
|
if actual != expected {
|
|
t.Fatalf("%s: expected %q, got %q", name, expected, actual)
|
|
}
|
|
}
|
|
|
|
func expectStrings(t *testing.T, name string, actual, expected []string) {
|
|
t.Helper()
|
|
if !slices.Equal(actual, expected) {
|
|
t.Fatalf("%s: expected %q, got %q", name, expected, actual)
|
|
}
|
|
}
|
|
|
|
func expectTrue(t *testing.T, name string, condition bool) {
|
|
t.Helper()
|
|
if !condition {
|
|
t.Fatalf("%s", name)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// route policy and ICE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalEchoesExactPerShareRoutePolicyAuthority(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{
|
|
stunURLs: []string{"stun:share.example.test:3478"},
|
|
natPredictionEnabled: true,
|
|
})
|
|
viewer := openClient(t, h)
|
|
waiting := authenticate(t, viewer, h.room, protocol.RoleViewer, "route-policy-viewer", 1, "", presenceOptions{})
|
|
expectTrue(t, "waiting shareGeneration must be null", waiting.ShareGeneration == nil)
|
|
host := openClient(t, h)
|
|
routePolicy := protocol.RoutePolicy{PeerOnly: true, TopologyOptimization: true, NatPrediction: true}
|
|
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "route-policy-host", 1,
|
|
"route_policy_share_generation_12345678", presenceOptions{routePolicy: &routePolicy})
|
|
if authenticated.RoutePolicy != routePolicy {
|
|
t.Fatalf("routePolicy: %+v", authenticated.RoutePolicy)
|
|
}
|
|
expectTrue(t, "sfuStandbyUrl must be absent", !hasKey(t, authenticated.raw, "sfuStandbyUrl"))
|
|
expectEqual(t, viewer.next("route-policy").raw,
|
|
`{"type":"route-policy","shareGeneration":"route_policy_share_generation_12345678","routePolicy":{"peerOnly":true,"topologyOptimization":true,"natPrediction":true}}`)
|
|
}
|
|
|
|
func TestSignalBroadcastsConfiguredNatPredictionInLightweightRooms(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{
|
|
stunURLs: []string{"stun:share.example.test:3478"},
|
|
natPredictionEnabled: true,
|
|
})
|
|
waitingViewer := openClient(t, h)
|
|
waiting := authenticate(t, waitingViewer, h.room, protocol.RoleViewer, "ordinary-nat-waiting-viewer", -1, "", presenceOptions{})
|
|
expectTrue(t, "waiting natPrediction must be false", !waiting.RoutePolicy.NatPrediction)
|
|
|
|
host := openClient(t, h)
|
|
routePolicy := protocol.DefaultRoutePolicy
|
|
routePolicy.NatPrediction = true
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "ordinary-nat-host", -1,
|
|
"ordinary_nat_share_generation_12345678", presenceOptions{routePolicy: &routePolicy})
|
|
if hostAuth.RoutePolicy != routePolicy {
|
|
t.Fatalf("routePolicy: %+v", hostAuth.RoutePolicy)
|
|
}
|
|
expectMatch(t, waitingViewer.next("route-policy").raw,
|
|
`{"routePolicy":{"peerOnly":false,"topologyOptimization":true,"natPrediction":true}}`)
|
|
|
|
joiningViewer := openClient(t, h)
|
|
joined := authenticate(t, joiningViewer, h.room, protocol.RoleViewer, "ordinary-nat-joining-viewer", -1, "", presenceOptions{})
|
|
if joined.RoutePolicy != routePolicy {
|
|
t.Fatalf("joined routePolicy: %+v", joined.RoutePolicy)
|
|
}
|
|
}
|
|
|
|
func TestSignalClampsNatPredictionOffWhenServerCapabilityIsDisabled(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
routePolicy := protocol.DefaultRoutePolicy
|
|
routePolicy.NatPrediction = true
|
|
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "disabled-nat-host", -1,
|
|
"disabled_nat_share_generation_12345678", presenceOptions{routePolicy: &routePolicy})
|
|
expectTrue(t, "natPrediction must be clamped off", !authenticated.RoutePolicy.NatPrediction)
|
|
}
|
|
|
|
func TestSignalDeliversOnlySelfHostedAuxiliaryNatStunEndpoints(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{
|
|
stunURLs: []string{"stun:ordinary.example.test:3478"},
|
|
natPredictionEnabled: true,
|
|
})
|
|
viewer := openClient(t, h)
|
|
authenticated := authenticate(t, viewer, h.room, protocol.RoleViewer, "nat-auxiliary-viewer", -1, "", presenceOptions{})
|
|
expectEqual(t, authenticated.IceConfig,
|
|
`{"iceServers":[{"urls":["stun:ordinary.example.test:3478"]}],"natPredictionStunUrls":["stun:ordinary.example.test:3479","stun:ordinary.example.test:3480"]}`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// lifecycle and restart
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalRebuildsRouteWhenViewerReconnectsBeforeHostAfterRestart(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "rooms.sqlite")
|
|
firstDatabase, err := room.NewDatabase(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
firstStore := newStore(t, 8, nil, firstDatabase)
|
|
rm := createStoreRoom(t, firstStore, protocol.CodeEntryOpen, "")
|
|
first := startHarness(t, harnessOptions{store: firstStore, room: &rm})
|
|
firstHost := openClient(t, first)
|
|
authenticate(t, firstHost, rm, protocol.RoleHost, "restart-route-host", 1, "restart_share_generation_12345678", presenceOptions{})
|
|
firstViewer := openClient(t, first)
|
|
authenticate(t, firstViewer, rm, protocol.RoleViewer, "restart-route-viewer", 1, "", presenceOptions{})
|
|
nextPreparedRoute(t, firstViewer)
|
|
|
|
first.close()
|
|
|
|
secondDatabase, err := room.NewDatabase(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secondStore := newStore(t, 8, nil, secondDatabase)
|
|
second := startHarness(t, harnessOptions{store: secondStore, room: &rm})
|
|
secondViewer := openClient(t, second)
|
|
viewerAuth := authenticate(t, secondViewer, rm, protocol.RoleViewer, "restart-route-viewer", 1, "", presenceOptions{})
|
|
expectString(t, "viewer upstream", viewerAuth.RouteAssignment.Upstream.Kind, "none")
|
|
|
|
secondHost := openClient(t, second)
|
|
authenticate(t, secondHost, rm, protocol.RoleHost, "restart-route-host", 1, "restart_share_generation_12345678", presenceOptions{})
|
|
viewerPrepare := nextPreparedRoute(t, secondViewer)
|
|
hostPrepare := nextPreparedRoute(t, secondHost)
|
|
if viewerPrepare.Revision != hostPrepare.Revision || viewerPrepare.Candidate != hostPrepare.Candidate {
|
|
t.Fatalf("viewer prepare %s\nhost prepare %s", viewerPrepare.raw, hostPrepare.raw)
|
|
}
|
|
}
|
|
|
|
func TestSignalIdentifiesGracefulServiceRestartToConnectedClients(t *testing.T) {
|
|
before := runtime.NumGoroutine()
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "restart-host", 1, "", presenceOptions{})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
shutdown := make(chan error, 1)
|
|
go func() { shutdown <- h.server.Close(ctx) }()
|
|
|
|
expectClose(t, host, 1012, "Service restart")
|
|
if err := <-shutdown; err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
h.close()
|
|
expectGoroutinesSettled(t, before)
|
|
}
|
|
|
|
// TS signaling.ts:251-256: forceCloseTimer terminates every client that has
|
|
// not finished the close handshake one second after close() began, however
|
|
// many there are; the library's own handshake timeout (5 s) never decides.
|
|
func TestSignalCloseTerminatesUnresponsiveClientsAfterOneSecondGrace(t *testing.T) {
|
|
before := runtime.NumGoroutine()
|
|
h := startHarness(t, harnessOptions{authenticationTimeoutMs: 60_000})
|
|
var conns []*websocket.Conn
|
|
for range 2 {
|
|
// Never read: the close frame is never answered.
|
|
conn, _, err := dialClient(t, h.wsURL, allowedOrigin, "")
|
|
if err != nil {
|
|
t.Fatalf("websocket.Dial: %v", err)
|
|
}
|
|
conns = append(conns, conn)
|
|
}
|
|
if !waitFor(t, defaultWait, func() bool { return h.sessionCount() == 2 }) {
|
|
t.Fatalf("sessions = %d, want 2", h.sessionCount())
|
|
}
|
|
|
|
started := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := h.server.Close(ctx); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if elapsed := time.Since(started); elapsed < serviceRestartCloseGrace || elapsed > 3*time.Second {
|
|
t.Fatalf("Close took %v, want the one-second grace", elapsed)
|
|
}
|
|
for _, conn := range conns {
|
|
_ = conn.CloseNow()
|
|
}
|
|
h.close()
|
|
expectGoroutinesSettled(t, before)
|
|
}
|
|
|
|
func TestSignalEndsEveryRoomBeforeLocalAuthorityStops(t *testing.T) {
|
|
before := runtime.NumGoroutine()
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "ending-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "ending-viewer", 1, "", presenceOptions{})
|
|
|
|
// TS: server.end() = endAllRooms() then close().
|
|
if err := h.server.EndAllRooms(); err != nil {
|
|
t.Fatalf("EndAllRooms: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
shutdown := make(chan error, 1)
|
|
go func() { shutdown <- h.server.Close(ctx) }()
|
|
|
|
expectEqual(t, host.next("room-closed").raw, `{"type":"room-closed","reason":"host-ended"}`)
|
|
expectEqual(t, viewer.next("room-closed").raw, `{"type":"room-closed","reason":"host-ended"}`)
|
|
expectClose(t, host, 1000, "Room abandoned")
|
|
expectClose(t, viewer, 1000, "Room abandoned")
|
|
if err := <-shutdown; err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
if size := h.storeSize(); size != 0 {
|
|
t.Fatalf("store size %d", size)
|
|
}
|
|
h.close()
|
|
expectGoroutinesSettled(t, before)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// challenge and diagnostics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalAnswersOnlyOptedInCurrentSocketsAndRateLimitsEachSocket(t *testing.T) {
|
|
now := newClock(time.Now().UnixMilli())
|
|
h := startHarness(t, harnessOptions{now: now.now})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "challenge-host", 1, "", presenceOptions{})
|
|
nextActiveRouteRevision(t, host, hostAuth.RouteRevision)
|
|
|
|
host.sendJSON(map[string]any{"type": "signaling-challenge", "sequence": 7})
|
|
expectEqual(t, host.next("signaling-challenge-response").raw,
|
|
`{"type":"signaling-challenge-response","sequence":7}`)
|
|
|
|
host.sendJSON(map[string]any{"type": "signaling-challenge", "sequence": 8})
|
|
host.expectNone(40 * time.Millisecond)
|
|
|
|
now.advance(1_000)
|
|
host.sendJSON(map[string]any{"type": "signaling-challenge", "sequence": 9})
|
|
expectEqual(t, host.next("signaling-challenge-response").raw,
|
|
`{"type":"signaling-challenge-response","sequence":9}`)
|
|
}
|
|
|
|
func TestSignalServesOneCurrentRouteSnapshotOnlyToAuthenticatedHost(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "route-diagnostic-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "route-diagnostic-viewer", 1, "", presenceOptions{})
|
|
|
|
host.sendJSON(map[string]any{"type": "request-route-diagnostic"})
|
|
expectMatch(t, host.next("route-diagnostic-snapshot").raw,
|
|
`{"snapshot":{"children":[{"ordinal":1,"finalRoute":"waiting"}]}}`)
|
|
|
|
viewer.sendJSON(map[string]any{"type": "request-route-diagnostic"})
|
|
expectMatch(t, viewer.next("error").raw, `{"code":"FORBIDDEN"}`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// native wire compatibility
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalKeepsExactNativeHostWireUnchangedWithoutPresenceOptIn(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "native-compatible-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "native-compatible-viewer", 1, "",
|
|
presenceOptions{displayName: "移动观众"})
|
|
hostPrepared := nextPreparedRoute(t, host)
|
|
viewerActive := commitPreparedRoute(t, viewer)
|
|
if hostPrepared.Revision != viewerActive.Revision {
|
|
t.Fatalf("host prepare revision %d, viewer active revision %d", hostPrepared.Revision, viewerActive.Revision)
|
|
}
|
|
nextActiveRouteRevision(t, host, viewerActive.Revision)
|
|
host.expectNone(40 * time.Millisecond)
|
|
|
|
host.sendJSON(map[string]any{"type": "set-display-name", "displayName": "Native 不应改名"})
|
|
expectMatch(t, host.next("error").raw, `{"code":"FORBIDDEN"}`)
|
|
host.expectNone(40 * time.Millisecond)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// password and code entry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func sendPasswordAuthenticate(client *testClient, rm room.CreatedRoom, clientID, password string) {
|
|
client.sendJSON(map[string]any{
|
|
"type": "authenticate",
|
|
"protocol": protocol.SignalingProtocol,
|
|
"roomId": rm.RoomID,
|
|
"role": protocol.RoleViewer,
|
|
"clientId": clientID,
|
|
"viewerPassword": password,
|
|
})
|
|
}
|
|
|
|
func TestSignalKeepsPasswordOptionalForPrivateRoomEntry(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "password-settings-host", 1, "", presenceOptions{})
|
|
|
|
updated := h.updateRoomAccess(protocol.SetCodeEntryPolicyRequest{Action: "set-code-entry-policy", Policy: protocol.CodeEntryPrivate})
|
|
if !reflect.DeepEqual(updated, protocol.CodeEntryPolicyUpdatedResponse{
|
|
Type: "code-entry-policy-updated", CodeEntryPolicy: protocol.CodeEntryPrivate, ViewerPasswordEnabled: false,
|
|
}) {
|
|
t.Fatalf("code entry update: %+v", updated)
|
|
}
|
|
|
|
wrongViewer := openClient(t, h)
|
|
sendPasswordAuthenticate(wrongViewer, h.room, "wrong-password-viewer", "wrong-password")
|
|
expectMatch(t, wrongViewer.next("error").raw, `{"code":"ROOM_ACCESS_DENIED"}`)
|
|
|
|
password := "easy-password"
|
|
enabled := h.updateRoomAccess(protocol.SetViewerPasswordRequest{Action: "set-viewer-password", Password: &password})
|
|
if !reflect.DeepEqual(enabled, protocol.ViewerPasswordUpdatedResponse{Type: "viewer-password-updated", Enabled: true}) {
|
|
t.Fatalf("password update: %+v", enabled)
|
|
}
|
|
|
|
passwordViewer := openClient(t, h)
|
|
admitted := authenticate(t, passwordViewer, h.room, protocol.RoleViewer, "password-viewer", 1, "",
|
|
presenceOptions{viewerPassword: "easy-password"})
|
|
expectString(t, "role", admitted.Role, protocol.RoleViewer)
|
|
commitPreparedRoute(t, passwordViewer)
|
|
|
|
disabled := h.updateRoomAccess(protocol.SetViewerPasswordRequest{Action: "set-viewer-password", Password: nil})
|
|
if !reflect.DeepEqual(disabled, protocol.ViewerPasswordUpdatedResponse{Type: "viewer-password-updated", Enabled: false}) {
|
|
t.Fatalf("password removal: %+v", disabled)
|
|
}
|
|
|
|
grantViewer := openClient(t, h)
|
|
granted := authenticate(t, grantViewer, h.room, protocol.RoleViewer, "grant-after-password-removal", 1, "", presenceOptions{})
|
|
expectString(t, "role", granted.Role, protocol.RoleViewer)
|
|
removedPasswordViewer := openClient(t, h)
|
|
sendPasswordAuthenticate(removedPasswordViewer, h.room, "removed-password-viewer", "easy-password")
|
|
expectMatch(t, removedPasswordViewer.next("error").raw, `{"code":"ROOM_ACCESS_DENIED"}`)
|
|
}
|
|
|
|
func TestSignalReportsPasswordConfigurationOnlyToAuthenticatedHost(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
h.updateRoomAccess(protocol.SetCodeEntryPolicyRequest{Action: "set-code-entry-policy", Policy: protocol.CodeEntryPrivate})
|
|
|
|
host := openClient(t, h)
|
|
withoutPassword := authenticate(t, host, h.room, protocol.RoleHost, "password-state-host", 1, "", presenceOptions{})
|
|
expectMatch(t, withoutPassword.raw, `{"role":"host","codeEntryPolicy":"private","viewerPasswordEnabled":false}`)
|
|
|
|
viewer := openClient(t, h)
|
|
viewerAuthenticated := authenticate(t, viewer, h.room, protocol.RoleViewer, "password-state-viewer", 1, "", presenceOptions{})
|
|
expectTrue(t, "viewerPasswordEnabled must be absent for viewers", !hasKey(t, viewerAuthenticated.raw, "viewerPasswordEnabled"))
|
|
|
|
password := "room-password"
|
|
h.updateRoomAccess(protocol.SetViewerPasswordRequest{Action: "set-viewer-password", Password: &password})
|
|
replacementHost := openClient(t, h)
|
|
withPassword := authenticate(t, replacementHost, h.room, protocol.RoleHost, "password-state-host", 1, "", presenceOptions{})
|
|
expectMatch(t, withPassword.raw, `{"role":"host","codeEntryPolicy":"private","viewerPasswordEnabled":true}`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// presence roster
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalReportsEveryOnlineViewerWithoutExpandingHostMediaFanout(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "presence-host", 1, "", presenceOptions{viewerPresence: true})
|
|
nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 0
|
|
})
|
|
|
|
viewerNames := []string{"阿明", "阿青", ""}
|
|
var viewers []*testClient
|
|
for index, displayName := range viewerNames {
|
|
viewer := openClient(t, h)
|
|
viewers = append(viewers, viewer)
|
|
authenticated := authenticate(t, viewer, h.room, protocol.RoleViewer,
|
|
fmt.Sprintf("presence-viewer-%d", index), 1, "", presenceOptions{displayName: displayName})
|
|
prepared := nextPreparedRoute(t, viewer)
|
|
viewer.sendJSON(map[string]any{
|
|
"type": "route-transport-connected",
|
|
"revision": prepared.Revision,
|
|
"connectionId": prepared.Candidate.ConnectionID,
|
|
})
|
|
viewer.sendJSON(map[string]any{"type": "route-ready", "revision": prepared.Revision, "phase": "prepare"})
|
|
expectTrue(t, "prepare revision must exceed the authenticated revision", prepared.Revision > authenticated.RouteRevision)
|
|
nextActiveRouteRevision(t, viewer, prepared.Revision)
|
|
}
|
|
|
|
isRelay := func(viewer presenceEntry) bool {
|
|
return viewer.Upstream.Kind == "peer" && viewer.Upstream.PeerID != hostAuth.PeerID
|
|
}
|
|
full := nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
entries := viewerPresenceEntries(message)
|
|
return len(entries) == 3 && slices.ContainsFunc(entries, isRelay)
|
|
})
|
|
fullEntries := viewerPresenceEntries(full)
|
|
expectStrings(t, "display names", displayNames(fullEntries), []string{"阿明", "阿青", "观众"})
|
|
directChildren := 0
|
|
for _, viewer := range fullEntries {
|
|
if viewer.Upstream.Kind == "peer" && viewer.Upstream.PeerID == hostAuth.PeerID {
|
|
directChildren++
|
|
}
|
|
}
|
|
if directChildren != 2 {
|
|
t.Fatalf("expected 2 direct children of the host, got %d", directChildren)
|
|
}
|
|
for _, viewer := range fullEntries {
|
|
expectTrue(t, "every viewer must be mediaReady", isMediaReady(viewer))
|
|
}
|
|
relayIndex := slices.IndexFunc(fullEntries, isRelay)
|
|
relay := fullEntries[relayIndex]
|
|
expectTrue(t, "relay parent must be in the roster", slices.ContainsFunc(fullEntries, func(viewer presenceEntry) bool {
|
|
return viewer.PeerID == relay.Upstream.PeerID
|
|
}))
|
|
host.ignore("route-update")
|
|
host.sendJSON(signalCandidate(relay.PeerID, "stale_connection_12345678"))
|
|
host.expectNone(40 * time.Millisecond)
|
|
|
|
viewers[2].sendJSON(map[string]any{"type": "set-display-name", "displayName": "后来改名"})
|
|
renamed := nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(viewer presenceEntry) bool {
|
|
return viewer.DisplayName == "后来改名"
|
|
})
|
|
})
|
|
if len(viewerPresenceEntries(renamed)) != 3 {
|
|
t.Fatalf("renamed roster: %s", renamed.raw)
|
|
}
|
|
|
|
h.closeClient(viewers[2])
|
|
afterDisconnect := nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 2
|
|
})
|
|
expectStrings(t, "display names after disconnect", displayNames(viewerPresenceEntries(afterDisconnect)), []string{"阿明", "阿青"})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// quality evidence forwarding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalForwardsExactDirectViewerReceiveEvidenceToHost(t *testing.T) {
|
|
now := newClock(10_000)
|
|
h := startHarness(t, harnessOptions{now: now.now})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "evidence-direct-host", 1, "", presenceOptions{viewerPresence: true})
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "evidence-direct-viewer", 1, "", presenceOptions{})
|
|
host.ignore("viewer-presence")
|
|
prepared := nextPreparedRoute(t, viewer)
|
|
connectionID := prepared.Candidate.ConnectionID
|
|
routeRevision := prepared.Revision
|
|
viewer.sendJSON(map[string]any{"type": "route-transport-connected", "revision": routeRevision, "connectionId": connectionID})
|
|
viewer.sendJSON(map[string]any{"type": "route-ready", "revision": routeRevision, "phase": "prepare"})
|
|
nextActiveRouteRevision(t, viewer, routeRevision)
|
|
nextActiveRouteRevision(t, host, routeRevision)
|
|
host.sendJSON(signalDescription(viewerAuth.PeerID, connectionID, "offer"))
|
|
viewer.next("signal")
|
|
|
|
viewer.sendJSON(viewerQualityEvidenceMessage(connectionID, routeRevision, 0, 0))
|
|
expectMatch(t, host.next("viewer-quality-evidence").raw, fmt.Sprintf(
|
|
`{"viewerPeerId":%q,"upstream":{"kind":"peer","peerId":%q},"guard":{"connectionId":%q,"routeRevision":%d}}`,
|
|
viewerAuth.PeerID, hostAuth.PeerID, connectionID, routeRevision))
|
|
|
|
viewer.sendJSON(viewerQualityEvidenceMessage(connectionID, routeRevision, 0, 1))
|
|
host.expectNone(40 * time.Millisecond)
|
|
now.advance(2_000)
|
|
viewer.sendJSON(viewerQualityEvidenceMessage(connectionID, routeRevision, 7, 1))
|
|
expectMatch(t, host.next("viewer-quality-evidence").raw, fmt.Sprintf(
|
|
`{"guard":{"connectionId":%q,"routeRevision":%d,"presentationEpoch":1},"sequence":7}`, connectionID, routeRevision))
|
|
now.advance(2_000)
|
|
viewer.sendJSON(viewerQualityEvidenceMessage(connectionID, routeRevision, 1, 0))
|
|
host.expectNone(40 * time.Millisecond)
|
|
viewer.sendJSON(viewerQualityEvidenceMessage(connectionID, routeRevision, 0, 2))
|
|
host.expectNone(40 * time.Millisecond)
|
|
now.advance(2_000)
|
|
viewer.sendJSON(viewerQualityEvidenceMessage(connectionID, routeRevision, 9, 2))
|
|
expectMatch(t, host.next("viewer-quality-evidence").raw, fmt.Sprintf(
|
|
`{"guard":{"connectionId":%q,"routeRevision":%d,"presentationEpoch":2},"sequence":9}`, connectionID, routeRevision))
|
|
}
|
|
|
|
func diagnosticQuality(t *testing.T, raw []byte) map[string]any {
|
|
t.Helper()
|
|
snapshot, _ := jsonObject(t, raw)["snapshot"].(map[string]any)
|
|
children, _ := snapshot["children"].([]any)
|
|
for _, child := range children {
|
|
entry, _ := child.(map[string]any)
|
|
if quality, ok := entry["quality"].(map[string]any); ok {
|
|
return quality
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestSignalForwardsRelayedChildReceiveReportToExactParentAndHost(t *testing.T) {
|
|
now := newClock(10_000)
|
|
h := startHarness(t, harnessOptions{endpointMediaCopyCapacity: 1, now: now.now})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "evidence-relay-host", 1, "", presenceOptions{viewerPresence: true})
|
|
parent := openClient(t, h)
|
|
parentAuth := authenticate(t, parent, h.room, protocol.RoleViewer, "evidence-relay-parent", 1, "", presenceOptions{})
|
|
parentPrepare := nextPreparedRoute(t, parent)
|
|
parent.sendJSON(map[string]any{"type": "route-ready", "revision": parentPrepare.Revision, "phase": "prepare"})
|
|
nextActiveRouteRevision(t, parent, parentPrepare.Revision)
|
|
|
|
child := openClient(t, h)
|
|
childAuth := authenticate(t, child, h.room, protocol.RoleViewer, "evidence-relay-child", 1, "", presenceOptions{})
|
|
childPrepare := nextPreparedRoute(t, child)
|
|
if childPrepare.Assignment.Upstream != protocol.PeerUpstream(parentAuth.PeerID) {
|
|
t.Fatalf("child upstream: %+v", childPrepare.Assignment.Upstream)
|
|
}
|
|
child.sendJSON(map[string]any{"type": "route-ready", "revision": childPrepare.Revision, "phase": "prepare"})
|
|
nextActiveRouteRevision(t, child, childPrepare.Revision)
|
|
|
|
child.sendJSON(viewerQualityEvidenceMessage(childPrepare.Candidate.ConnectionID, childPrepare.Revision, 0, 0))
|
|
expected := fmt.Sprintf(
|
|
`{"viewerPeerId":%q,"upstream":{"kind":"peer","peerId":%q},"guard":{"connectionId":%q,"routeRevision":%d}}`,
|
|
childAuth.PeerID, parentAuth.PeerID, childPrepare.Candidate.ConnectionID, childPrepare.Revision)
|
|
expectMatch(t, parent.next("viewer-quality-evidence").raw, expected)
|
|
expectMatch(t, host.next("viewer-quality-evidence").raw, expected)
|
|
host.sendJSON(map[string]any{"type": "request-route-diagnostic"})
|
|
quality := diagnosticQuality(t, host.next("route-diagnostic-snapshot").raw)
|
|
if !reflect.DeepEqual(quality, jsonValue(t, []byte(
|
|
`{"eligibleWindows":1,"eligibleDurationMs":2000,"freezeWindows":0,"freezeCount":0,"freezeDurationMs":0,"pauseCount":0,"pauseDurationMs":0}`))) {
|
|
t.Fatalf("diagnostic quality: %v", quality)
|
|
}
|
|
|
|
now.advance(2_000)
|
|
incomplete := viewerQualityEvidenceMessage(childPrepare.Candidate.ConnectionID, childPrepare.Revision, 1, 0)
|
|
incomplete["metrics"].(map[string]any)["pauseCountDelta"] = nil
|
|
child.sendJSON(incomplete)
|
|
expectMatch(t, parent.next("viewer-quality-evidence").raw, `{"metrics":{"pauseCountDelta":null}}`)
|
|
expectMatch(t, host.next("viewer-quality-evidence").raw, `{"metrics":{"pauseCountDelta":null}}`)
|
|
host.sendJSON(map[string]any{"type": "request-route-diagnostic"})
|
|
unchanged := diagnosticQuality(t, host.next("route-diagnostic-snapshot").raw)
|
|
if unchanged["eligibleWindows"] != float64(1) {
|
|
t.Fatalf("eligibleWindows: %v", unchanged["eligibleWindows"])
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// presence roster (continued)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalSharesOptedInHostNameWithHostAndViewerRosterSubscribers(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "named-host-client", 1, "",
|
|
presenceOptions{viewerPresence: true, displayName: "原始分享者"})
|
|
initial := parsePresence(t, host.next("viewer-presence").raw)
|
|
entry := hostPresenceEntry(initial)
|
|
if entry == nil || entry.PeerID != hostAuth.PeerID || entry.DisplayName != "原始分享者" || entry.Upstream.Kind != "none" {
|
|
t.Fatalf("initial host presence: %s", initial.raw)
|
|
}
|
|
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "named-viewer-client", 1, "",
|
|
presenceOptions{viewerPresence: true, displayName: "观看者"})
|
|
hostNamed := func(name string) func(presenceMessage) bool {
|
|
return func(message presenceMessage) bool {
|
|
entry := hostPresenceEntry(message)
|
|
return entry != nil && entry.DisplayName == name
|
|
}
|
|
}
|
|
viewerSnapshot := nextViewerPresenceMatching(t, viewer, hostNamed("原始分享者"))
|
|
expectString(t, "host peer", hostPresenceEntry(viewerSnapshot).PeerID, hostAuth.PeerID)
|
|
expectTrue(t, "viewer must see itself", slices.ContainsFunc(viewerPresenceEntries(viewerSnapshot), func(entry presenceEntry) bool {
|
|
return entry.PeerID == viewerAuth.PeerID
|
|
}))
|
|
|
|
hostSnapshot := nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return hostNamed("原始分享者")(message) && len(viewerPresenceEntries(message)) == 1
|
|
})
|
|
expectString(t, "host peer", hostPresenceEntry(hostSnapshot).PeerID, hostAuth.PeerID)
|
|
|
|
host.sendJSON(map[string]any{"type": "set-display-name", "displayName": "改名后的分享者"})
|
|
renamedViewer := nextViewerPresenceMatching(t, viewer, hostNamed("改名后的分享者"))
|
|
expectString(t, "host peer", hostPresenceEntry(renamedViewer).PeerID, hostAuth.PeerID)
|
|
renamedHost := nextViewerPresenceMatching(t, host, hostNamed("改名后的分享者"))
|
|
expectString(t, "host peer", hostPresenceEntry(renamedHost).PeerID, hostAuth.PeerID)
|
|
}
|
|
|
|
func TestSignalReplacesOptedInViewerRosterAfterAnotherViewerLeaves(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "viewer-roster-host", 1, "", presenceOptions{})
|
|
|
|
subscriber := openClient(t, h)
|
|
subscriberAuth := authenticate(t, subscriber, h.room, protocol.RoleViewer, "viewer-roster-subscriber", 1, "",
|
|
presenceOptions{viewerPresence: true, displayName: "订阅者"})
|
|
nextViewerPresenceMatching(t, subscriber, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 1
|
|
})
|
|
|
|
other := openClient(t, h)
|
|
otherAuth := authenticate(t, other, h.room, protocol.RoleViewer, "viewer-roster-other", 1, "",
|
|
presenceOptions{displayName: "另一位"})
|
|
joined := nextViewerPresenceMatching(t, subscriber, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 2
|
|
})
|
|
expectStrings(t, "joined roster", peerIDs(viewerPresenceEntries(joined)), []string{subscriberAuth.PeerID, otherAuth.PeerID})
|
|
|
|
h.closeClient(other)
|
|
left := nextViewerPresenceMatching(t, subscriber, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 1
|
|
})
|
|
expectStrings(t, "roster after leave", peerIDs(viewerPresenceEntries(left)), []string{subscriberAuth.PeerID})
|
|
}
|
|
|
|
func TestSignalClearsDisconnectedHostNameAndPublishesReplacementName(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 40})
|
|
firstHost := openClient(t, h)
|
|
firstAuth := authenticate(t, firstHost, h.room, protocol.RoleHost, "replaceable-host-client", 1, "",
|
|
presenceOptions{viewerPresence: true, displayName: "旧分享者"})
|
|
firstHost.next("viewer-presence")
|
|
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "replacement-roster-viewer", 1, "",
|
|
presenceOptions{viewerPresence: true})
|
|
nextViewerPresenceMatching(t, viewer, func(message presenceMessage) bool {
|
|
entry := hostPresenceEntry(message)
|
|
return entry != nil && entry.DisplayName == "旧分享者"
|
|
})
|
|
|
|
h.closeClient(firstHost)
|
|
viewer.next("host-status")
|
|
offline := nextViewerPresenceMatching(t, viewer, func(message presenceMessage) bool {
|
|
return hostPresenceEntry(message) == nil
|
|
})
|
|
expectMatch(t, viewerEntriesJSON(t, offline), fmt.Sprintf(`[{"peerId":%q,"upstream":{"kind":"none"}}]`, viewerAuth.PeerID))
|
|
|
|
replacement := openClient(t, h)
|
|
replacementAuth := authenticate(t, replacement, h.room, protocol.RoleHost, "replaceable-host-client", 1, "",
|
|
presenceOptions{viewerPresence: true, displayName: "新分享者"})
|
|
expectString(t, "replacement peer", replacementAuth.PeerID, firstAuth.PeerID)
|
|
online := nextViewerPresenceMatching(t, viewer, func(message presenceMessage) bool {
|
|
entry := hostPresenceEntry(message)
|
|
return entry != nil && entry.DisplayName == "新分享者"
|
|
})
|
|
expectString(t, "online host peer", hostPresenceEntry(online).PeerID, firstAuth.PeerID)
|
|
h.closeClient(replacement)
|
|
h.closeClient(viewer)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// grant rotation and revocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func viewerGrantOf(t *testing.T, inviteURL string) string {
|
|
t.Helper()
|
|
parsed, err := url.Parse(inviteURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
values, err := url.ParseQuery(parsed.Fragment)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return values.Get("v")
|
|
}
|
|
|
|
func TestSignalStronglyRotatesAndRevokesEveryViewerGenerationAndMediaEdge(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "ordinary-access-presence-host", 1, "",
|
|
presenceOptions{viewerPresence: true})
|
|
if entries := viewerPresenceEntries(parsePresence(t, host.next("viewer-presence").raw)); len(entries) != 0 {
|
|
t.Fatalf("initial roster: %v", entries)
|
|
}
|
|
|
|
firstViewer := openClient(t, h)
|
|
firstViewerAuth := authenticate(t, firstViewer, h.room, protocol.RoleViewer, "ordinary-access-presence-viewer-1", 1, "",
|
|
presenceOptions{displayName: "第一位"})
|
|
commitPreparedRoute(t, firstViewer)
|
|
hasPeerEdge := func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.Upstream.Kind == "peer"
|
|
})
|
|
}
|
|
expectMatch(t, viewerEntriesJSON(t, nextViewerPresenceMatching(t, host, hasPeerEdge)), fmt.Sprintf(
|
|
`[{"role":"viewer","peerId":%q,"displayName":"第一位","upstream":{"kind":"peer","peerId":%q}}]`,
|
|
firstViewerAuth.PeerID, hostAuth.PeerID))
|
|
|
|
rotated := h.updateRoomAccess(protocol.RotateViewerGrantRequest{Action: "rotate-viewer-grant"})
|
|
expectMatch(t, firstViewer.next("viewer-grant-revoked").raw,
|
|
fmt.Sprintf(`{"viewerAuthorizationGeneration":%q}`, firstViewerAuth.ViewerAuthorizationGeneration))
|
|
expectCloseCode(t, firstViewer, 4004)
|
|
nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 0
|
|
})
|
|
|
|
rotatedGrant, ok := rotated.(protocol.ViewerGrantUpdatedResponse)
|
|
if !ok || rotatedGrant.InviteURL == nil {
|
|
t.Fatalf("expected a rotated Viewer grant, got %+v", rotated)
|
|
}
|
|
grant := viewerGrantOf(t, *rotatedGrant.InviteURL)
|
|
expectTrue(t, "rotated grant must be present", grant != "")
|
|
rotatedRoom := h.room
|
|
rotatedRoom.ViewerGrant = grant
|
|
secondViewer := openClient(t, h)
|
|
secondViewerAuth := authenticate(t, secondViewer, rotatedRoom, protocol.RoleViewer, "ordinary-access-presence-viewer-2", 1, "",
|
|
presenceOptions{displayName: "第二位"})
|
|
commitPreparedRoute(t, secondViewer)
|
|
seen := nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.PeerID == secondViewerAuth.PeerID
|
|
})
|
|
})
|
|
if len(viewerPresenceEntries(seen)) != 1 {
|
|
t.Fatalf("second roster: %s", seen.raw)
|
|
}
|
|
|
|
revoked := h.updateRoomAccess(protocol.RevokeViewerGrantRequest{Action: "revoke-viewer-grant"})
|
|
secondViewer.next("viewer-grant-revoked")
|
|
expectCloseCode(t, secondViewer, 4004)
|
|
nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 0
|
|
})
|
|
revokedGrant, ok := revoked.(protocol.ViewerGrantUpdatedResponse)
|
|
if !ok || revokedGrant.InviteURL != nil {
|
|
t.Fatalf("revoke response: %+v", revoked)
|
|
}
|
|
expectNoViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) > 0
|
|
}, 30*time.Millisecond)
|
|
}
|
|
|
|
func TestSignalRotatesGrantAndPromotesWaitingCodeAdmittedViewer(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{endpointMediaCopyCapacity: 1, maxViewersPerRoom: 2})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "mixed-access-host", 2, "", presenceOptions{viewerPresence: true})
|
|
if entries := viewerPresenceEntries(parsePresence(t, host.next("viewer-presence").raw)); len(entries) != 0 {
|
|
t.Fatalf("initial roster: %v", entries)
|
|
}
|
|
|
|
grantViewer := openClient(t, h)
|
|
grantAuth := authenticate(t, grantViewer, h.room, protocol.RoleViewer, "mixed-grant-viewer", 0, "",
|
|
presenceOptions{displayName: "邀请观众"})
|
|
commitPreparedRoute(t, grantViewer)
|
|
nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.PeerID == grantAuth.PeerID && entry.Upstream.Kind == "peer" && isMediaReady(entry)
|
|
})
|
|
})
|
|
|
|
codeViewer := openClient(t, h)
|
|
codeAuth := authenticate(t, codeViewer, h.room, protocol.RoleViewer, "mixed-code-viewer", 1, "",
|
|
presenceOptions{codeOnly: true, displayName: "房间号观众"})
|
|
nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return len(viewerPresenceEntries(message)) == 2
|
|
})
|
|
h.updateRoomAccess(protocol.RotateViewerGrantRequest{Action: "rotate-viewer-grant"})
|
|
grantViewer.next("viewer-grant-revoked")
|
|
expectCloseCode(t, grantViewer, 4004)
|
|
commitPreparedRoute(t, codeViewer)
|
|
remaining := nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
entries := viewerPresenceEntries(message)
|
|
return len(entries) == 1 && entries[0].PeerID == codeAuth.PeerID && entries[0].Upstream.Kind == "peer"
|
|
})
|
|
expectEqual(t, viewerEntriesJSON(t, remaining), fmt.Sprintf(
|
|
`[{"role":"viewer","peerId":%q,"displayName":"房间号观众","upstream":{"kind":"peer","peerId":%q},"mediaReady":true}]`,
|
|
codeAuth.PeerID, hostAuth.PeerID))
|
|
expectTrue(t, "code viewer must stay open", !codeViewer.isClosed())
|
|
var connected bool
|
|
h.locked(func() { _, connected = h.store.GetConnectedViewer(h.room.RoomID, codeAuth.PeerID) })
|
|
expectTrue(t, "code viewer must stay connected", connected)
|
|
|
|
h.closeClient(codeViewer)
|
|
h.closeClient(host)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// site access at upgrade
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func sendCodeOnlyAuthenticate(client *testClient, roomID, clientID string, extra map[string]any) {
|
|
message := map[string]any{
|
|
"type": "authenticate",
|
|
"protocol": protocol.SignalingProtocol,
|
|
"roomId": roomID,
|
|
"role": protocol.RoleViewer,
|
|
"clientId": clientID,
|
|
}
|
|
for key, value := range extra {
|
|
message[key] = value
|
|
}
|
|
client.sendJSON(message)
|
|
}
|
|
|
|
func TestSignalRequiresSiteAccessForCodeOnlyViewersWhileAcceptingRoomGrants(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{siteAccessPassword: "protected-instance-password"})
|
|
|
|
viewer := openClient(t, h)
|
|
sendCodeOnlyAuthenticate(viewer, h.room.RoomID, "code-only-viewer-without-site-access", nil)
|
|
expectMatch(t, viewer.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
|
|
|
grantedViewer := openClient(t, h)
|
|
granted := authenticate(t, grantedViewer, h.room, protocol.RoleViewer, "grant-viewer-without-site-access", 1, "", presenceOptions{})
|
|
expectString(t, "role", granted.Role, protocol.RoleViewer)
|
|
|
|
unauthorizedHost := openClient(t, h)
|
|
unauthorizedHost.sendJSON(map[string]any{
|
|
"type": "authenticate",
|
|
"protocol": protocol.SignalingProtocol,
|
|
"roomId": h.room.RoomID,
|
|
"role": protocol.RoleHost,
|
|
"token": h.room.HostToken,
|
|
"clientId": "host-client-without-admission",
|
|
})
|
|
expectMatch(t, unauthorizedHost.next("error").raw, `{"code":"AUTH_REQUIRED"}`)
|
|
|
|
viewerWithHostCookie := openClientWithCookie(t, h, siteAccessCookie)
|
|
admitted := authenticate(t, viewerWithHostCookie, h.room, protocol.RoleViewer, "viewer-with-host-cookie", 1, "",
|
|
presenceOptions{codeOnly: true})
|
|
expectString(t, "role", admitted.Role, protocol.RoleViewer)
|
|
|
|
publicRoom := h.createRoom(protocol.CodeEntryOpen, "")
|
|
publicWithoutSiteAccess := openClient(t, h)
|
|
sendCodeOnlyAuthenticate(publicWithoutSiteAccess, publicRoom.RoomID, "public-viewer-without-site-access", nil)
|
|
expectMatch(t, publicWithoutSiteAccess.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
|
|
|
publicWithForgedGrant := openClient(t, h)
|
|
sendCodeOnlyAuthenticate(publicWithForgedGrant, publicRoom.RoomID, "public-viewer-with-forged-grant",
|
|
map[string]any{"viewerGrant": strings.Repeat("x", 21) + "g"})
|
|
expectMatch(t, publicWithForgedGrant.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
|
|
|
publicWithSiteAccess := openClientWithCookie(t, h, siteAccessCookie)
|
|
publicAdmitted := authenticate(t, publicWithSiteAccess, publicRoom, protocol.RoleViewer, "public-viewer-with-site-access", 1, "",
|
|
presenceOptions{codeOnly: true})
|
|
expectString(t, "role", publicAdmitted.Role, protocol.RoleViewer)
|
|
|
|
passwordRoom := h.createRoom(protocol.CodeEntryPrivate, "room-password")
|
|
|
|
passwordWithoutSiteAccess := openClient(t, h)
|
|
sendCodeOnlyAuthenticate(passwordWithoutSiteAccess, passwordRoom.RoomID, "password-viewer-without-site-access",
|
|
map[string]any{"viewerPassword": "room-password"})
|
|
expectMatch(t, passwordWithoutSiteAccess.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
|
|
|
passwordWithSiteAccess := openClientWithCookie(t, h, siteAccessCookie)
|
|
passwordAdmitted := authenticate(t, passwordWithSiteAccess, passwordRoom, protocol.RoleViewer, "password-viewer-with-site-access", 1, "",
|
|
presenceOptions{viewerPassword: "room-password"})
|
|
expectString(t, "role", passwordAdmitted.Role, protocol.RoleViewer)
|
|
|
|
host := openClientWithCookie(t, h, siteAccessCookie)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "host-client-protected", 1, "", presenceOptions{})
|
|
expectString(t, "role", hostAuth.Role, protocol.RoleHost)
|
|
}
|
|
|
|
func TestSignalDistinguishesMissingRoomsFromExistingRoomCodeOnlyDenials(t *testing.T) {
|
|
now := newClock(utcMillis(2026, time.August, 24, 12))
|
|
h := startHarness(t, harnessOptions{
|
|
siteAccessPassword: "protected-instance-password",
|
|
maxViewersPerRoom: 1,
|
|
now: now.now,
|
|
})
|
|
|
|
protectedRoom := h.createRoom(protocol.CodeEntryPrivate, "correct-password")
|
|
expiring := h.createRoom(protocol.CodeEntryOpen, "")
|
|
unusedRoomID := ""
|
|
for code := 1_000; code < 10_000; code++ {
|
|
candidate := fmt.Sprint(code)
|
|
if candidate != h.room.RoomID && candidate != protectedRoom.RoomID && candidate != expiring.RoomID {
|
|
unusedRoomID = candidate
|
|
break
|
|
}
|
|
}
|
|
|
|
admitted := openClientWithCookie(t, h, siteAccessCookie)
|
|
authenticate(t, admitted, h.room, protocol.RoleViewer, "admitted-viewer", 1, "", presenceOptions{codeOnly: true})
|
|
|
|
expectDenial := func(roomID, clientID, code, viewerPassword string) {
|
|
t.Helper()
|
|
client := openClientWithCookie(t, h, siteAccessCookie)
|
|
extra := map[string]any{}
|
|
if viewerPassword != "" {
|
|
extra["viewerPassword"] = viewerPassword
|
|
}
|
|
sendCodeOnlyAuthenticate(client, roomID, clientID, extra)
|
|
message := "Room access denied"
|
|
if code == "ROOM_NOT_FOUND" {
|
|
message = "Room not found or expired"
|
|
}
|
|
expectEqual(t, client.next("error").raw, fmt.Sprintf(`{"type":"error","code":%q,"message":%q}`, code, message))
|
|
expectClose(t, client, 4003, "Authentication failed")
|
|
}
|
|
|
|
expectDenial(unusedRoomID, "unknown-room-viewer", "ROOM_NOT_FOUND", "")
|
|
expectDenial(protectedRoom.RoomID, "missing-password-viewer", "ROOM_ACCESS_DENIED", "")
|
|
expectDenial(protectedRoom.RoomID, "wrong-password-viewer", "ROOM_ACCESS_DENIED", "wrong-password")
|
|
expectDenial(h.room.RoomID, "full-room-viewer", "ROOM_ACCESS_DENIED", "")
|
|
|
|
now.advance(86_400_001)
|
|
expectDenial(expiring.RoomID, "expired-room-viewer", "ROOM_NOT_FOUND", "")
|
|
|
|
expiredGrant := openClientWithCookie(t, h, siteAccessCookie)
|
|
sendCodeOnlyAuthenticate(expiredGrant, expiring.RoomID, "expired-grant-viewer",
|
|
map[string]any{"viewerGrant": expiring.ViewerGrant})
|
|
expectMatch(t, expiredGrant.next("error").raw, `{"code":"INVALID_TOKEN"}`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// room lease and expiry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalDoesNotExpireActiveRoomAndArmsItsLeaseAfterHostDisconnect(t *testing.T) {
|
|
now := newClock(utcMillis(2026, time.August, 20, 12))
|
|
h := startHarness(t, harnessOptions{now: now.now})
|
|
host := openClient(t, h)
|
|
authenticated := authenticate(t, host, h.room, protocol.RoleHost, "active-lease-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "provisional-viewer", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
|
|
now.advance(86_401_000)
|
|
expireAt := func(nowMs int64) []room.ClosedRoom {
|
|
t.Helper()
|
|
var expired []room.ClosedRoom
|
|
var err error
|
|
h.locked(func() { expired, err = h.store.ExpireRooms(nowMs) })
|
|
if err != nil {
|
|
t.Fatalf("ExpireRooms: %v", err)
|
|
}
|
|
return expired
|
|
}
|
|
if expired := expireAt(now.now()); len(expired) != 0 {
|
|
t.Fatalf("active room expired: %v", expired)
|
|
}
|
|
h.closeClient(host)
|
|
viewer.next("host-status")
|
|
var hostConnected bool
|
|
h.locked(func() { _, hostConnected = h.store.GetConnectedHost(h.room.RoomID) })
|
|
expectTrue(t, "host must be disconnected", !hostConnected)
|
|
if expired := expireAt(now.now() + 86_399_999); len(expired) != 0 {
|
|
t.Fatalf("room expired before its lease: %v", expired)
|
|
}
|
|
expired := expireAt(now.now() + 86_401_000)
|
|
if len(expired) != 1 || expired[0].RoomID != h.room.RoomID || len(expired[0].SessionIDs) != 1 {
|
|
t.Fatalf("expired rooms: %+v", expired)
|
|
}
|
|
h.closeClient(viewer)
|
|
expectString(t, "role", authenticated.Role, protocol.RoleHost)
|
|
}
|
|
|
|
func TestSignalTerminatesExpiredDormantRoomThroughCleanupTimer(t *testing.T) {
|
|
now := newClock(utcMillis(2026, time.August, 20, 12))
|
|
h := startHarness(t, harnessOptions{now: now.now, cleanupIntervalMs: 20})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "expiring-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "expiring-viewer", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
h.closeClient(host)
|
|
viewer.next("host-status")
|
|
|
|
now.advance(86_401_000)
|
|
|
|
expectEqual(t, viewer.next("room-closed").raw, `{"type":"room-closed","reason":"expired"}`)
|
|
expectClose(t, viewer, 1000, "Room expired")
|
|
if size := h.storeSize(); size != 0 {
|
|
t.Fatalf("store size %d", size)
|
|
}
|
|
}
|
|
|
|
func TestSignalKeepsPreHostViewerSessionCurrentAfterHostClearsRoomLease(t *testing.T) {
|
|
now := newClock(utcMillis(2026, time.August, 20, 12))
|
|
h := startHarness(t, harnessOptions{now: now.now})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "pre-host-lease-viewer", 1, "", presenceOptions{displayName: "Before"})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "pre-host-lease-host", 1, "", presenceOptions{viewerPresence: true})
|
|
named := func(name string) func(presenceMessage) bool {
|
|
return func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.DisplayName == name
|
|
})
|
|
}
|
|
}
|
|
nextViewerPresenceMatching(t, host, named("Before"))
|
|
|
|
now.advance(86_400_001)
|
|
viewer.sendJSON(map[string]any{"type": "set-display-name", "displayName": "After"})
|
|
updated := nextViewerPresenceMatching(t, host, named("After"))
|
|
if len(viewerPresenceEntries(updated)) != 1 {
|
|
t.Fatalf("updated roster: %s", updated.raw)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// grant update failure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalLeavesEstablishedAuthorizationAndMediaUntouchedWhenGrantUpdateFails(t *testing.T) {
|
|
// The TS mocked setViewerGrant to throw; here the stable-authority
|
|
// database is closed underneath the store, so the write fails before
|
|
// memory changes, which is the same failure point.
|
|
database, err := room.NewDatabase(filepath.Join(t.TempDir(), "rooms.sqlite"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := newStore(t, 8, nil, database)
|
|
h := startHarness(t, harnessOptions{store: store})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "persistence-failure-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "persistence-failure-viewer", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
host.sendJSON(signalDescription(viewerAuth.PeerID, "persistence-failure-edge", "offer"))
|
|
viewer.next("signal")
|
|
|
|
h.locked(func() {
|
|
if err := database.Close(); err != nil {
|
|
t.Fatalf("database.Close: %v", err)
|
|
}
|
|
})
|
|
_, err = h.server.UpdateRoomAccess(h.room.RoomID, h.room.HostToken,
|
|
protocol.RotateViewerGrantRequest{Action: "rotate-viewer-grant"})
|
|
var roomError *room.Error
|
|
if err == nil || errors.As(err, &roomError) {
|
|
t.Fatalf("expected the rotation to fail with a non-room error, got %v", err)
|
|
}
|
|
if message, ok := viewer.tryNext("viewer-grant-revoked", 30*time.Millisecond); ok {
|
|
t.Fatalf("viewer was revoked: %s", message.raw)
|
|
}
|
|
|
|
host.sendJSON(signalCandidate(viewerAuth.PeerID, "persistence-failure-edge"))
|
|
expectMatch(t, viewer.next("signal").raw, fmt.Sprintf(
|
|
`{"fromPeerId":%q,"payload":{"connectionId":"persistence-failure-edge"}}`, hostAuth.PeerID))
|
|
|
|
secondViewer := openClient(t, h)
|
|
secondAuth := authenticate(t, secondViewer, h.room, protocol.RoleViewer, "persistence-failure-new-viewer", 1, "", presenceOptions{})
|
|
expectString(t, "viewerAuthorizationGeneration", secondAuth.ViewerAuthorizationGeneration, viewerAuth.ViewerAuthorizationGeneration)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// host arrival, pause and quality
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalLetsViewersArriveFirstAndReplaysTheirSnapshotWhenHostJoins(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "viewer-client-early", 1, "", presenceOptions{})
|
|
expectTrue(t, "host must be offline", !viewerAuth.HostOnline)
|
|
expectTrue(t, "mediaMode must be present", hasKey(t, viewerAuth.raw, "mediaMode"))
|
|
expectTrue(t, "routeRevision must be present", hasKey(t, viewerAuth.raw, "routeRevision"))
|
|
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
host.ignore("route-update")
|
|
expectEqual(t, viewer.next("host-status").raw, `{"type":"host-status","online":true,"paused":false}`)
|
|
}
|
|
|
|
func setSharingPaused(client *testClient, shareGeneration string, paused bool) {
|
|
client.sendJSON(map[string]any{"type": "set-sharing-paused", "shareGeneration": shareGeneration, "paused": paused})
|
|
}
|
|
|
|
func TestSignalSnapshotsIntentionalPauseAcrossViewerReplacementAndClearsItOnResumeAndStop(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
shareGeneration := "pause_share_generation_12345678"
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "pause-host-client", 1, shareGeneration, presenceOptions{})
|
|
expectTrue(t, "hostPaused must be absent for the host", !hasKey(t, hostAuth.raw, "hostPaused"))
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "pause-viewer-client", 1, "", presenceOptions{})
|
|
expectTrue(t, "hostPaused must be false", viewerAuth.HostPaused != nil && !*viewerAuth.HostPaused)
|
|
commitPreparedRoute(t, viewer)
|
|
|
|
setSharingPaused(host, shareGeneration, true)
|
|
expectEqual(t, viewer.next("host-status").raw, `{"type":"host-status","online":true,"paused":true}`)
|
|
|
|
h.closeClient(host)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":false,"paused":false}`)
|
|
activeHost := openClient(t, h)
|
|
authenticate(t, activeHost, h.room, protocol.RoleHost, "pause-host-client", 1, shareGeneration,
|
|
presenceOptions{sharingPaused: boolPointer(true)})
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":true,"paused":true}`)
|
|
|
|
replacement := openClient(t, h)
|
|
replacementAuth := authenticate(t, replacement, h.room, protocol.RoleViewer, "pause-viewer-client", 1, "", presenceOptions{})
|
|
expectTrue(t, "hostPaused must be true", replacementAuth.HostPaused != nil && *replacementAuth.HostPaused)
|
|
|
|
setSharingPaused(activeHost, shareGeneration, false)
|
|
expectMatch(t, replacement.next("host-status").raw, `{"online":true,"paused":false}`)
|
|
|
|
setSharingPaused(activeHost, shareGeneration, true)
|
|
expectMatch(t, replacement.next("host-status").raw, `{"paused":true}`)
|
|
activeHost.sendJSON(map[string]any{"type": "stop-sharing", "shareGeneration": shareGeneration})
|
|
replacement.next("sharing-stopped")
|
|
expectMatch(t, replacement.next("host-status").raw, `{"online":false,"paused":false}`)
|
|
|
|
nextHost := openClient(t, h)
|
|
authenticate(t, nextHost, h.room, protocol.RoleHost, "pause-host-client", 1, "next_pause_share_generation_12345678", presenceOptions{})
|
|
replacement.next("sharing-stopped")
|
|
expectMatch(t, replacement.next("host-status").raw, `{"online":false,"paused":false}`)
|
|
expectMatch(t, replacement.next("host-status").raw, `{"online":true,"paused":false}`)
|
|
}
|
|
|
|
func TestSignalAppliesSymmetricPauseUpdatesOnlyToExactCurrentHostShare(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
shareGeneration := "hybrid_pause_share_generation_12345678"
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "hybrid-pause-host-client", 1, shareGeneration, presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "hybrid-pause-viewer-client", 1, "", presenceOptions{})
|
|
|
|
setSharingPaused(host, shareGeneration, true)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":true,"paused":true}`)
|
|
setSharingPaused(host, shareGeneration, false)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":true,"paused":false}`)
|
|
|
|
setSharingPaused(host, "stale_pause_share_generation_12345678", true)
|
|
expectCloseCode(t, host, 4001)
|
|
}
|
|
|
|
func TestSignalRetainsAuthoritativePauseWhenReconnectingHostAdvertisesUnpaused(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
shareGeneration := "reconnect_resume_share_generation_12345678"
|
|
hostClientID := "reconnect-resume-host-client"
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, hostClientID, 1, shareGeneration, presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "reconnect-resume-viewer-client", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
|
|
setSharingPaused(host, shareGeneration, true)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":true,"paused":true}`)
|
|
h.closeClient(host)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":false,"paused":false}`)
|
|
|
|
reconnectedHost := openClient(t, h)
|
|
authenticate(t, reconnectedHost, h.room, protocol.RoleHost, hostClientID, 1, shareGeneration,
|
|
presenceOptions{sharingPaused: boolPointer(false)})
|
|
expectEqual(t, reconnectedHost.next("pause-sharing-source").raw,
|
|
fmt.Sprintf(`{"type":"pause-sharing-source","shareGeneration":%q}`, shareGeneration))
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":true,"paused":true}`)
|
|
|
|
setSharingPaused(reconnectedHost, shareGeneration, false)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":true,"paused":false}`)
|
|
}
|
|
|
|
func TestSignalBindsNewShareQualityAndPreservesActiveShareQualityAcrossReconnect(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
initialSettings := map[string]any{
|
|
"resolution": "1440p",
|
|
"maxFramerate": 60,
|
|
"maxBitrate": 8_000_000,
|
|
"degradationPreference": "maintain-resolution",
|
|
"screenAudioQuality": "very-high",
|
|
}
|
|
initialJSON := `{"resolution":"1440p","maxFramerate":60,"maxBitrate":8000000,"degradationPreference":"maintain-resolution","screenAudioQuality":"very-high"}`
|
|
firstGeneration := "first_share_generation_12345678"
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "quality-viewer-client", 1, "", presenceOptions{})
|
|
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "quality-host-client", 1, firstGeneration,
|
|
presenceOptions{qualitySettings: initialSettings})
|
|
expectEqual(t, hostAuth.QualitySettings, initialJSON)
|
|
expectEqual(t, viewer.next("quality-settings").raw, `{"type":"quality-settings","qualitySettings":`+initialJSON+`}`)
|
|
|
|
reconnectSettings := map[string]any{
|
|
"resolution": "480p",
|
|
"maxFramerate": 15,
|
|
"maxBitrate": 2_000_000,
|
|
"degradationPreference": "maintain-framerate",
|
|
"screenAudioQuality": "saver",
|
|
}
|
|
reconnectJSON := `{"resolution":"480p","maxFramerate":15,"maxBitrate":2000000,"degradationPreference":"maintain-framerate","screenAudioQuality":"saver"}`
|
|
reconnectedHost := openClient(t, h)
|
|
reconnectedAuth := authenticate(t, reconnectedHost, h.room, protocol.RoleHost, "quality-host-client", 1, firstGeneration,
|
|
presenceOptions{qualitySettings: reconnectSettings})
|
|
expectCloseCode(t, host, 4001)
|
|
expectEqual(t, reconnectedAuth.QualitySettings, initialJSON)
|
|
if message, ok := viewer.tryNext("quality-settings", 30*time.Millisecond); ok {
|
|
t.Fatalf("unexpected quality-settings: %s", message.raw)
|
|
}
|
|
|
|
updatedSettings := map[string]any{
|
|
"resolution": "720p",
|
|
"maxFramerate": 30,
|
|
"maxBitrate": 3_000_000,
|
|
"degradationPreference": "balanced",
|
|
"screenAudioQuality": "music",
|
|
}
|
|
updatedJSON := `{"resolution":"720p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced","screenAudioQuality":"music"}`
|
|
reconnectedHost.sendJSON(map[string]any{"type": "set-quality-settings", "qualitySettings": updatedSettings})
|
|
expectEqual(t, viewer.next("quality-settings").raw, `{"type":"quality-settings","qualitySettings":`+updatedJSON+`}`)
|
|
|
|
reconnectedHost.sendJSON(map[string]any{"type": "stop-sharing", "shareGeneration": firstGeneration})
|
|
expectCloseCode(t, reconnectedHost, 1000)
|
|
viewer.next("sharing-stopped")
|
|
|
|
nextHost := openClient(t, h)
|
|
nextAuth := authenticate(t, nextHost, h.room, protocol.RoleHost, "quality-host-client", 1, "next_share_generation_12345678",
|
|
presenceOptions{qualitySettings: reconnectSettings})
|
|
expectEqual(t, nextAuth.QualitySettings, reconnectJSON)
|
|
expectEqual(t, viewer.next("quality-settings").raw, `{"type":"quality-settings","qualitySettings":`+reconnectJSON+`}`)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// grace, reconnect and media identity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalKeepsViewerPeerStableWhenItReconnectsInsideGracePeriod(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 80})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
firstViewer := openClient(t, h)
|
|
firstAuth := authenticate(t, firstViewer, h.room, protocol.RoleViewer, "viewer-client-stable", 1, "", presenceOptions{})
|
|
activeRoute := commitPreparedRoute(t, firstViewer)
|
|
nextActiveRouteRevision(t, host, activeRoute.Revision)
|
|
host.sendJSON(signalDescription(firstAuth.PeerID, "stable-connection", "offer"))
|
|
firstViewer.next("signal")
|
|
|
|
h.closeClient(firstViewer)
|
|
reconnectedViewer := openClient(t, h)
|
|
reconnectedAuth := authenticate(t, reconnectedViewer, h.room, protocol.RoleViewer, "viewer-client-stable", 1, "", presenceOptions{})
|
|
expectString(t, "peer", reconnectedAuth.PeerID, firstAuth.PeerID)
|
|
if reconnectedAuth.ConnectionID == nil || *reconnectedAuth.ConnectionID != "stable-connection" {
|
|
t.Fatalf("connectionId: %v", reconnectedAuth.ConnectionID)
|
|
}
|
|
nextActiveRouteRevision(t, host, reconnectedAuth.RouteRevision)
|
|
host.expectNone(110 * time.Millisecond)
|
|
|
|
h.closeClient(reconnectedViewer)
|
|
nextActiveRouteAfter(t, host, activeRoute.Revision)
|
|
host.sendJSON(map[string]any{"type": "request-route-diagnostic"})
|
|
expectMatch(t, host.next("route-diagnostic-snapshot").raw, `{"snapshot":{"children":[]}}`)
|
|
}
|
|
|
|
func TestSignalClearsPreviousMediaGenerationWhenSharingStops(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 300})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "host-client-stop", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "viewer-client-stop", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
host.sendJSON(signalDescription(viewerAuth.PeerID, "connection-before-stop", "offer"))
|
|
viewer.next("signal")
|
|
|
|
host.sendJSON(map[string]any{"type": "stop-sharing"})
|
|
viewer.next("sharing-stopped")
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":false}`)
|
|
expectCloseCode(t, host, 1000)
|
|
h.closeClient(viewer)
|
|
|
|
reconnected := openClient(t, h)
|
|
reconnectedAuth := authenticate(t, reconnected, h.room, protocol.RoleViewer, "viewer-client-stop", 1, "", presenceOptions{})
|
|
expectTrue(t, "connectionId must be null", reconnectedAuth.ConnectionID == nil)
|
|
expectString(t, "peer", reconnectedAuth.PeerID, viewerAuth.PeerID)
|
|
expectTrue(t, "host must be offline", !reconnectedAuth.HostOnline)
|
|
}
|
|
|
|
func TestSignalReplacesSameClientPresenceWithoutTransientLeaveChurn(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 40})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{viewerPresence: true})
|
|
if entries := viewerPresenceEntries(parsePresence(t, host.next("viewer-presence").raw)); len(entries) != 0 {
|
|
t.Fatalf("initial roster: %v", entries)
|
|
}
|
|
original := openClient(t, h)
|
|
originalAuth := authenticate(t, original, h.room, protocol.RoleViewer, "viewer-client-stable", 1, "",
|
|
presenceOptions{displayName: "旧会话"})
|
|
commitPreparedRoute(t, original)
|
|
expectMatch(t, viewerEntriesJSON(t, nextViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.Upstream.Kind == "peer"
|
|
})
|
|
})), fmt.Sprintf(
|
|
`[{"role":"viewer","peerId":%q,"displayName":"旧会话","upstream":{"kind":"peer","peerId":%q}}]`,
|
|
originalAuth.PeerID, hostAuth.PeerID))
|
|
|
|
replacement := openClient(t, h)
|
|
replacementAuth := authenticate(t, replacement, h.room, protocol.RoleViewer, "viewer-client-stable", 1, "",
|
|
presenceOptions{displayName: "新会话"})
|
|
|
|
expectString(t, "peer", replacementAuth.PeerID, originalAuth.PeerID)
|
|
expectCloseCode(t, original, 4001)
|
|
expectMatch(t, viewerEntriesJSON(t, parsePresence(t, host.next("viewer-presence").raw)), fmt.Sprintf(
|
|
`[{"role":"viewer","peerId":%q,"displayName":"新会话","upstream":{"kind":"peer","peerId":%q}}]`,
|
|
originalAuth.PeerID, hostAuth.PeerID))
|
|
expectNoViewerPresenceMatching(t, host, func(message presenceMessage) bool {
|
|
return !slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.PeerID == originalAuth.PeerID
|
|
})
|
|
}, 70*time.Millisecond)
|
|
}
|
|
|
|
func TestSignalNotifiesHostWhenPreOfferViewerReconnectsInsideGrace(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 300})
|
|
originalViewer := openClient(t, h)
|
|
originalAuth := authenticate(t, originalViewer, h.room, protocol.RoleViewer, "viewer-client-before-offer", 1, "", presenceOptions{})
|
|
h.closeClient(originalViewer)
|
|
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
nextActiveRouteRevision(t, host, hostAuth.RouteRevision)
|
|
|
|
reconnectedViewer := openClient(t, h)
|
|
reconnectedAuth := authenticate(t, reconnectedViewer, h.room, protocol.RoleViewer, "viewer-client-before-offer", 1, "", presenceOptions{})
|
|
expectString(t, "peer", reconnectedAuth.PeerID, originalAuth.PeerID)
|
|
prepared := nextPreparedRoute(t, host)
|
|
expectMatch(t, prepared.raw, fmt.Sprintf(
|
|
`{"candidate":{"childPeerId":%q,"transport":"direct","qualityProbe":false}}`, originalAuth.PeerID))
|
|
}
|
|
|
|
func TestSignalReplaysViewersWhenHostReconnects(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 500})
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, "viewer-client-stable", 1, "", presenceOptions{})
|
|
firstHost := openClient(t, h)
|
|
firstHostAuth := authenticate(t, firstHost, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
viewer.next("host-status")
|
|
firstHost.sendJSON(signalDescription(viewerAuth.PeerID, "connection-before-control-outage", "offer"))
|
|
viewer.next("signal")
|
|
|
|
h.closeClient(firstHost)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":false}`)
|
|
h.closeClient(viewer)
|
|
refreshedViewer := openClient(t, h)
|
|
refreshedViewerAuth := authenticate(t, refreshedViewer, h.room, protocol.RoleViewer, "viewer-client-stable", 1, "", presenceOptions{})
|
|
expectString(t, "peer", refreshedViewerAuth.PeerID, viewerAuth.PeerID)
|
|
expectTrue(t, "host must be offline", !refreshedViewerAuth.HostOnline)
|
|
|
|
secondHost := openClient(t, h)
|
|
secondHostAuth := authenticate(t, secondHost, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
expectString(t, "host peer", secondHostAuth.PeerID, firstHostAuth.PeerID)
|
|
var viewerPeerIDs []string
|
|
h.locked(func() { viewerPeerIDs = h.store.GetViewerPeerIDs(h.room.RoomID) })
|
|
expectStrings(t, "viewer peers", viewerPeerIDs, []string{viewerAuth.PeerID})
|
|
nextActiveRouteRevision(t, secondHost, secondHostAuth.RouteRevision)
|
|
expectMatch(t, refreshedViewer.next("host-status").raw, `{"online":true}`)
|
|
}
|
|
|
|
func TestSignalReconcilesEmptyRosterAfterViewerGracePeriodEndsOffline(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 30})
|
|
firstHost := openClient(t, h)
|
|
firstHostAuth := authenticate(t, firstHost, h.room, protocol.RoleHost, "host-client-roster", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "viewer-client-roster", 1, "", presenceOptions{})
|
|
commitPreparedRoute(t, viewer)
|
|
|
|
h.closeClient(firstHost)
|
|
h.closeClient(viewer)
|
|
time.Sleep(70 * time.Millisecond)
|
|
|
|
secondHost := openClient(t, h)
|
|
secondHostAuth := authenticate(t, secondHost, h.room, protocol.RoleHost, "host-client-roster", 1, "", presenceOptions{})
|
|
expectString(t, "host peer", secondHostAuth.PeerID, firstHostAuth.PeerID)
|
|
var viewerPeerIDs []string
|
|
h.locked(func() { viewerPeerIDs = h.store.GetViewerPeerIDs(h.room.RoomID) })
|
|
if len(viewerPeerIDs) != 0 {
|
|
t.Fatalf("viewer peers: %v", viewerPeerIDs)
|
|
}
|
|
nextActiveRouteRevision(t, secondHost, secondHostAuth.RouteRevision)
|
|
secondHost.expectNone(30 * time.Millisecond)
|
|
}
|
|
|
|
func TestSignalKeepsMediaIdentityThroughGraceWhilePresenceReturnsOnce(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{viewerDisconnectGraceMs: 500})
|
|
firstHost := openClient(t, h)
|
|
firstHostAuth := authenticate(t, firstHost, h.room, protocol.RoleHost, "host-client-grace-roster", 1, "",
|
|
presenceOptions{viewerPresence: true})
|
|
if entries := viewerPresenceEntries(parsePresence(t, firstHost.next("viewer-presence").raw)); len(entries) != 0 {
|
|
t.Fatalf("initial roster: %v", entries)
|
|
}
|
|
firstViewer := openClient(t, h)
|
|
firstViewerAuth := authenticate(t, firstViewer, h.room, protocol.RoleViewer, "viewer-client-grace-roster", 1, "",
|
|
presenceOptions{displayName: "短线重连"})
|
|
commitPreparedRoute(t, firstViewer)
|
|
expectMatch(t, viewerEntriesJSON(t, nextViewerPresenceMatching(t, firstHost, func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.Upstream.Kind == "peer"
|
|
})
|
|
})), fmt.Sprintf(
|
|
`[{"role":"viewer","peerId":%q,"displayName":"短线重连","upstream":{"kind":"peer","peerId":%q}}]`,
|
|
firstViewerAuth.PeerID, firstHostAuth.PeerID))
|
|
firstHost.sendJSON(signalDescription(firstViewerAuth.PeerID, "connection-during-grace", "offer"))
|
|
firstViewer.next("signal")
|
|
|
|
h.closeClient(firstViewer)
|
|
if entries := viewerPresenceEntries(parsePresence(t, firstHost.next("viewer-presence").raw)); len(entries) != 0 {
|
|
t.Fatalf("roster after viewer left: %v", entries)
|
|
}
|
|
h.closeClient(firstHost)
|
|
|
|
secondHost := openClient(t, h)
|
|
secondHostAuth := authenticate(t, secondHost, h.room, protocol.RoleHost, "host-client-grace-roster", 1, "",
|
|
presenceOptions{viewerPresence: true})
|
|
var viewerPeerIDs []string
|
|
h.locked(func() { viewerPeerIDs = h.store.GetViewerPeerIDs(h.room.RoomID) })
|
|
expectStrings(t, "viewer peers", viewerPeerIDs, []string{firstViewerAuth.PeerID})
|
|
if entries := viewerPresenceEntries(parsePresence(t, secondHost.next("viewer-presence").raw)); len(entries) != 0 {
|
|
t.Fatalf("second host roster: %v", entries)
|
|
}
|
|
|
|
secondViewer := openClient(t, h)
|
|
secondViewerAuth := authenticate(t, secondViewer, h.room, protocol.RoleViewer, "viewer-client-grace-roster", 1, "",
|
|
presenceOptions{displayName: "短线重连"})
|
|
expectString(t, "peer", secondViewerAuth.PeerID, firstViewerAuth.PeerID)
|
|
if secondViewerAuth.ConnectionID == nil || *secondViewerAuth.ConnectionID != "connection-during-grace" {
|
|
t.Fatalf("connectionId: %v", secondViewerAuth.ConnectionID)
|
|
}
|
|
expectEqual(t, viewerEntriesJSON(t, nextViewerPresenceMatching(t, secondHost, func(message presenceMessage) bool {
|
|
return slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.PeerID == firstViewerAuth.PeerID
|
|
})
|
|
})), fmt.Sprintf(
|
|
`[{"role":"viewer","peerId":%q,"displayName":"短线重连","upstream":{"kind":"peer","peerId":%q},"mediaReady":true}]`,
|
|
firstViewerAuth.PeerID, secondHostAuth.PeerID))
|
|
expectNoViewerPresenceMatching(t, secondHost, func(message presenceMessage) bool {
|
|
return !slices.ContainsFunc(viewerPresenceEntries(message), func(entry presenceEntry) bool {
|
|
return entry.PeerID == firstViewerAuth.PeerID
|
|
})
|
|
}, 30*time.Millisecond)
|
|
|
|
secondViewer.sendJSON(map[string]any{
|
|
"type": "restart-request",
|
|
"targetPeerId": secondHostAuth.PeerID,
|
|
"connectionId": *secondViewerAuth.ConnectionID,
|
|
"rebuild": false,
|
|
})
|
|
expectMatch(t, secondHost.next("restart-request").raw, fmt.Sprintf(
|
|
`{"fromPeerId":%q,"connectionId":"connection-during-grace","rebuild":false}`, firstViewerAuth.PeerID))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// capacity and abandonment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalStopsSharingWithoutDeletingRoomAndRestrictsRoomAbandonment(t *testing.T) {
|
|
maxViewersPerRoom := 5
|
|
h := startHarness(t, harnessOptions{maxViewersPerRoom: maxViewersPerRoom})
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
if hostAuth.MaxViewers != maxViewersPerRoom {
|
|
t.Fatalf("maxViewers %d", hostAuth.MaxViewers)
|
|
}
|
|
var viewers []*testClient
|
|
for index := 1; index <= maxViewersPerRoom; index++ {
|
|
viewer := openClient(t, h)
|
|
viewerAuth := authenticate(t, viewer, h.room, protocol.RoleViewer, fmt.Sprintf("viewer-client-%d", index), 1, "", presenceOptions{})
|
|
if viewerAuth.MaxViewers != maxViewersPerRoom {
|
|
t.Fatalf("viewer maxViewers %d", viewerAuth.MaxViewers)
|
|
}
|
|
commitPreparedRoute(t, viewer)
|
|
viewers = append(viewers, viewer)
|
|
}
|
|
|
|
overflow := openClient(t, h)
|
|
overflow.sendJSON(map[string]any{
|
|
"type": "authenticate",
|
|
"protocol": protocol.SignalingProtocol,
|
|
"roomId": h.room.RoomID,
|
|
"role": protocol.RoleViewer,
|
|
"viewerGrant": h.room.ViewerGrant,
|
|
"clientId": fmt.Sprintf("viewer-client-%d", maxViewersPerRoom+1),
|
|
})
|
|
expectMatch(t, overflow.next("error").raw, `{"code":"ROOM_FULL"}`)
|
|
|
|
host.sendJSON(map[string]any{"type": "stop-sharing"})
|
|
for _, viewer := range viewers {
|
|
expectEqual(t, viewer.next("sharing-stopped").raw, `{"type":"sharing-stopped"}`)
|
|
expectMatch(t, viewer.next("host-status").raw, `{"online":false}`)
|
|
}
|
|
expectCloseCode(t, host, 1000)
|
|
if size := h.storeSize(); size != 1 {
|
|
t.Fatalf("store size %d", size)
|
|
}
|
|
|
|
viewers[0].sendJSON(map[string]any{"type": "abandon-room"})
|
|
expectMatch(t, viewers[0].next("error").raw, `{"code":"FORBIDDEN"}`)
|
|
if size := h.storeSize(); size != 1 {
|
|
t.Fatalf("store size %d", size)
|
|
}
|
|
|
|
abandonHost := openClient(t, h)
|
|
authenticate(t, abandonHost, h.room, protocol.RoleHost, "host-client-stable", 1, "", presenceOptions{})
|
|
abandonHost.sendJSON(map[string]any{"type": "abandon-room"})
|
|
expectMatch(t, abandonHost.next("room-closed").raw, `{"reason":"host-ended"}`)
|
|
for _, viewer := range viewers {
|
|
expectMatch(t, viewer.next("room-closed").raw, `{"reason":"host-ended"}`)
|
|
}
|
|
if size := h.storeSize(); size != 0 {
|
|
t.Fatalf("store size %d", size)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// transport hardening
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSignalRequiresAllowedOriginTimelyAuthenticationAndBoundedPayloads(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{authenticationTimeoutMs: 30})
|
|
if status := rejectedUpgradeStatus(t, h, allowedOrigin+"/path"); status != 403 {
|
|
t.Fatalf("origin with path: %d", status)
|
|
}
|
|
if status := rejectedUpgradeStatus(t, h, "https://foreign.example.test"); status != 403 {
|
|
t.Fatalf("foreign origin: %d", status)
|
|
}
|
|
|
|
unauthenticated := openClient(t, h)
|
|
expectMatch(t, unauthenticated.nextWithin("error", 300*time.Millisecond).raw, `{"code":"AUTH_REQUIRED"}`)
|
|
expectCloseCode(t, unauthenticated, 4003)
|
|
|
|
oversized := openClient(t, h)
|
|
oversized.sendText(strings.Repeat("x", protocol.MaxSignalBytes+1))
|
|
expectCloseCode(t, oversized, 1009)
|
|
}
|
|
|
|
func TestSignalTerminatesStaleV12BeforeAuthentication(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
invalidClient := openClient(t, h)
|
|
invalidClient.sendJSON(map[string]any{
|
|
"type": "authenticate",
|
|
"protocol": "screener-v12",
|
|
"roomId": h.room.RoomID,
|
|
"role": protocol.RoleViewer,
|
|
"clientId": "invalid-client",
|
|
})
|
|
expectMatch(t, invalidClient.next("error").raw, `{"code":"INVALID_MESSAGE"}`)
|
|
expectClose(t, invalidClient, 1008, "Invalid message")
|
|
}
|
|
|
|
func TestSignalRejectsInvalidUpgradeRequestTargetWithoutStoppingServer(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
for _, testCase := range []struct{ target, status string }{
|
|
// new URL("//[", "http://localhost") throws (signaling.ts:186-190).
|
|
{"//[", "HTTP/1.1 400 Bad Request"},
|
|
// WHATWG pathname is not percent-decoded, so this is not "/signal".
|
|
{"/%73ignal", "HTTP/1.1 404 Not Found"},
|
|
} {
|
|
response := rawUpgradeResponse(t, h, testCase.target)
|
|
if !strings.HasPrefix(response, testCase.status) {
|
|
t.Fatalf("target %q: raw response %q, want %s", testCase.target, response, testCase.status)
|
|
}
|
|
}
|
|
|
|
host := openClient(t, h)
|
|
hostAuth := authenticate(t, host, h.room, protocol.RoleHost, "host-after-invalid-url", 1, "", presenceOptions{})
|
|
expectString(t, "role", hostAuth.Role, protocol.RoleHost)
|
|
}
|
|
|
|
func TestSignalRejectsUnauthenticatedAndTotalConnectionOverflowBeforeUpgrade(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{maxSignalConnections: 2, maxUnauthenticatedSignalConnections: 1})
|
|
host := openClient(t, h)
|
|
if status := rejectedUpgradeStatus(t, h, allowedOrigin); status != 503 {
|
|
t.Fatalf("unauthenticated overflow: %d", status)
|
|
}
|
|
|
|
authenticate(t, host, h.room, protocol.RoleHost, "capacity-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "capacity-viewer", 1, "", presenceOptions{})
|
|
if status := rejectedUpgradeStatus(t, h, allowedOrigin); status != 503 {
|
|
t.Fatalf("total overflow: %d", status)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Go-only coverage: the TS suite never exercised the heartbeat (it ran at
|
|
// 60 s) or the outbound buffer bound; both are new concurrency code here.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// timerQueue is an AfterFunc whose callbacks fire only on demand.
|
|
type timerQueue struct {
|
|
mu sync.Mutex
|
|
entries []*queuedTimer
|
|
}
|
|
|
|
type queuedTimer struct {
|
|
duration time.Duration
|
|
fn func()
|
|
stopped bool
|
|
}
|
|
|
|
func (m *timerQueue) afterFunc(duration time.Duration, fn func()) func() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
entry := &queuedTimer{duration: duration, fn: fn}
|
|
m.entries = append(m.entries, entry)
|
|
return func() bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
stopped := !entry.stopped
|
|
entry.stopped = true
|
|
return stopped
|
|
}
|
|
}
|
|
|
|
// fire runs every pending callback armed with exactly duration.
|
|
func (m *timerQueue) fire(duration time.Duration) {
|
|
m.fireWhere(func(armed time.Duration) bool { return armed == duration })
|
|
}
|
|
|
|
// fireDue runs every pending callback armed with less than limit.
|
|
func (m *timerQueue) fireDue(limit time.Duration) {
|
|
m.fireWhere(func(armed time.Duration) bool { return armed < limit })
|
|
}
|
|
|
|
func (m *timerQueue) fireWhere(due func(time.Duration) bool) {
|
|
m.mu.Lock()
|
|
var fired []*queuedTimer
|
|
m.entries = slices.DeleteFunc(m.entries, func(entry *queuedTimer) bool {
|
|
if entry.stopped {
|
|
return true
|
|
}
|
|
if due(entry.duration) {
|
|
fired = append(fired, entry)
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
m.mu.Unlock()
|
|
for _, entry := range fired {
|
|
entry.fn()
|
|
}
|
|
}
|
|
|
|
func TestSignalHeartbeatTerminatesSilentConnectionAfterMissedPong(t *testing.T) {
|
|
timers := &timerQueue{}
|
|
h := startHarness(t, harnessOptions{afterFunc: timers.afterFunc, cleanupIntervalMs: 30_000})
|
|
heartbeat := 60_000 * time.Millisecond
|
|
responsive := openClient(t, h)
|
|
header := http.Header{}
|
|
header.Set("Origin", allowedOrigin)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
silentConn, _, err := websocket.Dial(ctx, h.wsURL, &websocket.DialOptions{
|
|
HTTPHeader: header,
|
|
// A false return suppresses the pong.
|
|
OnPingReceived: func(context.Context, []byte) bool { return false },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("websocket.Dial: %v", err)
|
|
}
|
|
silent := newTestClient(t, silentConn)
|
|
// The client sees the 101 before the server goroutine registers the
|
|
// session under mu (the slot itself is already reserved), so wait.
|
|
if !waitFor(t, defaultWait, func() bool { return h.sessionCount() == 2 }) {
|
|
t.Fatalf("sessions: %d", h.sessionCount())
|
|
}
|
|
|
|
aliveSessions := func() int {
|
|
alive := 0
|
|
h.locked(func() {
|
|
for sess := range h.server.sessions.All() {
|
|
if sess.alive {
|
|
alive++
|
|
}
|
|
}
|
|
})
|
|
return alive
|
|
}
|
|
timers.fire(heartbeat)
|
|
if !waitFor(t, defaultWait, func() bool { return aliveSessions() == 1 }) {
|
|
t.Fatalf("expected exactly the responsive session to answer the ping, alive=%d", aliveSessions())
|
|
}
|
|
timers.fire(heartbeat)
|
|
silent.closeCode()
|
|
if !waitFor(t, defaultWait, func() bool { return h.sessionCount() == 1 }) {
|
|
t.Fatalf("silent session was not terminated")
|
|
}
|
|
expectTrue(t, "responsive client must stay open", !responsive.isClosed())
|
|
hostAuth := authenticate(t, responsive, h.room, protocol.RoleHost, "heartbeat-host", 1, "", presenceOptions{})
|
|
expectString(t, "role", hostAuth.Role, protocol.RoleHost)
|
|
}
|
|
|
|
func TestSignalTerminatesConnectionAboveOutboundBufferBound(t *testing.T) {
|
|
h := startHarness(t, harnessOptions{})
|
|
over := newSession(nil, true)
|
|
closed := newSession(nil, true)
|
|
h.locked(func() {
|
|
over.queuedBytes = maxBufferedSignalBytes + 1
|
|
h.server.sendEncoded(over, []byte("{}"))
|
|
closed.close(websocket.StatusNormalClosure, "bye")
|
|
h.server.sendEncoded(closed, []byte("{}"))
|
|
})
|
|
expectTrue(t, "a session above the bound is terminated, not queued", over.terminated && len(over.queue) == 0)
|
|
expectTrue(t, "a closing session drops sends without terminating", !closed.terminated && len(closed.queue) == 1)
|
|
}
|
|
|
|
// TS signaling.ts:233-259: close() set `closing` and ran the router's close()
|
|
// up to its first await in the same tick, so no router timer could act in
|
|
// between. In Go a deadline callback parked on mu while Close runs must find
|
|
// the router already closing; otherwise it advances the operation and starts
|
|
// a pump that creates a LiveKit room during shutdown. The interleaving is
|
|
// forced through sync.Mutex starvation mode: a waiter that has waited over
|
|
// 1 ms and wakes to a locked mutex switches it to starvation mode, after
|
|
// which Unlock hands the lock to the front waiter in FIFO order, so Close
|
|
// (queued first) runs its locked prefix and its Unlock hands the lock
|
|
// straight to the callback (queued second) with no chance to re-acquire.
|
|
func TestSignalCloseMarksRouterClosingBeforeReleasingTheLock(t *testing.T) {
|
|
timers := &timerQueue{}
|
|
now := newClock(utcMillis(2026, time.August, 24, 12))
|
|
control := sfutest.New()
|
|
h := startHarness(t, harnessOptions{
|
|
afterFunc: timers.afterFunc,
|
|
now: now.now,
|
|
sfu: &SfuFallback{
|
|
URL: "wss://sfu.example.test",
|
|
TokenIssuer: &fakeTokenIssuer{},
|
|
Admission: sfu.NewAdmission(sfu.AdmissionOptions{IngressCapacity: 2, EgressCapacity: 20}),
|
|
RoomControl: control,
|
|
PrepareTimeoutMs: 300,
|
|
},
|
|
})
|
|
host := openClient(t, h)
|
|
authenticate(t, host, h.room, protocol.RoleHost, "close-window-host", 1, "", presenceOptions{})
|
|
viewer := openClient(t, h)
|
|
authenticate(t, viewer, h.room, protocol.RoleViewer, "close-window-viewer", 1, "", presenceOptions{})
|
|
// The direct candidate is prepared; once the pump driver settles its
|
|
// boundary deadline (150 ms) is the only short timer armed.
|
|
nextPreparedRoute(t, viewer)
|
|
routerIdle := func() bool {
|
|
idle := false
|
|
h.locked(func() { idle = h.server.router.inflight == 0 })
|
|
return idle
|
|
}
|
|
if !waitFor(t, defaultWait, routerIdle) {
|
|
t.Fatal("pump driver did not settle")
|
|
}
|
|
now.advance(150)
|
|
|
|
h.server.mu.Lock()
|
|
closed := make(chan error, 1)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
closed <- h.server.Close(ctx)
|
|
}()
|
|
time.Sleep(50 * time.Millisecond) // Close is queued on mu first...
|
|
fired := make(chan struct{})
|
|
go func() {
|
|
timers.fireDue(time.Minute) // ...the deadline callback second.
|
|
close(fired)
|
|
}()
|
|
time.Sleep(50 * time.Millisecond)
|
|
// Wake Close and take the lock back before it runs: it finds the mutex
|
|
// locked after a long wait, flips starvation mode and requeues at the
|
|
// front. From here every Unlock is a FIFO handoff.
|
|
h.server.mu.Unlock()
|
|
h.server.mu.Lock()
|
|
time.Sleep(50 * time.Millisecond)
|
|
h.server.mu.Unlock()
|
|
|
|
if err := <-closed; err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
<-fired
|
|
if !waitFor(t, defaultWait, routerIdle) {
|
|
t.Fatal("router goroutines did not settle after Close")
|
|
}
|
|
if created := control.Created(); len(created) != 0 {
|
|
t.Fatalf("a deadline firing during Close created %d LiveKit room(s)", len(created))
|
|
}
|
|
h.close()
|
|
}
|