using System.IdentityModel.Tokens.Jwt; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SameBeat.Application.Zapytania; using SameBeat.Domain.Enumy; using SameBeat.Infrastructure.LiveKit; using Xunit; namespace SameBeat.Tests.Unit; public class SerwisTokenowLiveKitTests { private const string PokojTestowy = "pokoj-testowy"; private static SerwisTokenowLiveKit UtworzSerwis(string apiKey = "testowy-klucz", string apiSecret = "testowy-sekret-min-32-znaki-dlugosci!!") { var ustawienia = new UstawieniaLiveKit { Url = "wss://przyklad.livekit.cloud", ApiKey = apiKey, ApiSecret = apiSecret, NazwaPokojuGlowna = "impreza-glowna", NazwaPokojuPoczekalnia = "impreza-poczekalnia", CzasZyciaSekundy = 3600 }; return new SerwisTokenowLiveKit(Options.Create(ustawienia)); } private static JsonElement OdczytajGrantVideo(string token) { var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); var claimVideo = jwt.Claims.Single(c => c.Type == "video"); return JsonDocument.Parse(claimVideo.Value).RootElement.Clone(); } [Fact] public void WygenerujToken_ZwracaTokenPodpisanyOczekiwanymSekretem() { const string apiSecret = "testowy-sekret-min-32-znaki-dlugosci!!"; var serwis = UtworzSerwis(apiSecret: apiSecret); var token = serwis.WygenerujToken("piotr", "piotr", RolaUzytkownika.Widz, PokojTestowy); var parametry = new TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = false, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(apiSecret)) }; // Jesli podpis nie zgadza sie z sekretem, ta linia rzuci wyjatek -- to jest wlasciwy test // "poprawnej struktury JWT z odpowiednimi claimami" wymagany dla etapu 1. new JwtSecurityTokenHandler().ValidateToken(token, parametry, out _); } [Fact] public void WygenerujToken_ZawieraClaimyWymaganePrzezSpecyfikacjeLiveKit() { var serwis = UtworzSerwis(apiKey: "moj-klucz-api"); var token = serwis.WygenerujToken("uczestnik-abc123", "piotr", RolaUzytkownika.Uczestnik, PokojTestowy); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Equal("moj-klucz-api", jwt.Issuer); Assert.Contains(jwt.Claims, c => c.Type == "sub" && c.Value == "uczestnik-abc123"); Assert.Contains(jwt.Claims, c => c.Type == "name" && c.Value == "piotr"); Assert.Contains(jwt.Claims, c => c.Type == "nbf"); Assert.Contains(jwt.Claims, c => c.Type == "exp"); var video = OdczytajGrantVideo(token); Assert.True(video.GetProperty("roomJoin").GetBoolean()); Assert.Equal(PokojTestowy, video.GetProperty("room").GetString()); Assert.True(video.GetProperty("canSubscribe").GetBoolean()); } [Fact] public void WygenerujToken_DlaWidza_NieMaPrawaPublikowania() { var serwis = UtworzSerwis(); var token = serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Widz, PokojTestowy); var video = OdczytajGrantVideo(token); Assert.False(video.GetProperty("canPublish").GetBoolean()); Assert.True(video.GetProperty("canSubscribe").GetBoolean()); // Widz nie moze publikowac niczego -- brak canPublishSources w ogole (spojne z canPublish=false). Assert.False(video.TryGetProperty("canPublishSources", out _)); } [Fact] public void WygenerujToken_DlaUczestnika_MozePublikowacTylkoKamere() { var serwis = UtworzSerwis(); var token = serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Uczestnik, PokojTestowy); var video = OdczytajGrantVideo(token); Assert.True(video.GetProperty("canPublish").GetBoolean()); var zrodla = video.GetProperty("canPublishSources").EnumerateArray().Select(e => e.GetString()).ToArray(); // KLUCZOWE dla bezpieczenstwa: kamera jest jedynym dozwolonym zrodlem -- mikrofon NIGDY nie // jest publikowany domyslnie przez Uczestnika. Assert.Single(zrodla); Assert.Contains("camera", zrodla); Assert.DoesNotContain("microphone", zrodla); Assert.DoesNotContain("screen_share", zrodla); } [Fact] public void WygenerujToken_DlaHosta_NieMaPrawaPublikowaniaTylkoSubskrypcje() { var serwis = UtworzSerwis(); var token = serwis.WygenerujToken("host-piotr", "host-piotr", RolaUzytkownika.Host, PokojTestowy); var video = OdczytajGrantVideo(token); // Host steruje przez REST/SignalR, nie przez wlasna kamere/mikrofon -- na tym etapie // dostaje wylacznie prawo do subskrybowania (podglad kamer z poczekalni). Assert.False(video.GetProperty("canPublish").GetBoolean()); Assert.True(video.GetProperty("canSubscribe").GetBoolean()); Assert.False(video.TryGetProperty("canPublishSources", out _)); } [Fact] public void WygenerujToken_WidzIUczestnik_MajaRozneGrantyPublikowania() { var serwis = UtworzSerwis(); var videoWidza = OdczytajGrantVideo(serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Widz, PokojTestowy)); var videoUczestnika = OdczytajGrantVideo(serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Uczestnik, PokojTestowy)); Assert.NotEqual( videoWidza.GetProperty("canPublish").GetBoolean(), videoUczestnika.GetProperty("canPublish").GetBoolean()); } [Fact] public void WygenerujToken_RozneNazwyPokoju_DajaRozneClaimyRoom() { // Rozdzielenie pokoju glownego (widzowie) od poczekalni (uczestnicy + podglad hosta) jest // celowe -- anonimowy widz nie moze podejrzec kamery kogos, kogo host jeszcze nie zaakceptowal. // Ten test pilnuje, zeby nazwa pokoju faktycznie sterowala claimem "room" w tokenie. var serwis = UtworzSerwis(); var videoGlowny = OdczytajGrantVideo(serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Widz, "impreza-glowna")); var videoPoczekalni = OdczytajGrantVideo(serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Host, "impreza-poczekalnia")); Assert.Equal("impreza-glowna", videoGlowny.GetProperty("room").GetString()); Assert.Equal("impreza-poczekalnia", videoPoczekalni.GetProperty("room").GetString()); } [Fact] public void WygenerujToken_BrakNazwyPokoju_RzucaCzytelnyWyjatek() { var serwis = UtworzSerwis(); Assert.Throws(() => serwis.WygenerujToken("gosc", "gosc", RolaUzytkownika.Widz, string.Empty)); } [Fact] public void WygenerujToken_BrakNazwyWyswietlanej_RzucaCzytelnyWyjatek() { var serwis = UtworzSerwis(); Assert.Throws(() => serwis.WygenerujToken("gosc", string.Empty, RolaUzytkownika.Widz, PokojTestowy)); } [Fact] public void WygenerujToken_IdentityINazwaWyswietlanaMogaSieRoznic() { // To jest dokladnie przypadek Uczestnika: "identity" to losowy LiveKitIdentity (nigdy nie // ujawniany widzom/hostowi), "name" to nick -- po nim frontend dopasowuje wideo do slotu. var serwis = UtworzSerwis(); var token = serwis.WygenerujToken("uczestnik-losowy-guid", "Piotr", RolaUzytkownika.Uczestnik, PokojTestowy); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Contains(jwt.Claims, c => c.Type == "sub" && c.Value == "uczestnik-losowy-guid"); Assert.Contains(jwt.Claims, c => c.Type == "name" && c.Value == "Piotr"); } [Fact] public void ZadanieTokenuWidzaZapytanie_NieMaPolaRola() { // Test-strazak przeciwko regresji: publiczny endpoint POST /api/livekit/token musi zawsze // wymuszac Widz. Jesli ktos kiedys doda tu pole "Rola", zeby "ulatwic sobie" wywolanie z // frontendu, ten test wybuchnie i przypomni, dlaczego typu nie ma i miec nie moze. var wlasciwosci = typeof(ZadanieTokenuWidzaZapytanie).GetProperties().Select(p => p.Name); Assert.DoesNotContain("Rola", wlasciwosci); } }