Kamera i ekran dla konkretnego uczestnika z panelu prowadzenia
Deploy PROD / deploy (push) Successful in 26s

Karta "Na sali" dostala przelaczniki kamery i udostepniania ekranu przy
kazdym uczestniku (wylacznie LiveKit). Uprawnienia licza sie z trzech
niezaleznych praw (PublishRights: mikrofon, kamera, ekran) zamiast jednej
roli, w jednym miejscu dla tokenu i zmiany na zywo; serwer pokoju nadaje
i odbiera je bez ponownego wejscia na sale.

Kolumny camera_allowed i screen_allowed (V32), domyslnie false. Zgoda
indywidualna przezywa wylaczenie "wideo dla wszystkich". Uczestnik
dostaje komunikat, a przy ekranie ostrzezenie, ze sala zobaczy wszystko.

PublishRightsTest i PublishRightsTokenTest pilnuja, ze zgoda na jedno
zrodlo nie otwiera innego; oba sprawdzone w obie strony.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:59:00 +02:00
parent dda54f00a1
commit 589c317841
17 changed files with 603 additions and 100 deletions
@@ -0,0 +1,107 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.Registration;
import com.softspm.webinar.domain.RegistrationRoomRole;
import com.softspm.webinar.domain.Webinar;
import livekit.LivekitModels.TrackSource;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Indywidualna zgoda prowadzacego na kamere i ekran.
*
* Najwazniejsze sa dwie granice: zgoda na jedno zrodlo nie moze otwierac
* innego (sam ekran to nie kamera, sama kamera to nie mikrofon), a uczestnik
* z zapisu nigdy nie dostaje uprawnien prelegenta.
*/
class PublishRightsTest {
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null);
private static Webinar webinar(boolean openMic, boolean openVideo) {
Webinar webinar = new Webinar();
webinar.setOpenMic(openMic);
webinar.setOpenVideo(openVideo);
return webinar;
}
private static Registration registration(RegistrationRoomRole role, boolean camera, boolean screen) {
Registration registration = new Registration();
registration.setRoomRole(role);
registration.setCameraAllowed(camera);
registration.setScreenAllowed(screen);
return registration;
}
private Set<TrackSource> sources(PublishRights rights) {
return Set.copyOf(provider.permissionFor(rights).getCanPublishSourcesList());
}
@Test
void viewerWithoutGrantsPublishesNothing() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, false));
assertThat(rights).isEqualTo(PublishRights.NONE);
assertThat(provider.permissionFor(rights).getCanPublish()).isFalse();
}
@Test
void cameraGrantDoesNotOpenMicrophone() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
assertThat(sources(rights)).containsExactly(TrackSource.CAMERA);
}
@Test
void screenGrantDoesNotOpenCamera() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, true));
assertThat(sources(rights))
.containsExactlyInAnyOrder(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
@Test
void grantsAddUpWithVoice() {
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.SPEAKER, true, true));
assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.MICROPHONE, TrackSource.CAMERA,
TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
@Test
void individualCameraSurvivesTurningOffVideoForAll() {
// tryb "wideo dla wszystkich" wylaczony, ale prowadzacy dal kamere tej osobie
PublishRights rights = VideoService.participantRights(
webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false));
assertThat(rights.camera()).isTrue();
}
@Test
void videoForAllStillGivesNoScreen() {
PublishRights rights = VideoService.participantRights(
webinar(false, true), registration(RegistrationRoomRole.VIEWER, false, false));
assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE);
}
@Test
void participantNeverGetsPresenterRole() {
for (boolean audio : new boolean[] {false, true}) {
for (boolean camera : new boolean[] {false, true}) {
for (boolean screen : new boolean[] {false, true}) {
assertThat(new PublishRights(audio, camera, screen).closestRole())
.isNotEqualTo(RoomRole.PRESENTER);
}
}
}
}
@Test
void presenterKeepsEverything() {
assertThat(sources(PublishRights.of(RoomRole.PRESENTER))).containsExactlyInAnyOrder(
TrackSource.MICROPHONE, TrackSource.CAMERA,
TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO);
}
}
@@ -0,0 +1,86 @@
package com.softspm.webinar.video;
import com.softspm.webinar.domain.Webinar;
import com.softspm.webinar.settings.SettingKey;
import com.softspm.webinar.settings.SettingsService;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Test;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Token LiveKit wydany dla indywidualnych zgod na kamere i ekran.
*
* Bez kontekstu Springa i bez bazy: ustawienia sa podstawione, a token
* rozkladany tak samo jak w `ProviderTokenTest`. Sprawdza to, co faktycznie
* trafia do serwera LiveKit, czyli liste zrodel w podpisanym tokenie, a nie
* tylko obiekt uprawnien po naszej stronie.
*/
class PublishRightsTokenTest {
private static final String SECRET = "sekret-testowy-livekit-co-najmniej-32-znaki";
private final LiveKitVideoProvider provider = new LiveKitVideoProvider(settings());
private static SettingsService settings() {
SettingsService settings = mock(SettingsService.class);
when(settings.get(SettingKey.LIVEKIT_URL)).thenReturn("wss://livekit.example.test");
when(settings.get(SettingKey.LIVEKIT_API_KEY)).thenReturn("klucz-testowy");
when(settings.get(SettingKey.LIVEKIT_API_SECRET)).thenReturn(SECRET);
return settings;
}
@SuppressWarnings("unchecked")
private Map<String, Object> grants(PublishRights rights) {
Webinar webinar = new Webinar();
webinar.setId(4242L);
String jwt = provider.createJoinTicket(webinar, new VideoProvider.JoinRequest(
"Jan Testowy", "reg-1", rights.closestRole(), rights)).token();
var claims = Jwts.parser()
.verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"))
.build()
.parseSignedClaims(jwt)
.getPayload();
return (Map<String, Object>) claims.get("video");
}
@SuppressWarnings("unchecked")
private static List<String> sources(Map<String, Object> video) {
Object raw = video.get("canPublishSources");
return raw == null ? List.of() : (List<String>) raw;
}
@Test
void screenOnlyTokenHasNoCameraAndNoMicrophone() {
var video = grants(new PublishRights(false, false, true));
assertThat(video.get("canPublish")).isEqualTo(true);
assertThat(sources(video)).containsExactlyInAnyOrder("screen_share", "screen_share_audio");
}
@Test
void cameraOnlyTokenHasNoMicrophone() {
var video = grants(new PublishRights(false, true, false));
assertThat(sources(video)).containsExactly("camera");
}
@Test
void noRightsMeansNoPublishing() {
var video = grants(PublishRights.NONE);
assertThat(video.get("canPublish")).isEqualTo(false);
assertThat(sources(video)).isEmpty();
}
@Test
void participantTokenNeverGetsRoomAdmin() {
var video = grants(new PublishRights(true, true, true));
assertThat(video.get("roomAdmin")).isNotEqualTo(true);
assertThat(video.get("roomRecord")).isNotEqualTo(true);
}
}