using Microsoft.Extensions.Logging.Abstractions; using SameBeat.Application.DTO; using SameBeat.Application.Interfejsy; using SameBeat.Application.Komendy; using SameBeat.Application.Serwisy; using SameBeat.Domain.Encje; using SameBeat.Domain.Enumy; using SameBeat.Infrastructure.Autoryzacja; using Xunit; namespace SameBeat.Tests.Unit; /// /// Testy lancucha bezpieczenstwa "poczekalnia -> scena": walidacja slotu wzgledem ukladu, korekta po /// zmianie ukladu i weryfikacja sekretu sesji przy wydawaniu tokenu sceny. Zaleznosci od bazy danych /// sa zastapione prostymi fejkami w pamieci (projekt nie uzywa biblioteki do mockowania) -- hashowanie /// sekretu jest jednak PRAWDZIWE (Infrastructure.UslugaHaszowaniaSekretowSesji), zeby test faktycznie /// pilnowal calego mechanizmu, nie tylko atrapy. /// public class SerwisScenyTests { private sealed class FejkRepozytoriumWydarzen : IRepozytoriumWydarzen { private readonly List _wydarzenia = new(); public Task PobierzOstatnieAsync(CancellationToken token = default) => Task.FromResult(_wydarzenia.LastOrDefault()); public Task ZnajdzPoIdAsync(Guid id, CancellationToken token = default) => Task.FromResult(_wydarzenia.SingleOrDefault(w => w.Id == id)); public void Dodaj(Wydarzenie wydarzenie) => _wydarzenia.Add(wydarzenie); } private sealed class FejkRepozytoriumPoczekalni : IRepozytoriumPoczekalni { private readonly List _uczestnicy = new(); public Task ZnajdzPoIdAsync(Guid id, CancellationToken token = default) => Task.FromResult(_uczestnicy.SingleOrDefault(u => u.Id == id)); public Task> PobierzOczekujacychDlaWydarzeniaAsync(Guid wydarzenieId, CancellationToken token = default) => Task.FromResult(_uczestnicy.Where(u => u.WydarzenieId == wydarzenieId && u.Status == StatusUczestnikaOczekujacego.Oczekuje).ToList()); public void Dodaj(UczestnikOczekujacy uczestnik) => _uczestnicy.Add(uczestnik); } private sealed class FejkRepozytoriumSceny : IRepozytoriumSceny { private readonly List _sloty = new(); private readonly FejkRepozytoriumPoczekalni _repozytoriumPoczekalni; public FejkRepozytoriumSceny(FejkRepozytoriumPoczekalni repozytoriumPoczekalni) { _repozytoriumPoczekalni = repozytoriumPoczekalni; } public Task ZnajdzPoNumerzeAsync(Guid wydarzenieId, int numerSlotu, CancellationToken token = default) => Task.FromResult(_sloty.SingleOrDefault(s => s.WydarzenieId == wydarzenieId && s.NumerSlotu == numerSlotu)); public Task ZnajdzPoUczestnikuAsync(Guid wydarzenieId, Guid uczestnikOczekujacyId, CancellationToken token = default) => Task.FromResult(_sloty.SingleOrDefault(s => s.WydarzenieId == wydarzenieId && s.UczestnikOczekujacyId == uczestnikOczekujacyId)); public Task> PobierzZajeteDlaWydarzeniaAsync(Guid wydarzenieId, CancellationToken token = default) => Task.FromResult(_sloty.Where(s => s.WydarzenieId == wydarzenieId && s.UczestnikOczekujacyId != null).ToList()); public async Task> PobierzZajeteZNickamiAsync(Guid wydarzenieId, CancellationToken token = default) { var wynik = new List(); foreach (var slot in _sloty.Where(s => s.WydarzenieId == wydarzenieId && s.UczestnikOczekujacyId != null).OrderBy(s => s.NumerSlotu)) { var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(slot.UczestnikOczekujacyId!.Value, token); wynik.Add(new SlotScenyDto(slot.NumerSlotu, uczestnik!.Nick)); } return wynik; } public void Dodaj(SlotSceny slot) => _sloty.Add(slot); } private sealed class FejkJednostkaPracy : IJednostkaPracy { public Task ZapiszZmianyAsync(CancellationToken token = default) => Task.FromResult(0); } private sealed class FejkSerwisTokenowLiveKit : ISerwisTokenowLiveKit { public string? OstatniaTozsamosc { get; private set; } public string? OstatniaNazwaWyswietlana { get; private set; } public string? OstatniPokoj { get; private set; } public string WygenerujToken(string identity, string nazwaWyswietlana, RolaUzytkownika rola, string nazwaPokoju) { OstatniaTozsamosc = identity; OstatniaNazwaWyswietlana = nazwaWyswietlana; OstatniPokoj = nazwaPokoju; return "token-testowy"; } } private sealed class FejkSerwisAdministracjiLiveKit : ISerwisAdministracjiLiveKit { public List<(string Identity, string Pokoj)> WywolaniaOdbierzUprawnieniaSceny { get; } = new(); public Task UstawUprawnieniaMikrofonuAsync(string identity, string nazwaPokoju, bool mikrofonWlaczony, CancellationToken token = default) => Task.CompletedTask; public Task OdbierzUprawnieniaScenyAsync(string identity, string nazwaPokoju, CancellationToken token = default) { WywolaniaOdbierzUprawnieniaSceny.Add((identity, nazwaPokoju)); return Task.CompletedTask; } } private const string PokojGlowny = "impreza-glowna"; private const string UrlLiveKit = "wss://przyklad.livekit.cloud"; private readonly FejkRepozytoriumWydarzen _repozytoriumWydarzen = new(); private readonly FejkRepozytoriumPoczekalni _repozytoriumPoczekalni = new(); private readonly FejkRepozytoriumSceny _repozytoriumSceny; private readonly FejkSerwisTokenowLiveKit _serwisTokenowLiveKit = new(); private readonly FejkSerwisAdministracjiLiveKit _serwisAdministracjiLiveKit = new(); private readonly IUslugaHaszowaniaSekretowSesji _uslugaHaszowaniaSekretowSesji = new UslugaHaszowaniaSekretowSesji(); private readonly SerwisSceny _serwis; public SerwisScenyTests() { _repozytoriumSceny = new FejkRepozytoriumSceny(_repozytoriumPoczekalni); _serwis = new SerwisSceny( _repozytoriumWydarzen, _repozytoriumPoczekalni, _repozytoriumSceny, _serwisTokenowLiveKit, _serwisAdministracjiLiveKit, _uslugaHaszowaniaSekretowSesji, new FejkJednostkaPracy(), UrlLiveKit, PokojGlowny, NullLogger.Instance); } private Wydarzenie DodajWydarzenie() { var wydarzenie = Wydarzenie.Utworz("Testowa impreza"); _repozytoriumWydarzen.Dodaj(wydarzenie); return wydarzenie; } private UczestnikOczekujacy DodajUczestnika(Guid wydarzenieId, string nick = "Gosc") { var uczestnik = UczestnikOczekujacy.Dolacz(wydarzenieId, nick); var sekret = _uslugaHaszowaniaSekretowSesji.WygenerujSekret(); uczestnik.UstawSekretSesji(_uslugaHaszowaniaSekretowSesji.Haszuj(uczestnik, sekret)); _repozytoriumPoczekalni.Dodaj(uczestnik); return uczestnik; } // ---- Walidacja numeru slotu wzgledem ukladu ---- [Fact] public async Task PrzypiszSlotAsync_NumerSlotuPozaZakresemUkladu_ZwracaNiepowodzenie() { var wydarzenie = DodajWydarzenie(); // domyslnie Grid4 -> sloty 1..4 var uczestnik = DodajUczestnika(wydarzenie.Id); var wynik = await _serwis.PrzypiszSlotAsync(5, uczestnik.Id); Assert.False(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.Oczekuje, uczestnik.Status); } [Fact] public async Task PrzypiszSlotAsync_NumerSlotuWZakresie_PrzypisujeIWpuszczaNaScene() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); var wynik = await _serwis.PrzypiszSlotAsync(4, uczestnik.Id); Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.NaScenie, uczestnik.Status); Assert.Single(wynik.Dane!.Sloty); Assert.Equal(4, wynik.Dane.Sloty[0].NumerSlotu); Assert.Equal(uczestnik.Nick, wynik.Dane.Sloty[0].Nick); } [Fact] public async Task PrzypiszSlotAsync_UczestnikNieOczekuje_ZwracaNiepowodzenie() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); uczestnik.Odrzuc(); var wynik = await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); Assert.False(wynik.Sukces); } [Fact] public async Task PrzypiszSlotAsync_SlotZajetyPrzezInnego_ZwalniaPoprzedniegoUczestnika() { var wydarzenie = DodajWydarzenie(); var pierwszy = DodajUczestnika(wydarzenie.Id, "Ala"); var drugi = DodajUczestnika(wydarzenie.Id, "Bartek"); await _serwis.PrzypiszSlotAsync(1, pierwszy.Id); var wynik = await _serwis.PrzypiszSlotAsync(1, drugi.Id); Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.Oczekuje, pierwszy.Status); Assert.Equal(StatusUczestnikaOczekujacego.NaScenie, drugi.Status); Assert.Single(wynik.Dane!.Sloty); Assert.Equal("Bartek", wynik.Dane.Sloty[0].Nick); // ETAP 7: "przeciagniecie" slotu na nowego uczestnika to dla poprzedniego dokladnie taki sam // przypadek jak reczne zdjecie ze sceny -- musi wiec tak samo twardo stracic uprawnienia w LiveKit. var wywolanie = Assert.Single(_serwisAdministracjiLiveKit.WywolaniaOdbierzUprawnieniaSceny); Assert.Equal(pierwszy.LiveKitIdentity, wywolanie.Identity); } [Fact] public async Task PrzypiszSlotAsync_TenSamUczestnikTenSamSlot_JestNoOp() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); var wynik = await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.NaScenie, uczestnik.Status); Assert.Single(wynik.Dane!.Sloty); } // ---- Korekta slotow przy zmianie ukladu ---- [Fact] public async Task ZmienUkladAsync_ZwalniaSieroceSlotyPonadNowyLimit() { var wydarzenie = DodajWydarzenie(); await _serwis.ZmienUkladAsync(UkladSceny.Grid9); // 1..9 dostepne var uczestnikNaSlocie6 = DodajUczestnika(wydarzenie.Id, "Slot6"); await _serwis.PrzypiszSlotAsync(6, uczestnikNaSlocie6.Id); var wynik = await _serwis.ZmienUkladAsync(UkladSceny.Grid4); // teraz tylko 1..4 -- slot 6 sierocy Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.Oczekuje, uczestnikNaSlocie6.Status); Assert.Empty(wynik.Dane!.Sloty); // ETAP 7: sierocy slot po zmniejszeniu ukladu to TEN SAM przypadek "uczestnik przestal byc na // scenie" co reczne ZwolnijSlotAsync -- musi wiec tak samo twardo wymusic to w LiveKit. var wywolanie = Assert.Single(_serwisAdministracjiLiveKit.WywolaniaOdbierzUprawnieniaSceny); Assert.Equal(uczestnikNaSlocie6.LiveKitIdentity, wywolanie.Identity); Assert.Equal(PokojGlowny, wywolanie.Pokoj); } [Fact] public async Task ZmienUkladAsync_ZachowujeSlotyWGranicachNowegoUkladu() { var wydarzenie = DodajWydarzenie(); var uczestnikNaSlocie2 = DodajUczestnika(wydarzenie.Id, "Slot2"); await _serwis.PrzypiszSlotAsync(2, uczestnikNaSlocie2.Id); // Grid4 domyslnie var wynik = await _serwis.ZmienUkladAsync(UkladSceny.Grid6); // 1..6, slot 2 nadal miesci sie Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.NaScenie, uczestnikNaSlocie2.Status); Assert.Single(wynik.Dane!.Sloty); } // ---- ZwolnijSlotAsync ---- [Fact] public async Task ZwolnijSlotAsync_UczestnikWracaDoOczekiwania() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); var wynik = await _serwis.ZwolnijSlotAsync(1); Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.Oczekuje, uczestnik.Status); Assert.Empty(wynik.Dane!.Sloty); } [Fact] public async Task ZwolnijSlotAsync_PustySlot_JestIdempotentny() { DodajWydarzenie(); var wynik = await _serwis.ZwolnijSlotAsync(3); Assert.True(wynik.Sukces); Assert.Empty(_serwisAdministracjiLiveKit.WywolaniaOdbierzUprawnieniaSceny); } /// /// SEDNO ETAPU 7: oprocz "miekkiego" usuniecia w bazie (test wyzej), musi tez zajsc TWARDE wymuszenie /// w LiveKit -- inaczej klient, ktory zignoruje/nie dostanie zdarzenia SignalR, moglby dalej publikowac /// kamere/mikrofon mimo ze host go zdjal ze sceny (dokladnie ta luka, ktora ETAP 7 domyka). /// [Fact] public async Task ZwolnijSlotAsync_ZajetySlot_TwardoOdbieraUprawnieniaWLiveKit() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id, "Bartek"); await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); var wynik = await _serwis.ZwolnijSlotAsync(1); Assert.True(wynik.Sukces); var wywolanie = Assert.Single(_serwisAdministracjiLiveKit.WywolaniaOdbierzUprawnieniaSceny); Assert.Equal(uczestnik.LiveKitIdentity, wywolanie.Identity); Assert.Equal(PokojGlowny, wywolanie.Pokoj); } // ---- Sekret sesji: status uczestnika ---- [Fact] public async Task PobierzStatusUczestnikaAsync_PoprawnySekret_ZwracaStatus() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); var sekret = OdtworzSekretDlaOstatniegoUczestnika(uczestnik); var wynik = await _serwis.PobierzStatusUczestnikaAsync(uczestnik.Id, sekret); Assert.True(wynik.Sukces); Assert.Equal(StatusUczestnikaOczekujacego.Oczekuje, wynik.Dane!.Status); } [Fact] public async Task PobierzStatusUczestnikaAsync_ZlySekret_ZwracaNiepowodzenie() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); var wynik = await _serwis.PobierzStatusUczestnikaAsync(uczestnik.Id, "zly-sekret-atakujacego"); Assert.False(wynik.Sukces); Assert.Null(wynik.Dane); } [Fact] public async Task PobierzStatusUczestnikaAsync_NieznanyUczestnik_ZwracaNiepowodzenieNieNRE() { var wynik = await _serwis.PobierzStatusUczestnikaAsync(Guid.NewGuid(), "cokolwiek"); Assert.False(wynik.Sukces); } // Regresja ETAP 5: to jest jedyny bezpieczny kanal, ktorym uczestnik dowiaduje sie "to ja mam // teraz mikrofon" -- bez tego audio nigdy nie zaczyna sie publikowac po stronie klienta (byl to // krytyczny brak wykryty w audycie ETAPU 5). [Fact] public async Task PobierzStatusUczestnikaAsync_TenUczestnikMaMikrofon_ZwracaMikrofonAktywnyZKoncem() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); var sekret = OdtworzSekretDlaOstatniegoUczestnika(uczestnik); wydarzenie.PrzyznajMikrofon(uczestnik.Id, 5); var wynik = await _serwis.PobierzStatusUczestnikaAsync(uczestnik.Id, sekret); Assert.True(wynik.Sukces); Assert.True(wynik.Dane!.MikrofonAktywny); Assert.Equal(wydarzenie.AktywnyMikrofonKoniecUtc, wynik.Dane.MikrofonKoniecUtc); } [Fact] public async Task PobierzStatusUczestnikaAsync_MikrofonMaKtosInny_ZwracaMikrofonNieaktywny() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id, "Ja"); var innyUczestnik = DodajUczestnika(wydarzenie.Id, "Ktos Inny"); var sekret = OdtworzSekretDlaOstatniegoUczestnika(uczestnik); wydarzenie.PrzyznajMikrofon(innyUczestnik.Id, 5); var wynik = await _serwis.PobierzStatusUczestnikaAsync(uczestnik.Id, sekret); Assert.True(wynik.Sukces); Assert.False(wynik.Dane!.MikrofonAktywny); Assert.Null(wynik.Dane.MikrofonKoniecUtc); } // ---- Sekret sesji: token sceny ---- [Fact] public async Task WydajTokenScenyAsync_ZlySekret_OdmawiaTokenu() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); // NaScenie -- ale sekret bedzie zly var wynik = await _serwis.WydajTokenScenyAsync(new TokenScenyKomenda(uczestnik.Id, "zly-sekret")); Assert.False(wynik.Sukces); Assert.Null(wynik.Dane); } [Fact] public async Task WydajTokenScenyAsync_StatusNieNaScenie_OdmawiaTokenu() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); var sekret = OdtworzSekretDlaOstatniegoUczestnika(uczestnik); // Uczestnik NIE zostal przypisany do zadnego slotu -- Status nadal Oczekuje. var wynik = await _serwis.WydajTokenScenyAsync(new TokenScenyKomenda(uczestnik.Id, sekret)); Assert.False(wynik.Sukces); Assert.Null(wynik.Dane); } [Fact] public async Task WydajTokenScenyAsync_PoprawnySekretIStatusNaScenie_WydajeTokenDoPokojuGlownego() { var wydarzenie = DodajWydarzenie(); var uczestnik = DodajUczestnika(wydarzenie.Id); var sekret = OdtworzSekretDlaOstatniegoUczestnika(uczestnik); await _serwis.PrzypiszSlotAsync(1, uczestnik.Id); var wynik = await _serwis.WydajTokenScenyAsync(new TokenScenyKomenda(uczestnik.Id, sekret)); Assert.True(wynik.Sukces); Assert.Equal("token-testowy", wynik.Dane!.Token); Assert.Equal(UrlLiveKit, wynik.Dane.Url); // KLUCZOWE: token idzie do pokoju GLOWNEGO, nie poczekalni -- to jest "przejscie" na scene. Assert.Equal(PokojGlowny, _serwisTokenowLiveKit.OstatniPokoj); Assert.Equal(uczestnik.LiveKitIdentity, _serwisTokenowLiveKit.OstatniaTozsamosc); } /// /// Testy powyzej nie maja dostepu do jawnego sekretu zwroconego z DolaczAsync (to zadanie /// SerwisPoczekalni, nie SerwisSceny) -- zamiast tego generujemy nowy sekret i podmieniamy hash /// na uczestniku tuz przed weryfikacja, zeby test znal jawna wartosc, ktora prowadzi do tego hasha. /// private string OdtworzSekretDlaOstatniegoUczestnika(UczestnikOczekujacy uczestnik) { var sekretJawny = _uslugaHaszowaniaSekretowSesji.WygenerujSekret(); uczestnik.UstawSekretSesji(_uslugaHaszowaniaSekretowSesji.Haszuj(uczestnik, sekretJawny)); return sekretJawny; } }