Files
glchat/internal/voice/token_test.go
T
grendervill e9455ab6fd feat(voice): вебхуки LiveKit и сторож голосовых состояний
AGENT.md 7.14:

- POST /api/v1/livekit/webhook: подпись проверяется JWT-токеном, подписанным
  API-секретом, с проверкой sha256 тела (иначе 401), при выключенном голосе —
  503; обработка идемпотентна по event.id (повторная доставка → duplicate);
- события: participant_joined восстанавливает состояние после рестарта
  приложения, participant_left и participant_connection_aborted убирают
  участника, room_finished очищает комнату целиком, track_published и
  track_unpublished обновляют флаги камеры и демонстрации экрана; каждое
  изменение рассылает VOICE_STATE_UPDATE участникам сервера;
- сторож `StartVoiceWatchdog` раз в 15 минут удаляет состояния без обновлений
  дольше шести часов (сбои без вебхука) и уведомляет клиентов;
- тесты: подпись (чужой секрет, другое тело, отсутствие заголовка), полный
  цикл событий SFU и идемпотентность повторной доставки.
2026-09-20 18:31:51 +03:00

123 lines
4.0 KiB
Go

package voice_test
import (
"encoding/base64"
"encoding/json"
"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) {
url, err := voice.ResolveLiveKitURL("wss://gl.example/rtc/")
if err != nil || url != "wss://gl.example/rtc" {
t.Fatalf("ResolveLiveKitURL = %q, %v", url, err)
}
if _, err := voice.ResolveLiveKitURL("http://gl.example"); err == nil {
t.Fatal("http-адрес должен отклоняться")
}
}
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)
}
}