diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..61121ea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A Spring Boot + Vaadin Flow project (Vaadin 25, Spring Boot 4, Java 25). UI is built entirely in server-side Java — no HTML/JS/TypeScript to write for views. Data layer is Spring Data JPA over H2 (file/in-memory, dev only). + +## Commands + +```bash +./mvnw spring-boot:run # run the app (dev), http://localhost:8080 +./mvnw test # run all tests +./mvnw test -Dtest=TaskServiceTest # single test class +./mvnw test -Dtest=TaskServiceTest#tasks_are_validated_before_they_are_stored # single test method +./mvnw package # production build -> target/*.jar +java -jar target/*.jar # run the built jar +``` + +No system Maven is required; always use the `./mvnw` wrapper. There is no separate lint command configured. + +Live hotswap (edit Java, see changes without restart) requires launching via the Vaadin IDE plugin (IntelliJ/VS Code/Eclipse) instead of `spring-boot:run` — not available from the CLI. + +Port defaults to 8080 (override via `server.port` in `src/main/resources/application.properties` or the `PORT` env var). + +## Architecture + +**Feature-package structure**: code is organized by feature/domain under `com.example.`, not by technical layer. Each feature package is self-contained: entity, repository, service, and a `ui` sub-package for its views. `com.example.examplefeature` is the template feature to copy/replace — its `package-info.java` has a `TODO Remove this package once you have added real features`. + +**Package visibility convention**: types are package-private by default (e.g. `TaskRepository`, `TaskService`, `TaskListView` are not `public`). Only export a type outside its feature package when another package actually needs it. Follow this pattern for new features. + +**Null-safety**: packages are annotated `@NullMarked` (JSpecify) in their `package-info.java`, so all types are non-null by default; use `@Nullable` explicitly where null is allowed (e.g. `Task.dueDate`). Apply `@NullMarked` to any new feature package. + +**Layout/routing**: `MainLayout` (`com.example.base.ui`, annotated `@Layout`) is the shared `AppLayout` shell (drawer nav + header/footer) applied automatically to routed views. Side-nav entries are auto-discovered via `@Menu` on `@Route` view classes (see `TaskListView`) — no manual registration needed. `ViewTitle` is a shared composite for the per-view title bar (drawer toggle + heading). + +**Data access pattern**: `Slice` (via `findAllBy(Pageable)`) is preferred over `Page` for grid data providers, since `Slice` avoids the extra `COUNT` query — see the comment in `TaskRepository`. Vaadin `Grid` is wired to Spring Data via `VaadinSpringDataHelpers.toSpringPageRequest(query)` in an in-memory/lazy `DataProvider` callback (see `TaskListView`). + +**Entities**: JPA entities use `@GeneratedValue(strategy = GenerationType.SEQUENCE)`, expose validation in setters (throwing `IllegalArgumentException`) rather than via bean-validation annotations, and implement `equals`/`hashCode` based on ID only (with a fixed `hashCode`, per the pattern in `Task`). + +**Schema management**: `spring.jpa.hibernate.ddl-auto=update` is used for local dev convenience only. This is explicitly not appropriate for production — the app is meant to move to Flyway (or similar) for real schema migrations before shipping. + +**Frontend theming**: `Application` sets the app-shell config (`@Push`, `Aura` theme via `@StyleSheet(Aura.STYLESHEET)`, plus custom `styles.css`/`view-title.css` under `src/main/resources/META-INF/resources/`). No separate frontend build step is needed for typical UI work since Flow generates the client bundle; the `vaadin-maven-plugin` `build-frontend` goal runs as part of the Maven build. + +## Testing + +Two distinct test styles are used: + +- **Service/integration tests** (`TaskServiceTest`): `@SpringBootTest(webEnvironment = MOCK)` + `@Transactional` (each test rolls back), asserting against the JPA layer directly. +- **UI/browserless tests** (`TaskListViewTest`): extend `SpringBrowserlessTest` (from `com.vaadin:browserless-test-spring`), which renders and interacts with actual Vaadin components server-side without a real browser. Use `navigate(ViewClass.class)`, `test(component)` for interactions/assertions, and `$(ComponentClass.class)` for component lookup queries. Views expose package-private fields (e.g. `taskGrid`, `description`, `createBtn`) specifically so tests in the same package can drive them directly. + +## MCP + +`.mcp.json` configures a Vaadin docs MCP server (`https://mcp.vaadin.com/docs`) and a Playwright MCP server — both available for querying live Vaadin component/API docs and browser automation if needed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/pom.xml b/pom.xml index e121fa1..f190ff8 100644 --- a/pom.xml +++ b/pom.xml @@ -34,8 +34,7 @@ com.vaadin - - vaadin + vaadin-core com.vaadin @@ -54,6 +53,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-security + com.h2database h2 @@ -63,6 +66,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + com.vaadin browserless-test-spring diff --git a/src/main/frontend/index.html b/src/main/frontend/index.html new file mode 100644 index 0000000..7e3cdd4 --- /dev/null +++ b/src/main/frontend/index.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + +
+ + diff --git a/src/main/java/com/example/base/ui/MainLayout.java b/src/main/java/com/example/base/ui/MainLayout.java index bdf6e8b..8870f44 100644 --- a/src/main/java/com/example/base/ui/MainLayout.java +++ b/src/main/java/com/example/base/ui/MainLayout.java @@ -5,6 +5,7 @@ import com.vaadin.flow.component.Unit; import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.avatar.AvatarVariant; +import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.html.Span; import com.vaadin.flow.component.icon.Icon; import com.vaadin.flow.component.icon.SvgIcon; @@ -14,11 +15,18 @@ import com.vaadin.flow.component.sidenav.SideNavItem; import com.vaadin.flow.router.Layout; import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuEntry; +import com.vaadin.flow.spring.security.AuthenticationContext; +import jakarta.annotation.security.PermitAll; @Layout +@PermitAll public final class MainLayout extends AppLayout { - MainLayout() { + // AuthenticationContext is not Serializable by design, so this field must stay transient. + private final transient AuthenticationContext authenticationContext; + + MainLayout(AuthenticationContext authenticationContext) { + this.authenticationContext = authenticationContext; setPrimarySection(Section.DRAWER); addToDrawer(createApplicationHeader(), createApplicationDrawer(), createApplicationFooter()); } @@ -45,12 +53,21 @@ public final class MainLayout extends AppLayout { } private Component createApplicationFooter() { - var footer = new VerticalLayout(new Span("Made with ❤️ with Vaadin")); + var footer = new VerticalLayout(createUserInfo(), new Span("Made with ❤️ with Vaadin")); footer.setAlignItems(FlexComponent.Alignment.CENTER); footer.addClassName("app-footer"); return footer; } + private Component createUserInfo() { + var username = authenticationContext.getPrincipalName().orElse(""); + var logout = new Button("Logout", event -> authenticationContext.logout()); + + var userInfo = new HorizontalLayout(new Span(username), logout); + userInfo.setAlignItems(FlexComponent.Alignment.CENTER); + return userInfo; + } + private SideNav createSideNav() { var nav = new SideNav(); nav.setMinWidth(200, Unit.PIXELS); diff --git a/src/main/java/com/example/examplefeature/ui/TaskListView.java b/src/main/java/com/example/examplefeature/ui/TaskListView.java index 451024e..8398685 100644 --- a/src/main/java/com/example/examplefeature/ui/TaskListView.java +++ b/src/main/java/com/example/examplefeature/ui/TaskListView.java @@ -15,6 +15,7 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.router.Menu; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; +import jakarta.annotation.security.PermitAll; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -26,6 +27,7 @@ import static com.vaadin.flow.spring.data.VaadinSpringDataHelpers.toSpringPageRe @Route(value = "") @PageTitle("Task List") @Menu(order = 0, icon = "icons/clipboard-check.svg", title = "Task List") +@PermitAll class TaskListView extends VerticalLayout { private final TaskService taskService; diff --git a/src/main/java/com/example/security/AppUserDetailsService.java b/src/main/java/com/example/security/AppUserDetailsService.java new file mode 100644 index 0000000..f169c3b --- /dev/null +++ b/src/main/java/com/example/security/AppUserDetailsService.java @@ -0,0 +1,40 @@ +package com.example.security; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +class AppUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + AppUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = userRepository.findByUsername(username); + if (user == null) { + throw new UsernameNotFoundException("No user found with username '" + username + "'"); + } + + var authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) + .toList(); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getUsername()) + .password(user.getPasswordHash()) + .disabled(!user.isEnabled()) + .authorities(List.copyOf(authorities)) + .build(); + } +} diff --git a/src/main/java/com/example/security/DemoUserSeeder.java b/src/main/java/com/example/security/DemoUserSeeder.java new file mode 100644 index 0000000..3004c17 --- /dev/null +++ b/src/main/java/com/example/security/DemoUserSeeder.java @@ -0,0 +1,42 @@ +package com.example.security; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Seeds demo accounts for local development. + *

+ * Not appropriate for production: the app has no persistent datasource configured + * (see application.properties), so H2 is in-memory and these accounts are recreated + * on every restart, and any accounts created at runtime are lost. + */ +@Component +class DemoUserSeeder implements ApplicationRunner { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + DemoUserSeeder(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (userRepository.count() > 0) { + return; + } + + var user = new User("user", passwordEncoder.encode("user")); + user.addRole(Role.USER); + userRepository.save(user); + + var admin = new User("admin", passwordEncoder.encode("admin")); + admin.addRole(Role.ADMIN); + userRepository.save(admin); + } +} diff --git a/src/main/java/com/example/security/Role.java b/src/main/java/com/example/security/Role.java new file mode 100644 index 0000000..4a94af2 --- /dev/null +++ b/src/main/java/com/example/security/Role.java @@ -0,0 +1,6 @@ +package com.example.security; + +enum Role { + USER, + ADMIN +} diff --git a/src/main/java/com/example/security/SecurityConfig.java b/src/main/java/com/example/security/SecurityConfig.java new file mode 100644 index 0000000..22b5ea5 --- /dev/null +++ b/src/main/java/com/example/security/SecurityConfig.java @@ -0,0 +1,33 @@ +package com.example.security; + +import com.example.security.ui.LoginView; +import com.vaadin.flow.spring.security.VaadinSecurityConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizedUrl; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.with(VaadinSecurityConfigurer.vaadin(), configurer -> { + // Vaadin 25 denies any request not otherwise matched by default, which + // blocks framework error views (e.g. RouteNotFoundError) at the HTTP layer + // before navigation runs. Routes themselves stay protected by the + // secured-route matchers and navigation access control below. + configurer.loginView(LoginView.class).anyRequest(AuthorizedUrl::permitAll); + }).build(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/security/User.java b/src/main/java/com/example/security/User.java new file mode 100644 index 0000000..81bbdf9 --- /dev/null +++ b/src/main/java/com/example/security/User.java @@ -0,0 +1,109 @@ +package com.example.security; + +import jakarta.persistence.*; +import org.jspecify.annotations.Nullable; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "app_user") +public class User { + + public static final int USERNAME_MAX_LENGTH = 100; + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + @Column(name = "user_id") + private Long id; + + @Column(name = "username", nullable = false, unique = true, length = USERNAME_MAX_LENGTH) + private String username = ""; + + @Column(name = "password_hash", nullable = false) + private String passwordHash = ""; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "app_user_role", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + private Set roles = new HashSet<>(); + + protected User() { // To keep Hibernate happy + } + + public User(String username, String passwordHash) { + setUsername(username); + setPasswordHash(passwordHash); + } + + public @Nullable Long getId() { + return id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + if (username.isBlank()) { + throw new IllegalArgumentException("Username must not be blank"); + } + if (username.length() > USERNAME_MAX_LENGTH) { + throw new IllegalArgumentException("Username length exceeds " + USERNAME_MAX_LENGTH); + } + this.username = username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + if (passwordHash.isBlank()) { + throw new IllegalArgumentException("Password hash must not be blank"); + } + this.passwordHash = passwordHash; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Set getRoles() { + return roles; + } + + public void addRole(Role role) { + roles.add(role); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || !getClass().isAssignableFrom(obj.getClass())) { + return false; + } + if (obj == this) { + return true; + } + + User other = (User) obj; + return getId() != null && getId().equals(other.getId()); + } + + @Override + public int hashCode() { + // Hashcode should never change during the lifetime of an object. Because of + // this we can't use getId() to calculate the hashcode. Unless you have sets + // with lots of entities in them, returning the same hashcode should not be a + // problem. + return getClass().hashCode(); + } +} diff --git a/src/main/java/com/example/security/UserRepository.java b/src/main/java/com/example/security/UserRepository.java new file mode 100644 index 0000000..6f4a73a --- /dev/null +++ b/src/main/java/com/example/security/UserRepository.java @@ -0,0 +1,10 @@ +package com.example.security; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.jpa.repository.JpaRepository; + +interface UserRepository extends JpaRepository { + + @Nullable + User findByUsername(String username); +} diff --git a/src/main/java/com/example/security/package-info.java b/src/main/java/com/example/security/package-info.java new file mode 100644 index 0000000..0b7aed2 --- /dev/null +++ b/src/main/java/com/example/security/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.example.security; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/com/example/security/ui/LoginView.java b/src/main/java/com/example/security/ui/LoginView.java new file mode 100644 index 0000000..0b76bdc --- /dev/null +++ b/src/main/java/com/example/security/ui/LoginView.java @@ -0,0 +1,41 @@ +package com.example.security.ui; + +import com.vaadin.flow.component.html.Main; +import com.vaadin.flow.component.login.LoginForm; +import com.vaadin.flow.component.orderedlayout.FlexComponent; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.BeforeEnterObserver; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.server.auth.AnonymousAllowed; + +// Public: referenced from com.example.security.SecurityConfig via loginView(LoginView.class). +@Route(value = "login", autoLayout = false) +@PageTitle("Login") +@AnonymousAllowed +public class LoginView extends Main implements BeforeEnterObserver { + + final LoginForm login; + + public LoginView() { + login = new LoginForm(); + login.setAction("login"); + + var layout = new VerticalLayout(); + layout.setAlignItems(FlexComponent.Alignment.CENTER); + layout.setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER); + layout.add(login); + layout.setSizeFull(); + + add(layout); + setSizeFull(); + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) { + login.setError(true); + } + } +} diff --git a/src/main/java/com/example/security/ui/package-info.java b/src/main/java/com/example/security/ui/package-info.java new file mode 100644 index 0000000..bc22110 --- /dev/null +++ b/src/main/java/com/example/security/ui/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.example.security.ui; + +import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/com/example/examplefeature/ui/TaskListViewTest.java b/src/test/java/com/example/examplefeature/ui/TaskListViewTest.java index 1ce8daf..a22d054 100644 --- a/src/test/java/com/example/examplefeature/ui/TaskListViewTest.java +++ b/src/test/java/com/example/examplefeature/ui/TaskListViewTest.java @@ -4,6 +4,7 @@ import com.vaadin.browserless.SpringBrowserlessTest; import com.vaadin.flow.component.notification.Notification; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; @@ -12,6 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @Transactional +@WithMockUser(roles = "USER") class TaskListViewTest extends SpringBrowserlessTest { @Test diff --git a/src/test/java/com/example/security/AppUserDetailsServiceTest.java b/src/test/java/com/example/security/AppUserDetailsServiceTest.java new file mode 100644 index 0000000..e0e0be6 --- /dev/null +++ b/src/test/java/com/example/security/AppUserDetailsServiceTest.java @@ -0,0 +1,44 @@ +package com.example.security; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@Transactional +class AppUserDetailsServiceTest { + + @Autowired + AppUserDetailsService userDetailsService; + + @Autowired + PasswordEncoder passwordEncoder; + + @Test + void seeded_user_has_user_role_and_matching_password() { + var userDetails = userDetailsService.loadUserByUsername("user"); + + assertThat(userDetails.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_USER"); + assertThat(userDetails.getPassword()).isNotEqualTo("user"); + assertThat(passwordEncoder.matches("user", userDetails.getPassword())).isTrue(); + } + + @Test + void seeded_admin_has_admin_role() { + var userDetails = userDetailsService.loadUserByUsername("admin"); + + assertThat(userDetails.getAuthorities()).extracting(Object::toString).containsExactly("ROLE_ADMIN"); + } + + @Test + void unknown_username_throws() { + assertThatThrownBy(() -> userDetailsService.loadUserByUsername("nobody")) + .isInstanceOf(UsernameNotFoundException.class); + } +} diff --git a/src/test/java/com/example/security/UserTest.java b/src/test/java/com/example/security/UserTest.java new file mode 100644 index 0000000..3462473 --- /dev/null +++ b/src/test/java/com/example/security/UserTest.java @@ -0,0 +1,24 @@ +package com.example.security; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UserTest { + + @Test + void blank_username_is_rejected() { + assertThatThrownBy(() -> new User(" ", "hash")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void username_exceeding_max_length_is_rejected() { + assertThatThrownBy(() -> new User("x".repeat(User.USERNAME_MAX_LENGTH + 1), "hash")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void blank_password_hash_is_rejected() { + assertThatThrownBy(() -> new User("someone", " ")).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/com/example/security/ui/LoginViewTest.java b/src/test/java/com/example/security/ui/LoginViewTest.java new file mode 100644 index 0000000..edff664 --- /dev/null +++ b/src/test/java/com/example/security/ui/LoginViewTest.java @@ -0,0 +1,40 @@ +package com.example.security.ui; + +import com.vaadin.browserless.SpringBrowserlessTest; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.router.QueryParameters; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@Transactional +class LoginViewTest extends SpringBrowserlessTest { + + @Test + @WithAnonymousUser + void anonymous_user_is_redirected_to_login() { + navigate("", LoginView.class); + } + + @Test + @WithAnonymousUser + void login_form_shows_error_state_when_error_query_parameter_present() { + navigate(LoginView.class); + // navigate(String, Class) rejects a "?" in the location, so the query + // parameter has to be attached via the UI.navigate(String, QueryParameters) overload. + UI.getCurrent().navigate("login", QueryParameters.of("error", "")); + var view = (LoginView) getCurrentView(); + assertThat(view.login.isError()).isTrue(); + } + + @Test + @WithAnonymousUser + void login_form_has_no_error_state_by_default() { + var view = navigate(LoginView.class); + assertThat(view.login.isError()).isFalse(); + } +}