diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListener.java index ed68f7f5..b12dcbda 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListener.java +++ b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListener.java @@ -42,7 +42,7 @@ public void onUserPreDelete(UserPreDeleteEvent event) { } private void deleteUserEntityAndCredentials(WebAuthnUserEntity userEntity) { - credentialRepository.findByUserEntity(userEntity).forEach(credentialRepository::delete); + credentialRepository.deleteByUserEntity(userEntity); userEntityRepository.delete(userEntity); log.info("Deleted WebAuthn data for user entity {}", userEntity.getName()); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java index 1d971823..8938b2fa 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java @@ -1,7 +1,5 @@ package com.digitalsanctuary.spring.user.persistence.repository; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -11,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; +import com.digitalsanctuary.spring.user.util.WebAuthnTransportUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -59,6 +58,12 @@ public long countCredentials(Long userId) { /** * Lock all credentials for a user and return the count. * + *

+ * Note: This method uses {@code Propagation.MANDATORY}, so callers must already be within + * an active transaction. Calling this method without a surrounding transaction will throw + * {@link org.springframework.transaction.IllegalTransactionStateException}. + *

+ * * @param userId the user ID * @return count of locked credential rows */ @@ -139,18 +144,10 @@ private Optional findCredentialForUser(String credentialId, * @return the DTO */ private WebAuthnCredentialInfo toCredentialInfo(WebAuthnCredential entity) { - List transportList = parseTransportList(entity.getAuthenticatorTransports()); + List transportList = WebAuthnTransportUtils.parseTransportStrings(entity.getAuthenticatorTransports()); return WebAuthnCredentialInfo.builder().id(entity.getCredentialId()).label(entity.getLabel()) .created(entity.getCreated()).lastUsed(entity.getLastUsed()) .transports(transportList).backupEligible(entity.isBackupEligible()) .backupState(entity.isBackupState()).build(); } - - private List parseTransportList(String transports) { - if (transports == null || transports.isEmpty()) { - return Collections.emptyList(); - } - return Arrays.stream(transports.split(",")).map(String::trim).filter(s -> !s.isEmpty()) - .collect(Collectors.toList()); - } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialRepository.java index 3a4cf346..07872c7f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialRepository.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialRepository.java @@ -2,10 +2,12 @@ import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import jakarta.persistence.LockModeType; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; @@ -67,4 +69,14 @@ public interface WebAuthnCredentialRepository extends JpaRepository findByIdWithUser(@Param("credentialId") String credentialId); + + /** + * Delete all credentials for a WebAuthn user entity in a single bulk DELETE statement. + * + * @param userEntity the WebAuthn user entity whose credentials should be deleted + */ + @Transactional + @Modifying + @Query("DELETE FROM WebAuthnCredential c WHERE c.userEntity = :userEntity") + void deleteByUserEntity(@Param("userEntity") WebAuthnUserEntity userEntity); } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java index 77ec6ce5..eb835493 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java @@ -36,6 +36,8 @@ @Slf4j public class WebAuthnUserEntityBridge implements PublicKeyCredentialUserEntityRepository { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private final WebAuthnUserEntityRepository webAuthnUserEntityRepository; private final UserRepository userRepository; @@ -115,7 +117,7 @@ public void delete(Bytes id) { @Transactional public PublicKeyCredentialUserEntity createUserEntity(User user) { byte[] randomHandle = new byte[64]; - new SecureRandom().nextBytes(randomHandle); + SECURE_RANDOM.nextBytes(randomHandle); Bytes userId = new Bytes(randomHandle); String idStr = Base64.getUrlEncoder().withoutPadding().encodeToString(userId.getBytes()); String displayName = user.getFullName(); diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java index 8720d970..00c04c9a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java @@ -1,8 +1,6 @@ package com.digitalsanctuary.spring.user.security; -import java.util.Arrays; import java.util.Base64; -import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -21,6 +19,7 @@ import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository; import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityRepository; +import com.digitalsanctuary.spring.user.util.WebAuthnTransportUtils; import lombok.extern.slf4j.Slf4j; /** @@ -148,7 +147,7 @@ private CredentialRecord toCredentialRecord(WebAuthnCredential entity) { .signatureCount(entity.getSignatureCount()).uvInitialized(entity.isUvInitialized()) .backupEligible(entity.isBackupEligible()).backupState(entity.isBackupState()) .credentialType(credType) - .transports(parseTransports(entity.getAuthenticatorTransports())) + .transports(WebAuthnTransportUtils.parseTransports(entity.getAuthenticatorTransports())) .attestationObject( entity.getAttestationObject() != null ? new Bytes(entity.getAttestationObject()) : null) .attestationClientDataJSON(entity.getAttestationClientDataJson() != null @@ -157,23 +156,6 @@ private CredentialRecord toCredentialRecord(WebAuthnCredential entity) { .created(entity.getCreated()).lastUsed(entity.getLastUsed()).label(entity.getLabel()).build(); } - /** - * Parse comma-separated transport string to a Set of AuthenticatorTransport. - */ - private Set parseTransports(String transports) { - if (transports == null || transports.isEmpty()) { - return Collections.emptySet(); - } - return Arrays.stream(transports.split(",")).map(String::trim).filter(s -> !s.isEmpty()).map(value -> { - try { - return AuthenticatorTransport.valueOf(value); - } catch (IllegalArgumentException e) { - log.warn("Unknown AuthenticatorTransport '{}', skipping", value); - return null; - } - }).filter(java.util.Objects::nonNull).collect(Collectors.toSet()); - } - /** * Convert a Set of AuthenticatorTransport to a comma-separated string. */ diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java index 4724a2c6..523fc740 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java @@ -71,7 +71,7 @@ public boolean hasCredentials(User user) { public void renameCredential(String credentialId, String newLabel, User user) { validateLabel(newLabel); - int updated = credentialQueryRepository.renameCredential(credentialId, newLabel, user.getId()); + int updated = credentialQueryRepository.renameCredential(credentialId, newLabel.trim(), user.getId()); if (updated == 0) { throw new WebAuthnException("Credential not found or access denied"); diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/WebAuthnTransportUtils.java b/src/main/java/com/digitalsanctuary/spring/user/util/WebAuthnTransportUtils.java new file mode 100644 index 00000000..83e78d0d --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/util/WebAuthnTransportUtils.java @@ -0,0 +1,68 @@ +package com.digitalsanctuary.spring.user.util; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.security.web.webauthn.api.AuthenticatorTransport; +import lombok.extern.slf4j.Slf4j; + +/** + * Shared utility methods for parsing WebAuthn authenticator transport strings. + * + *

+ * Transport values are stored as comma-separated strings in the database (e.g. {@code "internal,hybrid"}). + * This class provides two levels of parsing: + *

+ *
    + *
  • {@link #parseTransportStrings(String)} - split into trimmed, non-empty strings (for DTOs)
  • + *
  • {@link #parseTransports(String)} - map to {@link AuthenticatorTransport} enum values (for Spring Security)
  • + *
+ */ +@Slf4j +public final class WebAuthnTransportUtils { + + private WebAuthnTransportUtils() { + throw new IllegalStateException("Utility class"); + } + + /** + * Parse a comma-separated transport string into a list of trimmed, non-empty strings. + * + * @param transports the comma-separated transport string, may be {@code null} or empty + * @return an unmodifiable list of transport name strings, never {@code null} + */ + public static List parseTransportStrings(String transports) { + if (transports == null || transports.isEmpty()) { + return Collections.emptyList(); + } + return Arrays.stream(transports.split(",")).map(String::trim).filter(s -> !s.isEmpty()) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Parse a comma-separated transport string into a set of {@link AuthenticatorTransport} enum values. + * + *

+ * Unknown transport values are logged at WARN level and skipped. + *

+ * + * @param transports the comma-separated transport string, may be {@code null} or empty + * @return an unmodifiable set of transport enum values, never {@code null} + */ + public static Set parseTransports(String transports) { + if (transports == null || transports.isEmpty()) { + return Collections.emptySet(); + } + return parseTransportStrings(transports).stream().map(value -> { + try { + return AuthenticatorTransport.valueOf(value); + } catch (IllegalArgumentException e) { + log.warn("Unknown AuthenticatorTransport '{}', skipping", value); + return null; + } + }).filter(Objects::nonNull).collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java index 6bac0bf0..2af48e6d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java @@ -154,7 +154,7 @@ class RenameCredentialTests { @Test @DisplayName("should rename credential successfully") - void shouldRenameSuccessfully() /* no checked exception */ { + void shouldRenameSuccessfully() { // Given WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop"); @@ -169,7 +169,7 @@ void shouldRenameSuccessfully() /* no checked exception */ { @Test @DisplayName("should throw when rename fails") - void shouldThrowOnFailure() /* no checked exception */ { + void shouldThrowOnFailure() { // Given WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name"); doThrow(new WebAuthnException("Credential not found or access denied")).when(credentialManagementService) @@ -182,7 +182,7 @@ void shouldThrowOnFailure() /* no checked exception */ { @Test @DisplayName("should throw not found when user not found") - void shouldThrowNotFoundWhenUserNotFound() /* no checked exception */ { + void shouldThrowNotFoundWhenUserNotFound() { // Given WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name"); when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null); @@ -200,7 +200,7 @@ class DeleteCredentialTests { @Test @DisplayName("should delete credential successfully") - void shouldDeleteSuccessfully() /* no checked exception */ { + void shouldDeleteSuccessfully() { // When ResponseEntity response = api.deleteCredential("cred-1", userDetails); @@ -212,7 +212,7 @@ void shouldDeleteSuccessfully() /* no checked exception */ { @Test @DisplayName("should throw when delete fails") - void shouldThrowOnFailure() /* no checked exception */ { + void shouldThrowOnFailure() { // Given doThrow(new WebAuthnException("Cannot delete last passkey")).when(credentialManagementService).deleteCredential(eq("cred-1"), any(User.class)); @@ -224,7 +224,7 @@ void shouldThrowOnFailure() /* no checked exception */ { @Test @DisplayName("should throw not found when user not found") - void shouldThrowNotFoundWhenUserNotFound() /* no checked exception */ { + void shouldThrowNotFoundWhenUserNotFound() { // Given when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null); diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java index 9d28f562..039999c1 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnPreDeleteEventListenerTest.java @@ -3,7 +3,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -13,7 +12,6 @@ import org.mockito.Mock; import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.persistence.model.User; -import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository; import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityRepository; @@ -53,16 +51,7 @@ void shouldDeleteWebAuthnDataForUser() { userEntity.setName(testUser.getEmail()); userEntity.setUser(testUser); - WebAuthnCredential cred1 = new WebAuthnCredential(); - cred1.setCredentialId("cred-1"); - cred1.setUserEntity(userEntity); - - WebAuthnCredential cred2 = new WebAuthnCredential(); - cred2.setCredentialId("cred-2"); - cred2.setUserEntity(userEntity); - when(userEntityRepository.findByUserId(testUser.getId())).thenReturn(Optional.of(userEntity)); - when(credentialRepository.findByUserEntity(userEntity)).thenReturn(List.of(cred1, cred2)); UserPreDeleteEvent event = new UserPreDeleteEvent(this, testUser); @@ -70,8 +59,7 @@ void shouldDeleteWebAuthnDataForUser() { listener.onUserPreDelete(event); // Then - verify(credentialRepository).delete(cred1); - verify(credentialRepository).delete(cred2); + verify(credentialRepository).deleteByUserEntity(userEntity); verify(userEntityRepository).delete(userEntity); } diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java index ac8c09ea..45f44fd8 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java @@ -114,7 +114,7 @@ class RenameCredentialTests { @Test @DisplayName("should rename credential successfully") - void shouldRenameCredentialSuccessfully() /* no checked exception */ { + void shouldRenameCredentialSuccessfully() { // Given when(credentialQueryRepository.renameCredential("cred-123", "Work Laptop", testUser.getId())).thenReturn(1); @@ -125,6 +125,19 @@ void shouldRenameCredentialSuccessfully() /* no checked exception */ { verify(credentialQueryRepository).renameCredential("cred-123", "Work Laptop", testUser.getId()); } + @Test + @DisplayName("should trim whitespace from label before storing") + void shouldTrimLabelBeforeStoring() { + // Given + when(credentialQueryRepository.renameCredential("cred-123", "Work Laptop", testUser.getId())).thenReturn(1); + + // When + service.renameCredential("cred-123", " Work Laptop ", testUser); + + // Then — trimmed value reaches the repository, not the padded original + verify(credentialQueryRepository).renameCredential("cred-123", "Work Laptop", testUser.getId()); + } + @Test @DisplayName("should throw when credential not found") void shouldThrowWhenCredentialNotFound() { @@ -184,7 +197,7 @@ class DeleteCredentialTests { @Test @DisplayName("should delete credential when user has multiple passkeys") - void shouldDeleteWhenMultiplePasskeys() /* no checked exception */ { + void shouldDeleteWhenMultiplePasskeys() { // Given when(credentialQueryRepository.lockAndCountCredentials(testUser.getId())).thenReturn(2L); when(credentialQueryRepository.deleteCredential("cred-123", testUser.getId())).thenReturn(1); @@ -198,7 +211,7 @@ void shouldDeleteWhenMultiplePasskeys() /* no checked exception */ { @Test @DisplayName("should delete last credential when user has a password") - void shouldDeleteLastCredentialWhenUserHasPassword() /* no checked exception */ { + void shouldDeleteLastCredentialWhenUserHasPassword() { // Given - user has password set (from TestFixtures) when(credentialQueryRepository.lockAndCountCredentials(testUser.getId())).thenReturn(1L); when(credentialQueryRepository.deleteCredential("cred-123", testUser.getId())).thenReturn(1); diff --git a/src/test/java/com/digitalsanctuary/spring/user/util/WebAuthnTransportUtilsTest.java b/src/test/java/com/digitalsanctuary/spring/user/util/WebAuthnTransportUtilsTest.java new file mode 100644 index 00000000..a68ae452 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/util/WebAuthnTransportUtilsTest.java @@ -0,0 +1,127 @@ +package com.digitalsanctuary.spring.user.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.springframework.security.web.webauthn.api.AuthenticatorTransport; + +@DisplayName("WebAuthnTransportUtils Tests") +class WebAuthnTransportUtilsTest { + + @Nested + @DisplayName("Utility Class Construction") + class ConstructionTests { + + @Test + @DisplayName("should throw IllegalStateException when instantiated via reflection") + void shouldThrowOnInstantiation() throws Exception { + Constructor constructor = WebAuthnTransportUtils.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertThatThrownBy(() -> { + try { + constructor.newInstance(); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + }).isInstanceOf(IllegalStateException.class).hasMessage("Utility class"); + } + } + + @Nested + @DisplayName("parseTransportStrings") + class ParseTransportStringsTests { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("should return empty list for null or empty input") + void shouldReturnEmptyForNullOrEmpty(String input) { + assertThat(WebAuthnTransportUtils.parseTransportStrings(input)).isEmpty(); + } + + @Test + @DisplayName("should parse single transport") + void shouldParseSingleTransport() { + assertThat(WebAuthnTransportUtils.parseTransportStrings("usb")).containsExactly("usb"); + } + + @Test + @DisplayName("should parse multiple transports") + void shouldParseMultipleTransports() { + List result = WebAuthnTransportUtils.parseTransportStrings("usb,nfc,ble"); + assertThat(result).containsExactlyInAnyOrder("usb", "nfc", "ble"); + } + + @Test + @DisplayName("should trim whitespace from each transport") + void shouldTrimWhitespace() { + List result = WebAuthnTransportUtils.parseTransportStrings(" usb , nfc "); + assertThat(result).containsExactlyInAnyOrder("usb", "nfc"); + } + + @Test + @DisplayName("should filter empty entries from leading/trailing commas") + void shouldFilterEmptyEntries() { + assertThat(WebAuthnTransportUtils.parseTransportStrings(",usb,")).containsExactly("usb"); + } + + @Test + @DisplayName("should return unmodifiable list") + void shouldReturnUnmodifiableList() { + List result = WebAuthnTransportUtils.parseTransportStrings("usb"); + assertThatThrownBy(() -> result.add("nfc")).isInstanceOf(UnsupportedOperationException.class); + } + } + + @Nested + @DisplayName("parseTransports") + class ParseTransportsTests { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("should return empty set for null or empty input") + void shouldReturnEmptyForNullOrEmpty(String input) { + assertThat(WebAuthnTransportUtils.parseTransports(input)).isEmpty(); + } + + @Test + @DisplayName("should parse single transport value") + void shouldParseSingleTransport() { + String value = AuthenticatorTransport.USB.getValue(); + Set result = WebAuthnTransportUtils.parseTransports(value); + assertThat(result).containsExactly(AuthenticatorTransport.USB); + } + + @Test + @DisplayName("should parse multiple transport values") + void shouldParseMultipleTransports() { + String input = AuthenticatorTransport.USB.getValue() + "," + AuthenticatorTransport.NFC.getValue(); + Set result = WebAuthnTransportUtils.parseTransports(input); + assertThat(result).containsExactlyInAnyOrder(AuthenticatorTransport.USB, AuthenticatorTransport.NFC); + } + + @Test + @DisplayName("should trim whitespace from each transport value") + void shouldTrimWhitespace() { + String input = " " + AuthenticatorTransport.USB.getValue() + " "; + Set result = WebAuthnTransportUtils.parseTransports(input); + assertThat(result).containsExactly(AuthenticatorTransport.USB); + } + + @Test + @DisplayName("should return unmodifiable set") + void shouldReturnUnmodifiableSet() { + String validValue = AuthenticatorTransport.USB.getValue(); + Set result = WebAuthnTransportUtils.parseTransports(validValue); + assertThatThrownBy(() -> result.add(AuthenticatorTransport.NFC)) + .isInstanceOf(UnsupportedOperationException.class); + } + } +}