diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java index affdd7c..034dd55 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/LoginFormController.java @@ -18,6 +18,16 @@ public class LoginFormController extends Controller { @FXML Label errorLabel; @FXML Button submitButton; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + + @Override + protected boolean interceptAuthFailure() { + return false; + } + @FXML void onSubmit(ActionEvent event) { String email = emailField.getText() == null ? "" : emailField.getText().trim(); @@ -42,19 +52,7 @@ void onSubmit(ActionEvent event) { hideError(); navigator.goTo("/fxml/ResourceList.fxml"); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); } } - - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java index 36b30ca..7805d8f 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceDetailsController.java @@ -48,6 +48,11 @@ public class ResourceDetailsController extends Controller { @FXML Button editButton; @FXML Button deleteButton; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + private UUID resourceId; /** @@ -101,7 +106,7 @@ public void load() { try { showResource(services.getResourceService().getById(resourceId)); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); } } @@ -120,7 +125,7 @@ void onDownload(FileInfo file) { Path saved = services.getFileService().downloadFile(resourceId, file.getUuid(), dir.toPath()); statusLabel.setText("Сохранено: " + saved.getFileName()); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); } } @@ -169,18 +174,6 @@ private static String formatSize(Long bytes) { return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024)); } - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } - private static final class DownloadCell extends TableCell { private final Button button = new Button("Скачать"); @@ -210,7 +203,7 @@ void onDelete(ActionEvent event) { try { services.getResourceService().delete(resourceId); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); return; } navigator.goTo("/fxml/ResourceList.fxml"); diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java index ca1866a..566a712 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceFormController.java @@ -23,6 +23,11 @@ public class ResourceFormController extends Controller { @FXML Button cancelButton; @FXML Button saveButton; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + private UUID resourceId; /** @@ -55,7 +60,7 @@ private void loadExisting() { titleField.setText(r.getTitle() == null ? "" : r.getTitle()); descriptionArea.setText(r.getDescription() == null ? "" : r.getDescription()); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); } } @@ -90,7 +95,7 @@ void onSave(ActionEvent event) { } navigator.goTo("/fxml/ResourceList.fxml"); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); } } @@ -98,16 +103,4 @@ void onSave(ActionEvent event) { void onCancel(ActionEvent event) { navigator.goTo("/fxml/ResourceList.fxml"); } - - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java index 88dd70d..3d36092 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/ResourceListController.java @@ -2,7 +2,6 @@ import com.DesktopApplicationClientJava.controllers.utils.Controller; import com.DesktopApplicationClientJava.entities.Resource; -import com.DesktopApplicationClientJava.services.ServiceException; import com.DesktopApplicationClientJava.services.filter.ResourceFilter; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @@ -53,6 +52,11 @@ public class ResourceListController extends Controller { private long offset = 0; private boolean canModify = false; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + // ===== Lifecycle ===== @FXML @@ -184,9 +188,7 @@ protected List call() { task.setOnFailed( e -> { var ex = task.getException(); - String msg = - ex instanceof ServiceException se ? se.getMessage() : "Не удалось загрузить список"; - showError(msg); + showError(ex, "Не удалось загрузить список"); rows.clear(); updatePaginationUi(0); statusLabel.setText(""); @@ -266,9 +268,7 @@ protected Void call() { task.setOnFailed( e -> { var ex = task.getException(); - String msg = - ex instanceof ServiceException se ? se.getMessage() : "Не удалось удалить ресурс"; - showError(msg); + showError(ex, "Не удалось удалить ресурс"); statusLabel.setText(""); }); @@ -292,8 +292,16 @@ protected boolean confirmDelete(Resource resource) { @FXML private void onLogout(ActionEvent e) { - services.getAuthService().logout(); - navigator.goTo("/fxml/LoginForm.fxml"); + try { + services.getAuthService().logout(); + } catch (RuntimeException ignored) { + // Выход съедает ошибку сервиса без мелькания: редирект ниже выполняется в любом случае. + } finally { + if (session != null) { + session.clear(); + } + navigator.goToLoginOnce(); + } } @FXML @@ -331,16 +339,4 @@ private void openDetails(Resource resource) { private static String formatDate(LocalDateTime value) { return value == null ? "" : DATE_TIME_FORMAT.format(value); } - - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/UserCreateFormController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/UserCreateFormController.java index 8e87ab2..45056d7 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/UserCreateFormController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/UserCreateFormController.java @@ -21,6 +21,11 @@ public class UserCreateFormController extends Controller { @FXML private TextField firstNameField; @FXML private TextField lastNameField; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + @FXML public void initialize() { // Способ 1: через setItems @@ -72,7 +77,7 @@ private void onSubmit(ActionEvent event) { hideError(); navigator.goTo("/fxml/UserList.fxml"); } catch (ServiceException e) { - showError(e.getMessage()); + showError(e); } } @@ -80,16 +85,4 @@ private void onSubmit(ActionEvent event) { void onCancel(ActionEvent event) { navigator.goTo("/fxml/UserList.fxml"); } - - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/UserDetailsController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/UserDetailsController.java index bb3fa23..8c7fff1 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/UserDetailsController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/UserDetailsController.java @@ -2,7 +2,6 @@ import com.DesktopApplicationClientJava.controllers.utils.Controller; import com.DesktopApplicationClientJava.entities.User; -import com.DesktopApplicationClientJava.services.ServiceException; import java.util.UUID; import javafx.concurrent.Task; import javafx.event.ActionEvent; @@ -29,6 +28,11 @@ public class UserDetailsController extends Controller { private UUID userId; private User current; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + // ===== Lifecycle ===== @FXML @@ -85,11 +89,7 @@ protected User call() { e -> { statusLabel.setText(""); var ex = task.getException(); - String msg = - ex instanceof ServiceException se - ? se.getMessage() - : "Не удалось загрузить пользователя"; - showError(msg); + showError(ex, "Не удалось загрузить пользователя"); }); startDaemon(task, "user-details-load"); @@ -179,11 +179,7 @@ protected Void call() { ev -> { setBusy(false); var ex = task.getException(); - String msg = - ex instanceof ServiceException se - ? se.getMessage() - : "Не удалось удалить пользователя"; - showError(msg); + showError(ex, "Не удалось удалить пользователя"); }); startDaemon(task, "user-delete"); @@ -228,18 +224,6 @@ private void setBusy(boolean busy) { statusLabel.setText(busy ? "Удаление…" : ""); } - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } - private static void startDaemon(Task task, String name) { var thread = new Thread(task, name); thread.setDaemon(true); diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormController.java index 41a1672..e224840 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormController.java @@ -3,7 +3,6 @@ import com.DesktopApplicationClientJava.controllers.utils.Controller; import com.DesktopApplicationClientJava.entities.Role; import com.DesktopApplicationClientJava.entities.User; -import com.DesktopApplicationClientJava.services.ServiceException; import com.DesktopApplicationClientJava.services.data.UpdateUserData; import java.util.UUID; import javafx.collections.FXCollections; @@ -31,6 +30,11 @@ public class UserUpdateFormController extends Controller { private UUID userId; private User current; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + // ===== Lifecycle ===== @FXML @@ -77,11 +81,7 @@ protected User call() { task.setOnFailed( e -> { var ex = task.getException(); - String msg = - ex instanceof ServiceException se - ? se.getMessage() - : "Не удалось загрузить пользователя"; - showError(msg); + showError(ex, "Не удалось загрузить пользователя"); setFormDisabled(true); }); @@ -130,11 +130,7 @@ protected User call() { ev -> { setFormDisabled(false); var ex = task.getException(); - String msg = - ex instanceof ServiceException se - ? se.getMessage() - : "Не удалось сохранить изменения"; - showError(msg); + showError(ex, "Не удалось сохранить изменения"); }); startDaemon(task, "user-update-save"); @@ -195,18 +191,6 @@ private void setFormDisabled(boolean disabled) { saveButton.setDisable(disabled); } - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } - private static String safeTrim(String value) { return value == null ? null : value.trim(); } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/UsersListController.java b/src/main/java/com/DesktopApplicationClientJava/controllers/UsersListController.java index 965d43f..9b0c9db 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/UsersListController.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/UsersListController.java @@ -2,7 +2,6 @@ import com.DesktopApplicationClientJava.controllers.utils.Controller; import com.DesktopApplicationClientJava.entities.User; -import com.DesktopApplicationClientJava.services.ServiceException; import com.DesktopApplicationClientJava.services.filter.UserFilter; import java.util.ArrayList; import java.util.List; @@ -50,6 +49,11 @@ public class UsersListController extends Controller { private final ObservableList rows = FXCollections.observableArrayList(); private long offset = 0; + @Override + protected Label getErrorLabel() { + return errorLabel; + } + // ===== Lifecycle ===== @FXML @@ -191,9 +195,7 @@ protected List call() { task.setOnFailed( e -> { var ex = task.getException(); - String msg = - ex instanceof ServiceException se ? se.getMessage() : "Не удалось загрузить список"; - showError(msg); + showError(ex, "Не удалось загрузить список"); rows.clear(); updatePaginationUi(0, searching); statusLabel.setText(""); @@ -303,8 +305,16 @@ private void onCreate(ActionEvent e) { @FXML private void onLogout(ActionEvent e) { - services.getAuthService().logout(); - navigator.goTo("/fxml/LoginForm.fxml"); + try { + services.getAuthService().logout(); + } catch (RuntimeException ignored) { + // Выход съедает ошибку сервиса без мелькания: редирект ниже выполняется в любом случае. + } finally { + if (session != null) { + session.clear(); + } + navigator.goToLoginOnce(); + } } @FXML @@ -335,18 +345,4 @@ private void openDetails(User user) { navigator.goTo( "/fxml/UserDetails.fxml", c -> ((UserDetailsController) c).setUserId(user.getUuid())); } - - // ===== helpers ===== - - private void showError(String message) { - errorLabel.setText(message); - errorLabel.setVisible(true); - errorLabel.setManaged(true); - } - - private void hideError() { - errorLabel.setText(""); - errorLabel.setVisible(false); - errorLabel.setManaged(false); - } } diff --git a/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java b/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java index 2e25530..3e6e679 100644 --- a/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java +++ b/src/main/java/com/DesktopApplicationClientJava/controllers/utils/Controller.java @@ -1,11 +1,14 @@ package com.DesktopApplicationClientJava.controllers.utils; +import com.DesktopApplicationClientJava.Api.ApiException; import com.DesktopApplicationClientJava.navigation.Navigator; import com.DesktopApplicationClientJava.navigation.NavigatorAware; +import com.DesktopApplicationClientJava.services.ServiceException; import com.DesktopApplicationClientJava.services.ServiceRegistry; import com.DesktopApplicationClientJava.services.ServicesAware; import com.DesktopApplicationClientJava.session.Session; import com.DesktopApplicationClientJava.session.SessionAware; +import javafx.scene.control.Label; import lombok.Setter; @Setter @@ -14,4 +17,79 @@ public abstract class Controller implements SessionAware, NavigatorAware, Servic protected Session session; protected Navigator navigator; protected ServiceRegistry services; + + protected abstract Label getErrorLabel(); + + protected void showError(String message) { + Label errorLabel = getErrorLabel(); + if (errorLabel == null) { + return; + } + errorLabel.setText(message == null ? "" : message); + errorLabel.setVisible(true); + errorLabel.setManaged(true); + } + + protected void hideError() { + Label errorLabel = getErrorLabel(); + if (errorLabel == null) { + return; + } + errorLabel.setText(""); + errorLabel.setVisible(false); + errorLabel.setManaged(false); + } + + /** + * Хук перехвата 401/403. Форма входа перекрывает в {@code false}: неверный пароль остаётся на + * месте с текстом ошибки без выхода. + */ + protected boolean interceptAuthFailure() { + return true; + } + + protected void showError(Throwable ex) { + showError(ex, ex == null ? "Произошла ошибка" : ex.getMessage()); + } + + protected void showError(Throwable ex, String fallback) { + if (interceptAuthFailure() && isAuthFailure(ex)) { + if (session != null) { + session.clear(); + } + if (navigator != null) { + navigator.goToLoginOnce(); + } + return; + } + String message = extractMessage(ex, fallback); + showError(message); + } + + private static boolean isAuthFailure(Throwable ex) { + for (Throwable current = ex; current != null; current = current.getCause()) { + if (current instanceof ServiceException se + && (se.getStatus() == 401 || se.getStatus() == 403)) { + return true; + } + if (current instanceof ApiException ae && (ae.getStatus() == 401 || ae.getStatus() == 403)) { + return true; + } + } + return false; + } + + private static String extractMessage(Throwable ex, String fallback) { + for (Throwable current = ex; current != null; current = current.getCause()) { + if (current instanceof ServiceException se && se.getMessage() != null) { + return se.getMessage(); + } + if (current instanceof ApiException ae + && ae.getErrorResponse() != null + && ae.getErrorResponse().message() != null) { + return ae.getErrorResponse().message(); + } + } + return fallback == null ? "Произошла ошибка" : fallback; + } } diff --git a/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java b/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java index 876def6..ed03d95 100644 --- a/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java +++ b/src/main/java/com/DesktopApplicationClientJava/navigation/Navigator.java @@ -34,6 +34,7 @@ public class Navigator { private final Stage stage; private final Session session; private final ServiceRegistry services; + private boolean loginRedirectArmed; public Navigator(Stage stage, Session session, ServiceRegistry services) { this.stage = stage; @@ -45,7 +46,24 @@ public void goTo(String fxmlFile) { goTo(fxmlFile, controller -> {}); } + /** + * Однократный переход на форму входа для параллельных 401/403: первое обращение навигирует, + * повторные до ухода со входа — no-op. Флаг сбрасывается любым уходом на другую страницу. + */ + public synchronized void goToLoginOnce() { + if (loginRedirectArmed) { + return; + } + loginRedirectArmed = true; + goTo(LOGIN); + } + public void goTo(String fxmlFile, Consumer withController) { + if (!LOGIN.equals(fxmlFile)) { + synchronized (this) { + loginRedirectArmed = false; + } + } String redirect = guardRedirect(fxmlFile); if (redirect != null) { if (redirect.equals(fxmlFile)) { diff --git a/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java b/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java index a0fbfc8..a1e6471 100644 --- a/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java +++ b/src/test/java/com/DesktopApplicationClientJava/controllers/UserDetailsControllerTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -60,6 +61,7 @@ class UserDetailsControllerTest { private UsersService usersService; private ServiceRegistry services; private Navigator navigator; + private Session session; private TestableUserDetailsController controller; private Parent root; @@ -96,6 +98,10 @@ void setUp() { when(services.getAuthService()).thenReturn(authService); when(services.getUsersService()).thenReturn(usersService); navigator = mock(Navigator.class); + session = new Session(); + session.setCurrentUser(user()); + session.setAccessToken("access"); + session.setRefreshToken("refresh"); when(authService.isAdmin()).thenReturn(true); when(authService.getCurrentUser()).thenReturn(null); } @@ -207,16 +213,16 @@ void nullUserShowsNotFound() { @Test @Tag("role:admin") - @DisplayName("ServiceException показывает её текст") - void serviceExceptionShowsItsMessage() { + @DisplayName("ServiceException 403 ведёт на вход с очищенной сессией") + void serviceExceptionForbiddenRedirectsToLogin() { when(usersService.getById(USER_ID)).thenThrow(new ServiceException("Нет доступа", 403)); loadPage(); fxSetUserId(USER_ID); - awaitLoaded(); + awaitLoginRedirect(); - assertTrue(label("#errorLabel").isVisible()); - assertEquals("Нет доступа", label("#errorLabel").getText()); - verifyNoInteractions(navigator); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); + assertFalse(label("#errorLabel").isVisible()); } @Test @@ -380,17 +386,17 @@ void confirmedDeleteCallsServiceAndNavigatesToList() { @Test @Tag("role:admin") - @DisplayName("Ошибка 403 показывает текст без перехода") - void deleteForbiddenShowsMessageWithoutNavigation() { + @DisplayName("Ошибка 403 ведёт на вход с очищенной сессией") + void deleteForbiddenRedirectsToLogin() { loadUser(user()); controller.confirmDeleteResult = true; doThrow(new ServiceException("Доступ запрещён", 403)).when(usersService).delete(USER_ID); fire("#deleteButton"); - awaitUnbusy(); + awaitLoginRedirect(); - assertTrue(label("#errorLabel").isVisible()); - assertEquals("Доступ запрещён", label("#errorLabel").getText()); - verify(navigator, never()).goTo(anyString()); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); + assertFalse(label("#errorLabel").isVisible()); } @Test @@ -420,7 +426,7 @@ private void loadPage() { () -> { try { controller = new TestableUserDetailsController(); - ControllerWiring.wire(controller, navigator, new Session(), services); + ControllerWiring.wire(controller, navigator, session, services); FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/UserDetails.fxml")); loader.setControllerFactory(type -> controller); root = loader.load(); @@ -472,6 +478,24 @@ private void awaitUnbusy() { } } + private void awaitLoginRedirect() { + try { + WaitForAsyncUtils.waitFor( + 30, + TimeUnit.SECONDS, + () -> { + try { + verify(navigator).goToLoginOnce(); + return true; + } catch (AssertionError e) { + return false; + } + }); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались перехода на вход", e); + } + } + private static void runOnFx(Runnable action) { try { WaitForAsyncUtils.asyncFx(action).get(30, TimeUnit.SECONDS); diff --git a/src/test/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormControllerTest.java b/src/test/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormControllerTest.java index 3e0ce99..2598f47 100644 --- a/src/test/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormControllerTest.java +++ b/src/test/java/com/DesktopApplicationClientJava/controllers/UserUpdateFormControllerTest.java @@ -65,6 +65,7 @@ class UserUpdateFormControllerTest { private UsersService usersService; private Navigator navigator; + private Session session; private UserUpdateFormController controller; private TextField emailField; @@ -96,7 +97,11 @@ void setUp() { when(services.getUsersService()).thenReturn(usersService); controller = new UserUpdateFormController(); - ControllerWiring.wire(controller, navigator, new Session(), services); + session = new Session(); + session.setCurrentUser(existingUser()); + session.setAccessToken("access"); + session.setRefreshToken("refresh"); + ControllerWiring.wire(controller, navigator, session, services); emailField = new TextField(); firstNameField = new TextField(); @@ -523,14 +528,17 @@ void loadNotFoundShowsErrorAndStaysDisabled() { } @Test - @DisplayName("Загрузка: 403 показывает текст ошибки без перехода") - void loadForbiddenShowsErrorWithoutNavigation() { + @DisplayName("Загрузка: 403 ведёт на вход с очищенной сессией") + void loadForbiddenRedirectsToLoginWithClearedSession() { when(usersService.getById(USER_ID)).thenThrow(new ServiceException("Доступ запрещён", 403)); controller.setUserId(USER_ID); - awaitError("Доступ запрещён"); - verify(navigator, never()).goTo(anyString()); - assertTrue(saveButton.isDisabled()); + awaitLoginRedirect(); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); + assertNull(session.getAccessToken()); + assertNull(session.getRefreshToken()); + assertFalse(errorLabel.isVisible()); } @Test @@ -571,14 +579,17 @@ void saveNotFoundShowsErrorAndReenables() { } @Test - @DisplayName("Сохранение: 403 показывает текст ошибки без перехода") - void saveForbiddenShowsErrorWithoutNavigation() { + @DisplayName("Сохранение: 403 ведёт на вход с очищенной сессией") + void saveForbiddenRedirectsToLoginWithClearedSession() { stubLoadedAndFailingUpdate(new ServiceException("Доступ запрещён", 403)); invokeHandler("onSave"); - awaitError("Доступ запрещён"); - verify(navigator, never()).goTo(anyString()); - assertFalse(saveButton.isDisabled()); + awaitLoginRedirect(); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); + assertNull(session.getAccessToken()); + assertNull(session.getRefreshToken()); + assertFalse(errorLabel.isVisible()); } @Test @@ -654,6 +665,19 @@ private void awaitNavigation() { "Не дождались перехода в список пользователей"); } + private void awaitLoginRedirect() { + await( + () -> { + try { + verify(navigator).goToLoginOnce(); + return true; + } catch (AssertionError e) { + return false; + } + }, + "Не дождались перехода на вход"); + } + private static void await(Callable condition, String message) { try { WaitForAsyncUtils.waitFor(10, TimeUnit.SECONDS, condition); diff --git a/src/test/java/com/DesktopApplicationClientJava/ui/AuthInterceptorTest.java b/src/test/java/com/DesktopApplicationClientJava/ui/AuthInterceptorTest.java new file mode 100644 index 0000000..2f92b77 --- /dev/null +++ b/src/test/java/com/DesktopApplicationClientJava/ui/AuthInterceptorTest.java @@ -0,0 +1,297 @@ +package com.DesktopApplicationClientJava.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +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.ServiceException; +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 io.qameta.allure.Story; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javafx.event.ActionEvent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +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.Test; +import org.mockito.Mockito; +import org.testfx.api.FxToolkit; +import org.testfx.util.WaitForAsyncUtils; + +@Epic("Безопасность") +@Feature("Перехват 401/403") +@DisplayName("Перехват 401/403 ведёт на вход с очищенной сессией") +class AuthInterceptorTest extends FxTestBase { + + private static final User ADMIN = + new User(UUID.randomUUID(), "admin@example.com", Role.ADMIN, "Имя", "Фамилия", true); + + private AuthService authService; + private ResourceService resourceService; + private UsersService usersService; + 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 = Mockito.mock(AuthService.class); + resourceService = Mockito.mock(ResourceService.class); + usersService = Mockito.mock(UsersService.class); + services = Mockito.mock(ServiceRegistry.class); + when(services.getAuthService()).thenReturn(authService); + when(services.getResourceService()).thenReturn(resourceService); + when(services.getUsersService()).thenReturn(usersService); + when(authService.getCurrentUser()).thenReturn(ADMIN); + when(authService.isAdmin()).thenReturn(true); + when(authService.isModerator()).thenReturn(false); + when(resourceService.search(any(), anyLong(), anyLong(), any(), any())).thenReturn(List.of()); + session = new Session(); + session.setCurrentUser(ADMIN); + session.setAccessToken("access"); + session.setRefreshToken("refresh"); + this.stage = stage; + navigator = new Navigator(stage, session, services); + robot.interact( + () -> { + stage.setScene(new Scene(new StackPane())); + stage.show(); + }); + } + + @Test + @Story("Синхронный путь") + @DisplayName("Синхронная 401 ведёт на вход с очищенной сессией") + void sync401RedirectsToLoginWithClearedSession() { + when(usersService.create(any())).thenThrow(new ServiceException("Сессия истекла", 401)); + robot.interact(() -> navigator.goTo("/fxml/UserCreateForm.fxml")); + waitForNode("#submitButton"); + robot.interact( + () -> { + robot + .lookup("#emailField") + .queryAs(javafx.scene.control.TextField.class) + .setText("new@example.com"); + robot + .lookup("#passwordField") + .queryAs(javafx.scene.control.PasswordField.class) + .setText("secret123"); + robot + .lookup("#firstNameField") + .queryAs(javafx.scene.control.TextField.class) + .setText("Имя"); + robot + .lookup("#lastNameField") + .queryAs(javafx.scene.control.TextField.class) + .setText("Фамилия"); + robot + .lookup("#rolesChoiceBox") + .queryAs(javafx.scene.control.ChoiceBox.class) + .setValue("USER"); + robot + .lookup("#submitButton") + .queryAs(Button.class) + .getOnAction() + .handle(new ActionEvent()); + }); + waitForLoginForm(); + + assertTrue(onLoginForm(), "401 должна вести на форму входа"); + assertNull(session.getCurrentUser(), "сессия должна быть очищена"); + assertNull(session.getAccessToken(), "токен должен быть очищен"); + assertNull(session.getRefreshToken(), "refresh-токен должен быть очищен"); + } + + @Test + @Story("Асинхронный путь") + @DisplayName("Асинхронная 403 ведёт на вход с очищенной сессией") + void async403RedirectsToLoginWithClearedSession() { + when(resourceService.search(any(), anyLong(), anyLong(), anyString(), anyString())) + .thenThrow(new ServiceException("Доступ запрещён", 403)); + robot.interact(() -> navigator.goTo("/fxml/ResourceList.fxml")); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "403 должна вести на форму входа"); + assertNull(session.getCurrentUser(), "сессия должна быть очищена"); + assertNull(session.getAccessToken(), "токен должен быть очищен"); + assertNull(session.getRefreshToken(), "refresh-токен должен быть очищен"); + } + + @Test + @Story("Форма входа") + @DisplayName("Неверный пароль остаётся на форме входа без выхода") + void wrongPasswordStaysOnLoginForm() { + robot.interact(session::clear); + robot.interact(() -> navigator.goTo("/fxml/LoginForm.fxml")); + waitForNode("#submitButton"); + when(authService.login(anyString(), anyString())) + .thenThrow(new ServiceException("Неверный логин или пароль", 401)); + + robot.interact( + () -> { + robot + .lookup("#emailField") + .queryAs(javafx.scene.control.TextField.class) + .setText("user@example.com"); + robot + .lookup("#passwordField") + .queryAs(javafx.scene.control.PasswordField.class) + .setText("wrong"); + robot + .lookup("#submitButton") + .queryAs(Button.class) + .getOnAction() + .handle(new ActionEvent()); + }); + waitForLoginError("Неверный логин или пароль"); + + assertTrue(onLoginForm(), "неверный пароль должен оставить на форме входа"); + assertFalse(present("#resourceTable"), "перехода на список ресурсов быть не должно"); + assertNull(session.getCurrentUser(), "сессия должна остаться пустой"); + } + + @Test + @Story("Выход") + @DisplayName("Выход съедает ошибку сервиса без мелькания и ведёт на вход") + void logoutSwallowsErrorWithoutFlicker() { + robot.interact(() -> navigator.goTo("/fxml/ResourceList.fxml")); + waitForNode("#resourceTable"); + doThrow(new ServiceException("Сессия уже истекла", 403)).when(authService).logout(); + + robot.interact( + () -> + robot + .lookup("#logoutButton") + .queryAs(Button.class) + .getOnAction() + .handle(new ActionEvent())); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "выход должен вести на форму входа"); + assertNull(session.getCurrentUser(), "сессия должна быть очищена"); + Label loginError = robot.lookup("#errorLabel").queryAs(Label.class); + assertFalse(loginError.isVisible(), "ошибка выхода не должна мелькать на форме входа"); + assertEquals("", loginError.getText(), "текст ошибки выхода должен быть пуст"); + } + + @Test + @Story("Параллельные отказы") + @DisplayName("Три параллельные 403 завершаются однократным входом без гонки") + void parallel403EndsOnLoginOnce() throws Exception { + when(resourceService.search(any(), anyLong(), anyLong(), anyString(), anyString())) + .thenThrow(new ServiceException("Доступ запрещён", 403)); + robot.interact( + () -> { + navigator.goToLoginOnce(); + navigator.goToLoginOnce(); + navigator.goToLoginOnce(); + }); + waitForNode("#submitButton"); + + assertTrue(onLoginForm(), "параллельные 403 должны завершиться на форме входа"); + } + + 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 void waitForLoginForm() { + try { + WaitForAsyncUtils.waitFor( + 10, + TimeUnit.SECONDS, + () -> { + try { + robot.lookup("#submitButton").query(); + return !present("#firstNameField") && present("#passwordField"); + } catch (Exception e) { + return false; + } + }); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались формы входа", e); + } + } + + private void waitForLoginError(String text) { + try { + WaitForAsyncUtils.waitFor( + 10, + TimeUnit.SECONDS, + () -> { + try { + Label error = robot.lookup("#errorLabel").queryAs(Label.class); + return error.isVisible() && text.equals(error.getText()); + } catch (Exception e) { + return false; + } + }); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались ошибки входа «" + text + "»", e); + } + } + + private boolean present(String query) { + try { + robot.lookup(query).query(); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean onLoginForm() { + return present("#submitButton") + && present("#passwordField") + && !present("#firstNameField") + && !present("#resourceTable") + && !present("#usersTable"); + } +} diff --git a/src/test/java/com/DesktopApplicationClientJava/ui/ResourceListControllerTest.java b/src/test/java/com/DesktopApplicationClientJava/ui/ResourceListControllerTest.java index 6a9dc6f..1a18cc9 100644 --- a/src/test/java/com/DesktopApplicationClientJava/ui/ResourceListControllerTest.java +++ b/src/test/java/com/DesktopApplicationClientJava/ui/ResourceListControllerTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -67,6 +68,7 @@ class ResourceListControllerTest extends FxTestBase { private ResourceService resourceService; private ServiceRegistry services; private Navigator navigator; + private Session session; private TestableListController controller; private Stage stage; @@ -104,6 +106,10 @@ void start(Stage stage) throws Exception { when(services.getAuthService()).thenReturn(authService); when(services.getResourceService()).thenReturn(resourceService); navigator = mock(Navigator.class); + session = new Session(); + session.setCurrentUser(ADMIN); + session.setAccessToken("access"); + session.setRefreshToken("refresh"); this.stage = stage; stubAdmin(); loadPage(); @@ -121,7 +127,7 @@ private void loadPage() { FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/ResourceList.fxml")); loader.setControllerFactory( clazz -> { - ControllerWiring.wire(controller, navigator, new Session(), services); + ControllerWiring.wire(controller, navigator, session, services); return controller; }); Parent root = loader.load(); @@ -299,7 +305,8 @@ void logoutCallsServiceAndNavigatesToLogin() { robot.clickOn("#logoutButton"); verify(authService).logout(); - verify(navigator).goTo("/fxml/LoginForm.fxml"); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); } @Test diff --git a/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java b/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java index e791e6a..8794546 100644 --- a/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java +++ b/src/test/java/com/DesktopApplicationClientJava/ui/UserDetailsControllerUiTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; @@ -58,6 +59,7 @@ class UserDetailsControllerUiTest extends FxTestBase { private UsersService usersService; private ServiceRegistry services; private Navigator navigator; + private Session session; private TestableUserDetailsController controller; private Stage stage; @@ -95,6 +97,10 @@ void start(Stage stage) throws Exception { when(services.getAuthService()).thenReturn(authService); when(services.getUsersService()).thenReturn(usersService); navigator = mock(Navigator.class); + session = new Session(); + session.setCurrentUser(user()); + session.setAccessToken("access"); + session.setRefreshToken("refresh"); this.stage = stage; when(authService.isAdmin()).thenReturn(true); when(authService.getCurrentUser()).thenReturn(null); @@ -109,7 +115,7 @@ private void loadPage() { FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/UserDetails.fxml")); loader.setControllerFactory( clazz -> { - ControllerWiring.wire(controller, navigator, new Session(), services); + ControllerWiring.wire(controller, navigator, session, services); return controller; }); Parent root = loader.load(); @@ -211,8 +217,8 @@ void confirmingModalDeletesAndNavigatesToList() { @Test @Tag("role:admin") - @DisplayName("Удаление без прав показывает ошибку без перехода") - void deleteForbiddenShowsErrorWithoutNavigation() { + @DisplayName("Удаление без прав ведёт на вход с очищенной сессией") + void deleteForbiddenRedirectsToLogin() { awaitLoaded(); doThrow(new ServiceException("Доступ запрещён", 403)).when(usersService).delete(USER_ID); robot.interact(() -> controller.stubConfirm = false); @@ -223,8 +229,10 @@ void deleteForbiddenShowsErrorWithoutNavigation() { waitForClicker(clicker); assertModalClosed(robot); - awaitError("Доступ запрещён"); - verify(navigator, never()).goTo(anyString()); + awaitLoginRedirect(); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); + assertFalse(robot.lookup("#errorLabel").queryAs(Label.class).isVisible()); } @Test @@ -284,6 +292,24 @@ private void awaitError(String expected) { } } + private void awaitLoginRedirect() { + try { + WaitForAsyncUtils.waitFor( + 30, + TimeUnit.SECONDS, + () -> { + try { + verify(navigator).goToLoginOnce(); + return true; + } catch (AssertionError e) { + return false; + } + }); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались перехода на вход", e); + } + } + private static String titleOf(DialogPane dialog) { return ((Stage) dialog.getScene().getWindow()).getTitle(); } diff --git a/src/test/java/com/DesktopApplicationClientJava/ui/UsersListControllerTest.java b/src/test/java/com/DesktopApplicationClientJava/ui/UsersListControllerTest.java index 558132e..f15273a 100644 --- a/src/test/java/com/DesktopApplicationClientJava/ui/UsersListControllerTest.java +++ b/src/test/java/com/DesktopApplicationClientJava/ui/UsersListControllerTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; @@ -58,6 +59,7 @@ class UsersListControllerTest extends FxTestBase { private UsersService usersService; private ServiceRegistry services; private Navigator navigator; + private Session session; private Stage stage; @BeforeEach @@ -79,6 +81,10 @@ void start(Stage stage) throws Exception { when(services.getAuthService()).thenReturn(authService); when(services.getUsersService()).thenReturn(usersService); navigator = mock(Navigator.class); + session = new Session(); + session.setCurrentUser(ADMIN); + session.setAccessToken("access"); + session.setRefreshToken("refresh"); this.stage = stage; stubAdmin(); loadPage(); @@ -95,7 +101,7 @@ private void loadPage() { FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/UserList.fxml")); loader.setControllerFactory( clazz -> { - ControllerWiring.wire(controller, navigator, new Session(), services); + ControllerWiring.wire(controller, navigator, session, services); return controller; }); Parent root = loader.load(); @@ -479,7 +485,8 @@ void logoutCallsServiceAndNavigatesToLogin() { fireButton("#logoutButton"); verify(authService).logout(); - verify(navigator).goTo("/fxml/LoginForm.fxml"); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); } @Test @@ -521,16 +528,18 @@ void runtimeExceptionShowsGenericMessage() { } @Test - @DisplayName("Ошибка 403 прокидывает текст сервера") - void forbiddenErrorPassesThroughText() { + @DisplayName("Ошибка 403 ведёт на вход с очищенной сессией") + void forbiddenErrorRedirectsToLogin() { when(usersService.search(any(), eq(0L), eq(20L))) .thenThrow(new ServiceException("Доступ запрещён", 403)); fireSearch(""); - waitForLoadComplete(); + awaitLoginRedirect(); - assertEquals("Доступ запрещён", textOf("#errorLabel")); - assertTrue(rows().isEmpty()); + verify(navigator).goToLoginOnce(); + assertNull(session.getCurrentUser()); + assertNull(session.getAccessToken()); + assertFalse(robot.lookup("#errorLabel").queryAs(Label.class).isVisible()); } @Test @@ -642,6 +651,24 @@ private void waitForLoadComplete() { } } + private void awaitLoginRedirect() { + try { + WaitForAsyncUtils.waitFor( + 30, + TimeUnit.SECONDS, + () -> { + try { + verify(navigator).goToLoginOnce(); + return true; + } catch (AssertionError e) { + return false; + } + }); + } catch (TimeoutException e) { + throw new AssertionError("Не дождались перехода на вход", e); + } + } + @SuppressWarnings("unchecked") private List rows() { return robot.lookup("#usersTable").queryAs(TableView.class).getItems();