Files
Warp/server/internal/clientapi/voip_test.go
T
tajniak81andClaude Opus 5.5 325431a5be Voice and video calls: TURN endpoint and web client calls
Server: GET /voip/turnServer, configured under client_api.turn. A shared
secret (coturn use-auth-secret) gives each user expiring TURN REST API
credentials; static credentials are the fallback; with nothing configured
it answers {} so clients still try a direct connection.

Web client: 1:1 calls (Matrix VoIP via matrix-js-sdk) in rooms with one
other person. Voice and video buttons in the room header, a ringing card
with answer, answer without video and decline, and a call panel that
stays up across rooms: mute, camera, screen sharing, expand, hang up,
ring tones. Call events show in the timeline and room list. Works around
the SDK keeping a failed microphone request forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:04:02 +02:00

90 lines
2.6 KiB
Go

package clientapi_test
import (
"crypto/hmac"
"crypto/sha1" //nolint:gosec // The TURN REST API is HMAC-SHA1.
"encoding/base64"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
)
const turnPath = "/_matrix/client/v3/voip/turnServer"
type turnResponse struct {
URIs []string `json:"uris"`
Username string `json:"username"`
Password string `json:"password"`
TTL int64 `json:"ttl"`
}
func TestTurnServerNeedsAToken(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(turnPath, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
}
func TestTurnServerUnconfiguredIsEmpty(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "correct horse battery")
rec := ts.get(turnPath, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
if body := jsonBody(t, rec); len(body) != 0 {
t.Errorf("body = %v, want an empty object", body)
}
}
func TestTurnServerSharedSecret(t *testing.T) {
const secret = "s3cret"
ts := newTestServer(t, func(o *clientapi.Options) {
o.TURN = config.TURN{
URIs: []string{"turn:turn.warp.example:3478?transport=udp"},
SharedSecret: secret,
UserLifetime: time.Hour,
}
})
acc := ts.register("alice", "correct horse battery")
before := time.Now().Unix()
rec := ts.get(turnPath, acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
got := decode[turnResponse](t, rec)
if len(got.URIs) != 1 || got.TTL != 3600 {
t.Fatalf("response = %+v", got)
}
expiry, user, ok := strings.Cut(got.Username, ":")
if !ok || user != acc.UserID {
t.Fatalf("username = %q, want <expiry>:%s", got.Username, acc.UserID)
}
if n, err := strconv.ParseInt(expiry, 10, 64); err != nil || n < before+3600 || n > before+3600+60 {
t.Errorf("expiry = %q, want about an hour from now", expiry)
}
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(got.Username))
if want := base64.StdEncoding.EncodeToString(mac.Sum(nil)); got.Password != want {
t.Errorf("password = %q, want %q", got.Password, want)
}
}
func TestTurnServerStatic(t *testing.T) {
ts := newTestServer(t, func(o *clientapi.Options) {
o.TURN = config.TURN{
URIs: []string{"turns:turn.warp.example:5349", "stun:turn.warp.example:3478"},
Username: "warp",
Password: "hunter2",
}
})
acc := ts.register("alice", "correct horse battery")
got := decode[turnResponse](t, ts.get(turnPath, acc.AccessToken))
if len(got.URIs) != 2 || got.Username != "warp" || got.Password != "hunter2" || got.TTL <= 0 {
t.Errorf("response = %+v", got)
}
}