2538e07ec3
LiveKit SDK сам дописывает сигнальный путь (`/rtc`, `/rtc/v1`) к базовому
адресу, поэтому `wss://host/rtc` из конфига превращался в `wss://host/rtc/rtc`
и комната навсегда оставалась в состоянии «Подключение…» (AGENT.md 7.14).
- `voice.ResolveLiveKitURL` снимает завершающий сегмент `/rtc`, сохраняя
префикс прокси (`wss://host/livekit/rtc` → `wss://host/livekit`);
- `POST /channels/{id}/voice/join` отдаёт адрес через `VoiceURLForClient` и
отвечает `voice.disabled`, если адрес не разрешился;
- тесты: таблица адресов, ожидание `wss://gl.test` в голосовом сценарии.
141 lines
4.9 KiB
Go
141 lines
4.9 KiB
Go
package voice_test
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"glchat/internal/voice"
|
|
)
|
|
|
|
func TestIssueTokenHasLiveKitClaims(t *testing.T) {
|
|
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
|
issuer.SetClock(func() time.Time { return time.Unix(1_700_000_000, 0) })
|
|
|
|
token, err := issuer.Issue(voice.RoomName(10, 20), "42", "Алиса", voice.Grants{
|
|
RoomJoin: true, CanPublish: true, CanSubscribe: true, CanPublishData: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Issue: %v", err)
|
|
}
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts))
|
|
}
|
|
header, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
if err != nil {
|
|
t.Fatalf("decode header: %v", err)
|
|
}
|
|
if !strings.Contains(string(header), "HS256") {
|
|
t.Fatalf("header = %s", header)
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
t.Fatalf("decode payload: %v", err)
|
|
}
|
|
var claims struct {
|
|
Issuer string `json:"iss"`
|
|
Subject string `json:"sub"`
|
|
Name string `json:"name"`
|
|
Video struct {
|
|
Room string `json:"room"`
|
|
RoomJoin bool `json:"roomJoin"`
|
|
CanPublish bool `json:"canPublish"`
|
|
} `json:"video"`
|
|
ExpiresAt int64 `json:"exp"`
|
|
}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
t.Fatalf("decode claims: %v", err)
|
|
}
|
|
if claims.Issuer != "key123" || claims.Subject != "42" || claims.Name != "Алиса" {
|
|
t.Fatalf("claims = %+v", claims)
|
|
}
|
|
if claims.Video.Room != "guild_10_channel_20" || !claims.Video.RoomJoin || !claims.Video.CanPublish {
|
|
t.Fatalf("video grants = %+v", claims.Video)
|
|
}
|
|
if claims.ExpiresAt != 1_700_000_000+3600 {
|
|
t.Fatalf("exp = %d", claims.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestIssuerDisabledWithoutKeys(t *testing.T) {
|
|
issuer := voice.NewIssuer("", "", time.Hour)
|
|
if issuer.Enabled() {
|
|
t.Fatal("пустые ключи должны означать выключенный голос")
|
|
}
|
|
if _, err := issuer.Issue("room", "1", "user", voice.Grants{}); err == nil {
|
|
t.Fatal("Issue без ключей должен возвращать ошибку")
|
|
}
|
|
}
|
|
|
|
func TestParseRoomName(t *testing.T) {
|
|
guildID, channelID, ok := voice.ParseRoomName("guild_123_channel_456")
|
|
if !ok || guildID != 123 || channelID != 456 {
|
|
t.Fatalf("ParseRoomName = %d/%d/%t", guildID, channelID, ok)
|
|
}
|
|
if _, _, ok := voice.ParseRoomName("что-то другое"); ok {
|
|
t.Fatal("посторонняя строка не должна разбираться как имя комнаты")
|
|
}
|
|
}
|
|
|
|
func TestResolveLiveKitURL(t *testing.T) {
|
|
cases := map[string]string{
|
|
"wss://gl.example/rtc/": "wss://gl.example",
|
|
"wss://gl.example/rtc": "wss://gl.example",
|
|
"wss://gl.example": "wss://gl.example",
|
|
"wss://gl.example/": "wss://gl.example",
|
|
"wss://gl.example/livekit/rtc": "wss://gl.example/livekit",
|
|
"wss://gl.example:7880/rtc": "wss://gl.example:7880",
|
|
"ws://192.168.1.247:7880/rtc/": "ws://192.168.1.247:7880",
|
|
"wss://gl.example/notrtc": "wss://gl.example/notrtc",
|
|
"wss://gl.example/rtc/extra": "wss://gl.example/rtc/extra",
|
|
"wss://gl.example/livekit/rtcx": "wss://gl.example/livekit/rtcx",
|
|
}
|
|
for raw, want := range cases {
|
|
got, err := voice.ResolveLiveKitURL(raw)
|
|
if err != nil || got != want {
|
|
t.Fatalf("ResolveLiveKitURL(%q) = %q, %v; ожидалось %q", raw, got, err, want)
|
|
}
|
|
}
|
|
if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil {
|
|
t.Fatal("http-адрес должен отклоняться")
|
|
}
|
|
if _, err := voice.ResolveLiveKitURL(""); !errors.Is(err, voice.ErrDisabled) {
|
|
t.Fatalf("пустой адрес = %v, ожидалось ErrDisabled", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyWebhookSignature(t *testing.T) {
|
|
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
|
body := []byte(`{"event":"participant_joined"}`)
|
|
|
|
// Без заголовка — отказ.
|
|
if err := issuer.VerifyWebhook("", body); err == nil {
|
|
t.Fatal("подпись обязательна")
|
|
}
|
|
// Чужой подписью — отказ.
|
|
other := voice.NewIssuer("key123", "another-secret", time.Hour)
|
|
token, err := other.IssueWebhookForTest(body)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
if err := issuer.VerifyWebhook(token, body); err == nil {
|
|
t.Fatal("подпись чужим секретом должна отклоняться")
|
|
}
|
|
// Своей подписью, но другим телом — отказ.
|
|
valid, err := issuer.IssueWebhookForTest(body)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
if err := issuer.VerifyWebhook(valid, []byte(`{"event":"room_finished"}`)); err == nil {
|
|
t.Fatal("несовпадение тела должно отклоняться")
|
|
}
|
|
// Корректная подпись — успех.
|
|
if err := issuer.VerifyWebhook(valid, body); err != nil {
|
|
t.Fatalf("VerifyWebhook: %v", err)
|
|
}
|
|
}
|