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;
}
}