From 7fd062dc9fdafc2b8245c436b3747c0b058daef8 Mon Sep 17 00:00:00 2001 From: Vladimir Valekzhanin Date: Fri, 16 Oct 2026 19:58:41 +0300 Subject: [PATCH] =?UTF-8?q?fix(=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F):=20=D0=B3=D0=B0=D1=80=D0=B4=20=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B2=20Navigator.goTo=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Прямой navigator.goTo админских страниц под USER/MODER/гостем больше не открывает их: страницы пользователей требуют ADMIN, форма ресурса — ADMIN или MODER. При mismatch гость уходит на форму входа, залогиненный без роли — на список ресурсов. Fail-closed при null сессии/AuthService. --- .../navigation/Navigator.java | 75 +++++ .../ui/NavigatorGuardTest.java | 298 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 src/test/java/com/DesktopApplicationClientJava/ui/NavigatorGuardTest.java diff --git a/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java b/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java index 87d0a59..876def6 100644 --- a/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java +++ b/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java @@ -1,17 +1,36 @@ package com.DesktopApplicationClientJava.navigation; +import com.DesktopApplicationClientJava.services.AuthService; import com.DesktopApplicationClientJava.services.ServiceRegistry; import com.DesktopApplicationClientJava.session.Session; import com.DesktopApplicationClientJava.session.SessionAware; import com.DesktopApplicationClientJava.utils.ControllerWiring; import java.io.IOException; +import java.util.Set; import java.util.function.Consumer; +import java.util.logging.Logger; import javafx.fxml.FXMLLoader; import javafx.scene.Parent; import javafx.stage.Stage; /** Navigator */ public class Navigator { + private static final Logger LOG = Logger.getLogger(Navigator.class.getName()); + + private static final String LOGIN = "/fxml/LoginForm.fxml"; + private static final String FALLBACK = "/fxml/ResourceList.fxml"; + + /** Страницы пользователей доступны только администраторам. */ + private static final Set ADMIN_ONLY = + Set.of( + "/fxml/UserList.fxml", + "/fxml/UserDetails.fxml", + "/fxml/UserCreateForm.fxml", + "/fxml/UserUpdateForm.fxml"); + + /** Форма ресурса доступна администраторам и модераторам. */ + private static final Set MODER_PLUS = Set.of("/fxml/ResourceForm.fxml"); + private final Stage stage; private final Session session; private final ServiceRegistry services; @@ -27,6 +46,62 @@ public void goTo(String fxmlFile) { } public void goTo(String fxmlFile, Consumer withController) { + String redirect = guardRedirect(fxmlFile); + if (redirect != null) { + if (redirect.equals(fxmlFile)) { + load(fxmlFile, withController); + } else { + LOG.fine(() -> "Переход " + fxmlFile + " отклонён, редирект на " + redirect); + load(redirect, controller -> {}); + } + return; + } + load(fxmlFile, withController); + } + + /** + * Клиентский гард по роли. Возвращает null, если переход разрешён, иначе целевой FXML: гость (нет + * пользователя) — форма входа, залогиненный без роли — список ресурсов. Fail-closed: null + * сессии/AuthService — на вход. + */ + private String guardRedirect(String fxmlFile) { + boolean adminPage = ADMIN_ONLY.contains(fxmlFile); + boolean moderPage = MODER_PLUS.contains(fxmlFile); + if (!adminPage && !moderPage) { + return null; + } + AuthService auth = null; + try { + auth = services == null ? null : services.getAuthService(); + } catch (RuntimeException e) { + LOG.fine(() -> "Гард: AuthService недоступен, редирект на вход"); + return LOGIN; + } + if (session == null || auth == null) { + return LOGIN; + } + Object current; + try { + current = auth.getCurrentUser(); + } catch (RuntimeException e) { + return LOGIN; + } + if (current == null) { + return LOGIN; + } + boolean allowed; + try { + allowed = adminPage ? auth.isAdmin() : (auth.isAdmin() || auth.isModerator()); + } catch (RuntimeException e) { + return LOGIN; + } + if (allowed) { + return null; + } + return FALLBACK; + } + + private void load(String fxmlFile, Consumer withController) { var url = Navigator.class.getResource(fxmlFile); if (url == null) { throw new IllegalArgumentException("FXML not found on classpath: " + fxmlFile); diff --git a/src/test/java/com/DesktopApplicationClientJava/ui/NavigatorGuardTest.java b/src/test/java/com/DesktopApplicationClientJava/ui/NavigatorGuardTest.java new file mode 100644 index 0000000..dff72fc --- /dev/null +++ b/src/test/java/com/DesktopApplicationClientJava/ui/NavigatorGuardTest.java @@ -0,0 +1,298 @@ +package com.DesktopApplicationClientJava.ui; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.DesktopApplicationClientJava.entities.Role; +import com.DesktopApplicationClientJava.entities.User; +import com.DesktopApplicationClientJava.navigation.Navigator; +import com.DesktopApplicationClientJava.services.AuthService; +import com.DesktopApplicationClientJava.services.ResourceService; +import com.DesktopApplicationClientJava.services.ServiceRegistry; +import com.DesktopApplicationClientJava.services.UsersService; +import com.DesktopApplicationClientJava.session.Session; +import io.qameta.allure.Epic; +import io.qameta.allure.Feature; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javafx.scene.Scene; +import javafx.scene.layout.StackPane; +import javafx.stage.Stage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.testfx.api.FxToolkit; +import org.testfx.util.WaitForAsyncUtils; + +@Epic("Безопасность") +@Feature("Гард навигации") +@DisplayName("Гард Navigator.goTo по ролям") +class NavigatorGuardTest extends FxTestBase { + + private static final User ADMIN = + new User(UUID.randomUUID(), "admin@example.com", Role.ADMIN, "Имя", "Фамилия", true); + private static final User MODERATOR = + new User(UUID.randomUUID(), "moder@example.com", Role.MODER, "Имя", "Фамилия", true); + private static final User PLAIN_USER = + new User(UUID.randomUUID(), "user@example.com", Role.USER, "Имя", "Фамилия", true); + + private AuthService authService; + private ServiceRegistry services; + private Session session; + private Navigator navigator; + private Stage stage; + + @BeforeEach + void resetScene() throws Exception { + FxToolkit.setupStage( + stage -> { + try { + start(stage); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + void start(Stage stage) { + authService = mock(AuthService.class); + UsersService usersService = mock(UsersService.class); + ResourceService resourceService = mock(ResourceService.class); + services = mock(ServiceRegistry.class); + when(services.getAuthService()).thenReturn(authService); + when(services.getUsersService()).thenReturn(usersService); + when(services.getResourceService()).thenReturn(resourceService); + when(usersService.search( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong())) + .thenReturn(List.of()); + when(resourceService.search( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) + .thenReturn(List.of()); + session = new Session(); + this.stage = stage; + navigator = new Navigator(stage, session, services); + stubGuest(); + robot.interact( + () -> { + stage.setScene(new Scene(new StackPane())); + stage.show(); + }); + } + + private void stubRole(User user, boolean admin, boolean moderator) { + when(authService.getCurrentUser()).thenReturn(user); + when(authService.isAdmin()).thenReturn(admin); + when(authService.isModerator()).thenReturn(moderator); + session.setCurrentUser(user); + } + + private void stubGuest() { + stubRole(null, false, false); + } + + private void goTo(String fxml) { + robot.interact(() -> navigator.goTo(fxml)); + } + + private void waitForNode(String query) { + try { + WaitForAsyncUtils.waitFor( + 10, + TimeUnit.SECONDS, + () -> { + try { + robot.lookup(query).query(); + return true; + } catch (Exception e) { + return false; + } + }); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались узла " + query, e); + } + } + + private boolean present(String query) { + try { + robot.lookup(query).query(); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean onResourceList() { + return present("#resourceTable"); + } + + private boolean onUserList() { + return present("#usersTable"); + } + + private boolean onLoginForm() { + return present("#submitButton") + && present("#passwordField") + && !present("#firstNameField") + && !present("#resourceTable") + && !present("#usersTable"); + } + + private boolean onResourceForm() { + return present("#titleField") && present("#formTitleLabel"); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь прямым переходом не открывает список пользователей") + void userCannotOpenUserListDirectly() { + robot.interact(() -> stubRole(PLAIN_USER, false, false)); + goTo("/fxml/UserList.fxml"); + waitForNode("#resourceTable"); + + assertTrue(onResourceList(), "ожидался редирект на список ресурсов"); + assertFalse(onUserList(), "список пользователей должен быть отклонён"); + } + + @Test + @Tag("role:moder") + @DisplayName("Модер прямым переходом не открывает список пользователей") + void moderatorCannotOpenUserListDirectly() { + robot.interact(() -> stubRole(MODERATOR, false, true)); + goTo("/fxml/UserList.fxml"); + waitForNode("#resourceTable"); + + assertTrue(onResourceList(), "ожидался редирект на список ресурсов"); + assertFalse(onUserList(), "список пользователей должен быть отклонён"); + } + + @Test + @Tag("role:guest") + @DisplayName("Гость прямым переходом на список пользователей уходит на вход") + void guestUserListRedirectsToLogin() { + robot.interact(this::stubGuest); + goTo("/fxml/UserList.fxml"); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "гость должен оказаться на форме входа"); + assertFalse(onUserList(), "список пользователей должен быть отклонён"); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь прямым переходом не открывает детали пользователя") + void userCannotOpenUserDetailsDirectly() { + robot.interact(() -> stubRole(PLAIN_USER, false, false)); + goTo("/fxml/UserDetails.fxml"); + waitForNode("#resourceTable"); + + assertTrue(onResourceList(), "ожидался редирект на список ресурсов"); + assertFalse(present("#roleLabel"), "детали пользователя должны быть отклонены"); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь прямым переходом не открывает форму создания пользователя") + void userCannotOpenUserCreateFormDirectly() { + robot.interact(() -> stubRole(PLAIN_USER, false, false)); + goTo("/fxml/UserCreateForm.fxml"); + waitForNode("#resourceTable"); + + assertTrue(onResourceList(), "ожидался редирект на список ресурсов"); + assertFalse(present("#rolesChoiceBox"), "форма создания должна быть отклонена"); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь прямым переходом не открывает форму обновления пользователя") + void userCannotOpenUserUpdateFormDirectly() { + robot.interact(() -> stubRole(PLAIN_USER, false, false)); + goTo("/fxml/UserUpdateForm.fxml"); + waitForNode("#resourceTable"); + + assertTrue(onResourceList(), "ожидался редирект на список ресурсов"); + assertFalse(present("#roleChoiceBox"), "форма обновления должна быть отклонена"); + } + + @Test + @Tag("role:user") + @DisplayName("Пользователь прямым переходом не открывает форму ресурса") + void userCannotOpenResourceFormDirectly() { + robot.interact(() -> stubRole(PLAIN_USER, false, false)); + goTo("/fxml/ResourceForm.fxml"); + waitForNode("#resourceTable"); + + assertTrue(onResourceList(), "ожидался редирект на список ресурсов"); + assertFalse(onResourceForm(), "форма ресурса должна быть отклонена"); + } + + @Test + @Tag("role:guest") + @DisplayName("Гость прямым переходом на форму ресурса уходит на вход") + void guestResourceFormRedirectsToLogin() { + robot.interact(this::stubGuest); + goTo("/fxml/ResourceForm.fxml"); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "гость должен оказаться на форме входа"); + assertFalse(onResourceForm(), "форма ресурса должна быть отклонена"); + } + + @Test + @Tag("role:moder") + @DisplayName("Модер открывает форму ресурса напрямую") + void moderatorCanOpenResourceFormDirectly() { + robot.interact(() -> stubRole(MODERATOR, false, true)); + goTo("/fxml/ResourceForm.fxml"); + waitForNode("#titleField"); + + assertTrue(onResourceForm(), "модер должен открыть форму ресурса"); + } + + @Test + @Tag("role:admin") + @DisplayName("Админ открывает список пользователей напрямую") + void adminCanOpenUserListDirectly() { + robot.interact(() -> stubRole(ADMIN, true, false)); + goTo("/fxml/UserList.fxml"); + waitForNode("#usersTable"); + + assertTrue(onUserList(), "админ должен открыть список пользователей"); + } + + @Test + @Tag("role:guest") + @DisplayName("Без AuthService прямой переход fail-closed уходит на вход") + void nullAuthServiceRedirectsToLogin() { + robot.interact(() -> when(services.getAuthService()).thenReturn(null)); + goTo("/fxml/UserList.fxml"); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "без AuthService должен быть редирект на вход"); + } + + @Test + @Tag("role:guest") + @DisplayName("Без сессии прямой переход fail-closed уходит на вход") + void nullSessionRedirectsToLogin() { + robot.interact( + () -> { + stubRole(PLAIN_USER, false, false); + navigator = new Navigator(stage, null, services); + }); + goTo("/fxml/UserList.fxml"); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "без сессии должен быть редирект на вход"); + } +}