fix(voice): административный токен RoomService привязан к комнате

LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без
`video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и
отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14).

- `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет
  пустое имя комнаты;
- `AdminClient.call` выпускает токен под конкретную комнату каждого вызова
  (GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants);
- тест на состав грахта и отказ при пустой комнате;
- e2e: администратор и гость работают в отдельных api-контекстах (код 2FA
  одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей
  кнопки.
This commit is contained in:
2026-09-20 19:54:21 +03:00
parent 912a067c9a
commit a6d23a295e
4 changed files with 108 additions and 45 deletions
+37
View File
@@ -61,6 +61,43 @@ func TestIssueTokenHasLiveKitClaims(t *testing.T) {
}
}
func TestIssueAdminTokenIsRoomScoped(t *testing.T) {
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
token, err := issuer.IssueAdmin("guild_1_channel_2")
if err != nil {
t.Fatalf("IssueAdmin: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("decode payload: %v", err)
}
var claims struct {
Issuer string `json:"iss"`
Video struct {
Room string `json:"room"`
RoomAdmin bool `json:"roomAdmin"`
} `json:"video"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatalf("decode claims: %v", err)
}
// LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room`
// RoomService отвечает 401 `permissions denied` (проверено на 1.9.7).
if claims.Issuer != "key123" || !claims.Video.RoomAdmin {
t.Fatalf("административные права = %+v", claims)
}
if claims.Video.Room != "guild_1_channel_2" {
t.Fatalf("комната в грахте = %q", claims.Video.Room)
}
if _, err := issuer.IssueAdmin(" "); err == nil {
t.Fatal("пустая комната должна отклоняться")
}
}
func TestIssuerDisabledWithoutKeys(t *testing.T) {
issuer := voice.NewIssuer("", "", time.Hour)
if issuer.Enabled() {