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);
+ }
+ }
+}