From 2415fec16ac8f6ffb9832083b03883696ec6334a Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Tue, 9 Jun 2026 06:51:53 -0700 Subject: [PATCH] test: raise line/branch coverage across library, adapters, and persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds targeted unit and integration tests across every published module to close JaCoCo line/branch gaps surfaced by the per-module reports. No production code changed — tests only. Highlights: - refresh-tokens: was 0 tests with a silently-SKIPPED coverage gate (no execution data) — now driven via RefreshTokenScenarios plus service, handler, record, config, and deletion-listener tests; gate now enforced. - jwt: cover PkAuthJwtValidator malformed/missing-claim/wrong-shape branches via hand-built raw JWTs, plus JwtClaims and TokenTtlPolicy. - core: registration happy-path (persistRegistration) and evaluateAttestation decisions via mocked WebAuthn4J RegistrationData; in-memory rate limiter/window counter; SPI carrier records; persistence exceptions. - micronaut / spring / dropwizard: authenticated admin-endpoint paths and the config-builder factory/auto-config validation+defaulting branches. - persistence (jdbi + dynamodb): repository mutation/bulk paths not exercised by the shared scenarios — credential update/delete, user-lookup register/findView, backup-code replaceAll, otp deleteByUserHandle, refresh deleteExpiredBefore. Also fixes two latent issues found along the way: - refresh-tokens coverage verification was vacuously skipped (no tests). - Spring admin integration suite did not reset the shared in-memory ceremony rate limiter between tests, tripping the per-IP limit as cases were added; added a @BeforeEach reset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InMemoryCeremonyRateLimiterTest.java | 81 ++++ ...AuthenticationServiceRegistrationTest.java | 350 ++++++++++++++++++ .../ratelimit/InMemoryWindowCounterTest.java | 71 ++++ .../pkauth/spi/PersistenceExceptionTest.java | 40 ++ .../pkauth/spi/SpiCarrierRecordsTest.java | 109 ++++++ .../PkAuthBundleAltFlowsIntegrationTest.java | 89 +++++ .../admin/PkAuthAdminResourceTest.java | 59 +++ .../pkauth/jwt/JwtClaimsTest.java | 117 ++++++ .../jwt/PkAuthJwtValidatorEdgeCasesTest.java | 260 +++++++++++++ .../pkauth/jwt/TokenTtlPolicyTest.java | 67 ++++ .../PkAuthAdminEndpointsCoverageTest.java | 151 ++++++++ .../micronaut/PkAuthFactoryConfigTest.java | 128 +++++++ ...PkAuthPersistenceExceptionHandlerTest.java | 33 ++ .../DynamoDbAltFlowsIntegrationTest.java | 42 +++ ...DbCredentialRepositoryIntegrationTest.java | 158 ++++++++ ...RefreshTokenRepositoryIntegrationTest.java | 50 +++ .../DynamoDbUserLookupIntegrationTest.java | 75 ++++ .../jdbi/JdbiAltFlowsIntegrationTest.java | 51 +++ ...biCredentialRepositoryIntegrationTest.java | 136 +++++++ ...RefreshTokenRepositoryIntegrationTest.java | 53 +++ .../jdbi/JdbiUserLookupIntegrationTest.java | 72 ++++ .../pkauth/refresh/RefreshRecordsTest.java | 99 +++++ .../refresh/RefreshTokenConfigTest.java | 65 ++++ .../refresh/RefreshTokenRecordTest.java | 313 ++++++++++++++++ ...freshTokenServiceDeletionListenerTest.java | 54 +++ .../refresh/RefreshTokenServiceTest.java | 280 ++++++++++++++ .../pkauth/refresh/RefreshTtlPolicyTest.java | 81 ++++ .../refresh/web/RefreshHandlerTest.java | 138 +++++++ .../spring/PkAuthAdminIntegrationTest.java | 60 +++ .../PkAuthAutoConfigurationUnitTest.java | 107 ++++++ 30 files changed, 3389 insertions(+) create mode 100644 pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiterTest.java create mode 100644 pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java create mode 100644 pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java create mode 100644 pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/PersistenceExceptionTest.java create mode 100644 pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/SpiCarrierRecordsTest.java create mode 100644 pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java create mode 100644 pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtClaimsTest.java create mode 100644 pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java create mode 100644 pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java create mode 100644 pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java create mode 100644 pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactoryConfigTest.java create mode 100644 pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandlerTest.java create mode 100644 pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java create mode 100644 pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java create mode 100644 pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java create mode 100644 pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshRecordsTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecordTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListenerTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandlerTest.java create mode 100644 pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfigurationUnitTest.java diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiterTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiterTest.java new file mode 100644 index 0000000..a5afa0f --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiterTest.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.ceremony; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +/** Direct unit coverage for the in-memory ceremony rate limiter and its two counter buckets. */ +class InMemoryCeremonyRateLimiterTest { + + @Test + void defaultConstructorUsesDocumentedAllowances() { + InMemoryCeremonyRateLimiter limiter = new InMemoryCeremonyRateLimiter(); + // First DEFAULT_PER_USERNAME_LIMIT acquisitions for a username succeed; the next one trips. + for (int i = 0; i < InMemoryCeremonyRateLimiter.DEFAULT_PER_USERNAME_LIMIT; i++) { + assertThat(limiter.tryAcquireForUsername("alice")).as("call %d", i).isTrue(); + } + assertThat(limiter.tryAcquireForUsername("alice")).isFalse(); + } + + @Test + void perIpLimitTripsAfterAllowanceExhausted() { + InMemoryCeremonyRateLimiter limiter = + new InMemoryCeremonyRateLimiter(2, 5, Duration.ofMinutes(1)); + assertThat(limiter.tryAcquireForIp("1.2.3.4")).isTrue(); + assertThat(limiter.tryAcquireForIp("1.2.3.4")).isTrue(); + assertThat(limiter.tryAcquireForIp("1.2.3.4")).isFalse(); + // A different IP has its own bucket. + assertThat(limiter.tryAcquireForIp("5.6.7.8")).isTrue(); + } + + @Test + void nullOrEmptyIpIsAllowedWithoutThrottling() { + InMemoryCeremonyRateLimiter limiter = + new InMemoryCeremonyRateLimiter(1, 1, Duration.ofMinutes(1)); + // Both bypass the bucket entirely, so repeated calls keep returning true. + assertThat(limiter.tryAcquireForIp(null)).isTrue(); + assertThat(limiter.tryAcquireForIp(null)).isTrue(); + assertThat(limiter.tryAcquireForIp("")).isTrue(); + assertThat(limiter.tryAcquireForIp("")).isTrue(); + assertThat(limiter.ipKeys()).isEmpty(); + } + + @Test + void usernameAcquireRejectsNull() { + InMemoryCeremonyRateLimiter limiter = new InMemoryCeremonyRateLimiter(); + assertThatThrownBy(() -> limiter.tryAcquireForUsername(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void constructorValidatesLimitsAndWindow() { + assertThatThrownBy(() -> new InMemoryCeremonyRateLimiter(0, 5, Duration.ofMinutes(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("perIpLimit"); + assertThatThrownBy(() -> new InMemoryCeremonyRateLimiter(5, 0, Duration.ofMinutes(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("perUsernameLimit"); + assertThatThrownBy(() -> new InMemoryCeremonyRateLimiter(5, 5, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new InMemoryCeremonyRateLimiter(5, 5, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("window"); + } + + @Test + void diagnosticKeyAccessorsAndResetTrackState() { + InMemoryCeremonyRateLimiter limiter = + new InMemoryCeremonyRateLimiter(5, 5, Duration.ofMinutes(1)); + limiter.tryAcquireForIp("9.9.9.9"); + limiter.tryAcquireForUsername("bob"); + assertThat(limiter.ipKeys()).containsExactly("9.9.9.9"); + assertThat(limiter.usernameKeys()).containsExactly("bob"); + + limiter.reset(); + assertThat(limiter.ipKeys()).isEmpty(); + assertThat(limiter.usernameKeys()).isEmpty(); + } +} diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java new file mode 100644 index 0000000..419261e --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.internal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codeheadsystems.pkauth.api.AttestationConveyance; +import com.codeheadsystems.pkauth.api.ChallengeId; +import com.codeheadsystems.pkauth.api.CredentialId; +import com.codeheadsystems.pkauth.api.FinishRegistrationRequest; +import com.codeheadsystems.pkauth.api.RegistrationResponseJson; +import com.codeheadsystems.pkauth.api.RegistrationResponseJson.AuthenticatorAttestationResponseJson; +import com.codeheadsystems.pkauth.api.RegistrationResult; +import com.codeheadsystems.pkauth.api.ResidentKeyRequirement; +import com.codeheadsystems.pkauth.api.Transport; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.api.UserVerificationRequirement; +import com.codeheadsystems.pkauth.config.CeremonyConfig; +import com.codeheadsystems.pkauth.config.CounterRegressionPolicy; +import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.credential.CredentialRecord; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.metrics.Metrics; +import com.codeheadsystems.pkauth.spi.AttestationTrustPolicy; +import com.codeheadsystems.pkauth.spi.ChallengeRecord; +import com.codeheadsystems.pkauth.spi.ChallengeStore; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import com.codeheadsystems.pkauth.spi.CredentialRepository; +import com.codeheadsystems.pkauth.spi.OriginValidator; +import com.codeheadsystems.pkauth.spi.UserLookup; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.webauthn4j.WebAuthnManager; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.AuthenticatorTransport; +import com.webauthn4j.data.RegistrationData; +import com.webauthn4j.data.RegistrationParameters; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.attestation.authenticator.AAGUID; +import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData; +import com.webauthn4j.data.attestation.authenticator.EC2COSEKey; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +/** + * Happy-path registration ({@code persistRegistration}) and the {@code evaluateAttestation} + * decision branches, which the main service test doesn't reach because it never returns a verified + * {@link RegistrationData} from the mocked {@link WebAuthnManager}. + */ +class DefaultPasskeyAuthenticationServiceRegistrationTest { + + private static final Instant NOW = Instant.parse("2026-05-13T12:00:00Z"); + private static final byte[] CHALLENGE = filled(32, (byte) 1); + private static final ChallengeId CHALLENGE_ID = new ChallengeId(Base64Url.encode(CHALLENGE)); + private static final UserHandle USER_HANDLE = UserHandle.of(filled(16, (byte) 9)); + private static final byte[] CRED_ID = filled(20, (byte) 2); + private static final CredentialId CRED_ID_VALUE = CredentialId.of(CRED_ID); + + private final JsonMapper jsonMapper = + JsonMapper.builder() + .changeDefaultPropertyInclusion(v -> v.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); + private final ObjectConverter objectConverter = new ObjectConverter(); + + private WebAuthnManager webAuthnManager; + private CredentialRepository credentialRepository; + private UserLookup userLookup; + private ChallengeStore challengeStore; + private OriginValidator originValidator; + private AttestationTrustPolicy attestationTrustPolicy; + private Metrics metrics; + private DefaultPasskeyAuthenticationService service; + + @BeforeEach + void setUp() { + webAuthnManager = mock(WebAuthnManager.class); + credentialRepository = mock(CredentialRepository.class); + userLookup = mock(UserLookup.class); + challengeStore = mock(ChallengeStore.class); + originValidator = mock(OriginValidator.class); + attestationTrustPolicy = mock(AttestationTrustPolicy.class); + metrics = mock(Metrics.class); + + lenient().when(originValidator.isAllowed("https://example.com")).thenReturn(true); + lenient() + .when(attestationTrustPolicy.evaluate(any())) + .thenReturn(new AttestationTrustPolicy.Decision.Trusted()); + // Default: challenge present + valid for registration, and the credential is brand new. + lenient() + .when(challengeStore.takeOnce(CHALLENGE_ID)) + .thenReturn( + Optional.of( + new ChallengeRecord( + CHALLENGE, + ChallengeRecord.Purpose.REGISTRATION, + USER_HANDLE, + NOW.plusSeconds(300)))); + lenient() + .when(credentialRepository.findByCredentialId(CRED_ID_VALUE)) + .thenReturn(Optional.empty()); + + RelyingPartyConfig rp = + new RelyingPartyConfig("example.com", "Example", Set.of("https://example.com")); + CeremonyConfig ceremonyConfig = + new CeremonyConfig( + Duration.ofMinutes(5), + UserVerificationRequirement.PREFERRED, + ResidentKeyRequirement.PREFERRED, + AttestationConveyance.NONE, + CounterRegressionPolicy.REJECT); + SecureRandom random = + new SecureRandom() { + @Override + public void nextBytes(byte[] bytes) { + System.arraycopy(CHALLENGE, 0, bytes, 0, Math.min(bytes.length, CHALLENGE.length)); + } + }; + service = + new DefaultPasskeyAuthenticationService( + webAuthnManager, + objectConverter, + credentialRepository, + userLookup, + challengeStore, + ClockProvider.fromClock(Clock.fixed(NOW, ZoneOffset.UTC)), + originValidator, + attestationTrustPolicy, + rp, + ceremonyConfig, + new ChallengeGenerator(random), + metrics); + } + + @Test + void happyPathPersistsCredentialWithTransportsAndAaguidAndLabel() throws Exception { + UUID aaguid = UUID.fromString("01020304-0506-0708-090a-0b0c0d0e0f10"); + RegistrationData regData = + mockRegistrationData(new AAGUID(aaguid), Set.of(AuthenticatorTransport.USB), false); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), "My Key")); + + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.Success.class, + s -> { + assertThat(s.credential().credentialId()).isEqualTo(CRED_ID_VALUE); + assertThat(s.credential().userHandle()).isEqualTo(USER_HANDLE); + assertThat(s.credential().label()).isEqualTo("My Key"); + assertThat(s.credential().aaguid()).isEqualTo(aaguid); + assertThat(s.credential().transports()).contains(Transport.USB); + assertThat(s.credential().createdAt()).isEqualTo(NOW); + }); + + var captor = org.mockito.ArgumentCaptor.forClass(CredentialRecord.class); + verify(credentialRepository).save(captor.capture()); + assertThat(captor.getValue().credentialId()).isEqualTo(CRED_ID_VALUE); + verify(metrics).incrementCounter("pkauth.registration.outcome", "result", "Success"); + } + + @Test + void happyPathWithZeroAaguidNullTransportsAndDefaultLabel() throws Exception { + // AAGUID.ZERO → stored aaguid is null; null transports → empty transport set; null label → + // "Passkey" default. + RegistrationData regData = mockRegistrationData(AAGUID.ZERO, null, false); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), null)); + + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.Success.class, + s -> { + assertThat(s.credential().aaguid()).isNull(); + assertThat(s.credential().transports()).isEmpty(); + assertThat(s.credential().label()).isEqualTo("Passkey"); + }); + } + + @Test + void usernamelessChallengeFallsBackToUsernamelessHandle() throws Exception { + UserHandle usernameless = UserHandle.of(filled(16, (byte) 7)); + when(challengeStore.takeOnce(CHALLENGE_ID)) + .thenReturn( + Optional.of( + new ChallengeRecord( + CHALLENGE, ChallengeRecord.Purpose.REGISTRATION, null, NOW.plusSeconds(300)))); + when(userLookup.getOrCreateHandle(UserLookup.USERNAMELESS_KEY)).thenReturn(usernameless); + RegistrationData regData = mockRegistrationData(AAGUID.ZERO, null, false); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), "k")); + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.Success.class, + s -> assertThat(s.credential().userHandle()).isEqualTo(usernameless)); + } + + @Test + void missingAttestedCredentialDataIsInvalidPayload() throws Exception { + RegistrationData regData = mockRegistrationData(AAGUID.ZERO, null, /* acdNull */ true); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), "k")); + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.InvalidPayload.class, + p -> assertThat(p.detail()).contains("attested credential data missing")); + verify(credentialRepository, never()).save(any()); + } + + @Test + void rejectedAttestationPolicyYieldsAttestationRejected() throws Exception { + when(attestationTrustPolicy.evaluate(any())) + .thenReturn(new AttestationTrustPolicy.Decision.Rejected("untrusted authenticator")); + RegistrationData regData = mockRegistrationData(AAGUID.ZERO, null, false); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), "k")); + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.AttestationRejected.class, + r -> assertThat(r.reason()).isEqualTo("untrusted authenticator")); + verify(credentialRepository, never()).save(any()); + } + + @Test + void duplicateCredentialIsRejectedBeforePersist() throws Exception { + when(credentialRepository.findByCredentialId(CRED_ID_VALUE)) + .thenReturn(Optional.of(mock(CredentialRecord.class))); + RegistrationData regData = mockRegistrationData(AAGUID.ZERO, null, false); + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenReturn(regData); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), "k")); + assertThat(result) + .isInstanceOfSatisfying( + RegistrationResult.DuplicateCredential.class, + d -> assertThat(d.credentialId()).isEqualTo(CRED_ID_VALUE)); + verify(credentialRepository, never()).save(any()); + } + + @Test + void missingChallengeVerificationExceptionMapsToInvalidChallenge() throws Exception { + when(webAuthnManager.verify( + any(com.webauthn4j.data.RegistrationRequest.class), any(RegistrationParameters.class))) + .thenThrow(new com.webauthn4j.verifier.exception.MissingChallengeException("missing")); + + RegistrationResult result = service.finishRegistration(finishReg(cd(), "k")); + assertThat(result).isInstanceOf(RegistrationResult.InvalidChallenge.class); + } + + // -- fixtures ----------------------------------------------------------------------------- + + private RegistrationData mockRegistrationData( + AAGUID aaguid, Set transports, boolean acdNull) { + RegistrationData data = mock(RegistrationData.class); + AttestationObject ao = mock(AttestationObject.class); + @SuppressWarnings("unchecked") + com.webauthn4j.data.attestation.authenticator.AuthenticatorData< + com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput> + authData = mock(com.webauthn4j.data.attestation.authenticator.AuthenticatorData.class); + when(data.getAttestationObject()).thenReturn(ao); + when(ao.getAuthenticatorData()).thenAnswer(inv -> authData); + when(ao.getFormat()).thenReturn("none"); + when(data.getTransports()).thenReturn(transports); + + if (acdNull) { + when(authData.getAttestedCredentialData()).thenReturn(null); + return data; + } + + AttestedCredentialData acd = mock(AttestedCredentialData.class); + when(authData.getAttestedCredentialData()).thenReturn(acd); + when(acd.getCredentialId()).thenReturn(CRED_ID); + when(acd.getAaguid()).thenReturn(aaguid); + when(acd.getCOSEKey()).thenAnswer(inv -> coseKey()); + when(authData.getSignCount()).thenReturn(0L); + when(authData.isFlagUP()).thenReturn(true); + when(authData.isFlagUV()).thenReturn(true); + when(authData.isFlagBE()).thenReturn(false); + when(authData.isFlagBS()).thenReturn(false); + when(authData.isFlagAT()).thenReturn(true); + when(authData.isFlagED()).thenReturn(false); + return data; + } + + private static EC2COSEKey coseKey() { + byte[] point = new byte[65]; + point[0] = 0x04; + for (int i = 1; i < point.length; i++) { + point[i] = (byte) i; + } + return EC2COSEKey.createFromUncompressedECCKey(point); + } + + private FinishRegistrationRequest finishReg(byte[] clientDataJson, String label) { + return new FinishRegistrationRequest( + CHALLENGE_ID, + "alice", + label, + new RegistrationResponseJson( + CRED_ID, + CRED_ID, + new AuthenticatorAttestationResponseJson( + clientDataJson, new byte[] {(byte) 0xa0}, null, null, null, null), + null, + null, + "public-key")); + } + + private byte[] cd() { + var node = jsonMapper.createObjectNode(); + node.put("type", "webauthn.create"); + node.put("challenge", Base64Url.encode(CHALLENGE)); + node.put("origin", "https://example.com"); + return jsonMapper.writeValueAsString(node).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] filled(int len, byte v) { + byte[] out = new byte[len]; + java.util.Arrays.fill(out, v); + return out; + } +} diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java new file mode 100644 index 0000000..bdcbf67 --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +/** Unit coverage for the Caffeine-backed sliding-window counter. */ +class InMemoryWindowCounterTest { + + private final InMemoryWindowCounter counter = new InMemoryWindowCounter(Duration.ofMinutes(1)); + + @Test + void constructorRejectsNullZeroAndNegativeWindow() { + assertThatThrownBy(() -> new InMemoryWindowCounter(null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new InMemoryWindowCounter(Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("window"); + assertThatThrownBy(() -> new InMemoryWindowCounter(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void firstIncrementStartsAtOneAndAccumulates() { + assertThat(counter.countAndIncrement("k")).isEqualTo(1); + assertThat(counter.countAndIncrement("k")).isEqualTo(2); + assertThat(counter.countAndIncrement("k")).isEqualTo(3); + } + + @Test + void distinctKeysAreCountedIndependently() { + counter.countAndIncrement("a"); + counter.countAndIncrement("a"); + counter.countAndIncrement("b"); + assertThat(counter.current("a")).isEqualTo(2); + assertThat(counter.current("b")).isEqualTo(1); + } + + @Test + void currentReturnsZeroForUnseenKeyAndDoesNotIncrement() { + assertThat(counter.current("absent")).isZero(); + assertThat(counter.current("absent")).isZero(); + } + + @Test + void resetDropsAllCounters() { + counter.countAndIncrement("a"); + counter.countAndIncrement("b"); + counter.reset(); + assertThat(counter.current("a")).isZero(); + assertThat(counter.current("b")).isZero(); + assertThat(counter.keys()).isEmpty(); + } + + @Test + void keysReflectsTrackedCounters() { + counter.countAndIncrement("a"); + counter.countAndIncrement("b"); + assertThat(counter.keys()).containsExactlyInAnyOrder("a", "b"); + } + + @Test + void countAndIncrementAndCurrentRejectNullKey() { + assertThatThrownBy(() -> counter.countAndIncrement(null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> counter.current(null)).isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/PersistenceExceptionTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/PersistenceExceptionTest.java new file mode 100644 index 0000000..ed7c2b1 --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/PersistenceExceptionTest.java @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.spi; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** Constructors and the {@code operation()} tag on the persistence exception hierarchy. */ +class PersistenceExceptionTest { + + @Test + void persistenceExceptionCarriesOperationMessageAndCause() { + Throwable cause = new IllegalStateException("db down"); + PkAuthPersistenceException ex = + new PkAuthPersistenceException("credentials.save", "save failed", cause); + assertThat(ex.operation()).isEqualTo("credentials.save"); + assertThat(ex.getMessage()).isEqualTo("save failed"); + assertThat(ex.getCause()).isSameAs(cause); + } + + @Test + void persistenceExceptionTwoArgConstructorHasNoCause() { + PkAuthPersistenceException ex = new PkAuthPersistenceException("challenges.put", "put failed"); + assertThat(ex.operation()).isEqualTo("challenges.put"); + assertThat(ex.getCause()).isNull(); + } + + @Test + void duplicateCredentialExceptionPinsOperationToCredentialsSave() { + DuplicateCredentialException ex = new DuplicateCredentialException("already exists"); + assertThat(ex.operation()).isEqualTo("credentials.save"); + assertThat(ex.getMessage()).isEqualTo("already exists"); + assertThat(ex).isInstanceOf(PkAuthPersistenceException.class); + + Throwable cause = new RuntimeException("unique violation"); + DuplicateCredentialException withCause = new DuplicateCredentialException("dup", cause); + assertThat(withCause.getCause()).isSameAs(cause); + assertThat(withCause.operation()).isEqualTo("credentials.save"); + } +} diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/SpiCarrierRecordsTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/SpiCarrierRecordsTest.java new file mode 100644 index 0000000..2543ec4 --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/spi/SpiCarrierRecordsTest.java @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.spi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Validation guards for the SPI-declared carrier records {@code StoredOtp} / {@code + * StoredBackupCode}. + */ +class SpiCarrierRecordsTest { + + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + + // -- StoredOtp ---------------------------------------------------------------------------- + + private static OtpRepository.StoredOtp validOtp(int attempts, int maxAttempts) { + return new OtpRepository.StoredOtp( + "otp-1", + USER, + "+15551234567", + "hash", + attempts, + maxAttempts, + false, + NOW, + NOW.plusSeconds(60)); + } + + @Test + void storedOtpExposesAllFields() { + OtpRepository.StoredOtp otp = validOtp(1, 5); + assertThat(otp.otpId()).isEqualTo("otp-1"); + assertThat(otp.userHandle()).isEqualTo(USER); + assertThat(otp.phoneE164()).isEqualTo("+15551234567"); + assertThat(otp.hashedCode()).isEqualTo("hash"); + assertThat(otp.attempts()).isEqualTo(1); + assertThat(otp.maxAttempts()).isEqualTo(5); + assertThat(otp.consumed()).isFalse(); + assertThat(otp.expiresAt()).isEqualTo(NOW.plusSeconds(60)); + } + + @Test + void storedOtpRejectsNullRequiredFields() { + assertThatThrownBy( + () -> new OtpRepository.StoredOtp(null, USER, "+1", "h", 0, 1, false, NOW, NOW)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new OtpRepository.StoredOtp("id", null, "+1", "h", 0, 1, false, NOW, NOW)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new OtpRepository.StoredOtp("id", USER, null, "h", 0, 1, false, NOW, NOW)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new OtpRepository.StoredOtp("id", USER, "+1", null, 0, 1, false, NOW, NOW)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new OtpRepository.StoredOtp("id", USER, "+1", "h", 0, 1, false, null, NOW)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new OtpRepository.StoredOtp("id", USER, "+1", "h", 0, 1, false, NOW, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void storedOtpRejectsNegativeAttemptsAndNonPositiveMax() { + assertThatThrownBy(() -> validOtp(-1, 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attempts"); + assertThatThrownBy(() -> validOtp(0, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxAttempts"); + } + + // -- StoredBackupCode --------------------------------------------------------------------- + + @Test + void storedBackupCodeExposesFieldsAndAllowsNullConsumedAt() { + BackupCodeRepository.StoredBackupCode code = + new BackupCodeRepository.StoredBackupCode("c-1", USER, "hash", false, NOW, null); + assertThat(code.codeId()).isEqualTo("c-1"); + assertThat(code.userHandle()).isEqualTo(USER); + assertThat(code.hashedCode()).isEqualTo("hash"); + assertThat(code.consumed()).isFalse(); + assertThat(code.createdAt()).isEqualTo(NOW); + assertThat(code.consumedAt()).isNull(); + } + + @Test + void storedBackupCodeRejectsNullRequiredFields() { + assertThatThrownBy( + () -> new BackupCodeRepository.StoredBackupCode(null, USER, "h", false, NOW, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new BackupCodeRepository.StoredBackupCode("id", null, "h", false, NOW, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new BackupCodeRepository.StoredBackupCode("id", USER, null, false, NOW, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new BackupCodeRepository.StoredBackupCode("id", USER, "h", false, null, null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java index fd15a97..5e6aa1e 100644 --- a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java +++ b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java @@ -170,4 +170,93 @@ void jwtAccessorsWorkUnderAutoWiring() { assertThat(state.bundle.jwtIssuer()).isNotNull(); assertThat(state.bundle.jwtValidator()).isNotNull(); } + + // -- Authenticated admin endpoints (exercise every PkAuthAdminResource handler) ----------- + + private String bearerFor(String username) { + com.codeheadsystems.pkauth.api.UserHandle uh = + state.everything.users.getOrCreateHandle(username); + return state + .bundle + .jwtIssuer() + .issue(com.codeheadsystems.pkauth.jwt.JwtClaims.forBackupCode(uh, java.util.List.of("t"))); + } + + private jakarta.ws.rs.client.Invocation.Builder authed(String path, String username) { + return client + .target(baseUrl + path) + .request(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + bearerFor(username)); + } + + @Test + void listCredentialsReturnsOkForAuthenticatedUser() { + try (Response r = authed("/auth/admin/credentials", "dw-list").get()) { + assertThat(r.getStatus()).isEqualTo(200); + } + } + + @Test + void remainingBackupCodesCountReturnsOk() { + try (Response r = authed("/auth/admin/backup-codes/count", "dw-count").get()) { + assertThat(r.getStatus()).isEqualTo(200); + } + } + + @Test + void regenerateBackupCodesReturnsOk() { + try (Response r = + authed("/auth/admin/backup-codes/regenerate", "dw-regen") + .post(jakarta.ws.rs.client.Entity.json("{}"))) { + assertThat(r.getStatus()).isEqualTo(200); + } + } + + @Test + void deleteUnknownCredentialReturnsNotFound() { + String id = com.codeheadsystems.pkauth.json.Base64Url.encode(new byte[] {4, 5, 6}); + try (Response r = authed("/auth/admin/credentials/" + id, "dw-del").delete()) { + assertThat(r.getStatus()).isEqualTo(404); + } + } + + @Test + void startEmailVerificationReturnsNoContent() { + try (Response r = + authed("/auth/admin/email/start-verification", "dw-email") + .post(jakarta.ws.rs.client.Entity.json("{\"email\":\"a@example.com\"}"))) { + assertThat(r.getStatus()).isEqualTo(204); + } + } + + @Test + void finishEmailVerificationUnauthenticatedRejectsBadToken() { + try (Response r = + client + .target(baseUrl + "/auth/admin/email/complete-verification") + .request(MediaType.APPLICATION_JSON) + .post(jakarta.ws.rs.client.Entity.json("{\"token\":\"not.a.jwt\"}"))) { + assertThat(r.getStatus()).isEqualTo(400); + } + } + + @Test + void startPhoneVerificationReturnsOk() { + try (Response r = + authed("/auth/admin/phone/start-verification", "dw-phone") + .post(jakarta.ws.rs.client.Entity.json("{\"phone\":\"+15551230000\"}"))) { + assertThat(r.getStatus()).isEqualTo(200); + } + } + + @Test + void finishPhoneVerificationWithNoActiveOtpReturnsOk() { + try (Response r = + authed("/auth/admin/phone/complete-verification", "dw-phone-finish") + .post( + jakarta.ws.rs.client.Entity.json( + "{\"phone\":\"+15559990000\",\"code\":\"000000\"}"))) { + assertThat(r.getStatus()).isEqualTo(200); + } + } } diff --git a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java new file mode 100644 index 0000000..8b14afd --- /dev/null +++ b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.dropwizard.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.codeheadsystems.pkauth.admin.AdminRequests.RenameCredential; +import com.codeheadsystems.pkauth.admin.AdminResult; +import com.codeheadsystems.pkauth.admin.AdminService; +import com.codeheadsystems.pkauth.admin.CredentialSummary; +import com.codeheadsystems.pkauth.api.CredentialId; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.dropwizard.auth.PkAuthPasskeyPrincipal; +import com.codeheadsystems.pkauth.json.Base64Url; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +/** + * Direct unit coverage of {@link PkAuthAdminResource#renameCredential}, whose {@code body == null ? + * null : body.label()} branch is awkward to reach over HTTP (PATCH isn't supported by the default + * JAX-RS client connector). + */ +class PkAuthAdminResourceTest { + + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final String CRED_B64 = Base64Url.encode(new byte[] {9, 9, 9}); + private final AdminService adminService = mock(AdminService.class); + private final PkAuthAdminResource resource = new PkAuthAdminResource(adminService); + private final PkAuthPasskeyPrincipal principal = new PkAuthPasskeyPrincipal(USER, "jti-1"); + + @Test + void renameWithBodyPassesLabelThrough() { + when(adminService.renameCredential(eq(USER), eq(USER), any(CredentialId.class), eq("Laptop"))) + .thenReturn(new AdminResult.Success<>(mock(CredentialSummary.class))); + try (Response r = + resource.renameCredential(principal, CRED_B64, new RenameCredential("Laptop"))) { + assertThat(r.getStatus()).isEqualTo(200); + } + } + + @Test + void renameWithNullBodyPassesNullLabel() { + // The null-body branch forwards a null label, which the service rejects as ValidationFailed. + when(adminService.renameCredential(eq(USER), eq(USER), any(CredentialId.class), eq(null))) + .thenReturn(new AdminResult.ValidationFailed<>("label must be non-blank")); + try (Response r = resource.renameCredential(principal, CRED_B64, null)) { + assertThat(r.getStatus()).isEqualTo(400); + } + } + + @Test + void constructorRejectsNullService() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> new PkAuthAdminResource(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtClaimsTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtClaimsTest.java new file mode 100644 index 0000000..2703e9c --- /dev/null +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtClaimsTest.java @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Compact-constructor guards, defensive copies, factories, and value semantics of {@link + * JwtClaims}. + */ +class JwtClaimsTest { + + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + + @Test + void factoriesSetExpectedMethodAndAudience() { + assertThat(JwtClaims.forPasskey(USER, new byte[] {9}, List.of("u")).method()) + .isEqualTo(AuthMethod.PASSKEY); + assertThat(JwtClaims.forBackupCode(USER, List.of("u")).method()) + .isEqualTo(AuthMethod.BACKUP_CODE); + assertThat(JwtClaims.forMagicLink(USER, List.of("u")).method()) + .isEqualTo(AuthMethod.MAGIC_LINK); + assertThat(JwtClaims.forRefresh(USER, "web", List.of("u")).method()) + .isEqualTo(AuthMethod.REFRESH); + + assertThat(JwtClaims.forPasskey(USER, new byte[] {9}, "web", List.of("u")).audience()) + .isEqualTo("web"); + assertThat(JwtClaims.forBackupCode(USER, "cli", List.of("u")).audience()).isEqualTo("cli"); + assertThat(JwtClaims.forMagicLink(USER, "cli", List.of("u")).audience()).isEqualTo("cli"); + } + + @Test + void fiveArgConstructorDefaultsAudienceToNull() { + JwtClaims claims = new JwtClaims(USER, AuthMethod.BACKUP_CODE, null, List.of("u"), null); + assertThat(claims.audience()).isNull(); + } + + @Test + void rejectsBlankAudience() { + assertThatThrownBy( + () -> new JwtClaims(USER, AuthMethod.BACKUP_CODE, null, List.of("u"), null, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("audience"); + } + + @Test + void rejectsNullRequiredFields() { + assertThatThrownBy(() -> new JwtClaims(null, AuthMethod.BACKUP_CODE, null, List.of(), null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new JwtClaims(USER, null, null, List.of(), null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new JwtClaims(USER, AuthMethod.BACKUP_CODE, null, null, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void credentialIdAccessorAndStoredCopyAreDefensive() { + byte[] cred = {4, 5, 6}; + JwtClaims claims = JwtClaims.forPasskey(USER, cred, List.of("u")); + cred[0] = 0; // mutate source after construction + assertThat(claims.credentialId()).containsExactly(4, 5, 6); + byte[] out = claims.credentialId(); + out[0] = 0; // mutate the returned array + assertThat(claims.credentialId()).containsExactly(4, 5, 6); + } + + @Test + void nonPasskeyCredentialIdAccessorReturnsNull() { + assertThat(JwtClaims.forBackupCode(USER, List.of("u")).credentialId()).isNull(); + } + + @Test + void additionalClaimsAreCopiedDefensively() { + Map extra = new HashMap<>(); + extra.put("tenant", "acme"); + JwtClaims claims = + new JwtClaims(USER, AuthMethod.BACKUP_CODE, null, List.of("u"), extra, "web"); + extra.put("tenant", "tampered"); + assertThat(claims.additionalClaims()).containsEntry("tenant", "acme"); + } + + @Test + void equalsAndHashCodeReflectEveryField() { + JwtClaims a = JwtClaims.forPasskey(USER, new byte[] {1}, "web", List.of("u")); + JwtClaims b = JwtClaims.forPasskey(USER, new byte[] {1}, "web", List.of("u")); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(null).isNotEqualTo("nope"); + + // Differ by credentialId content (Arrays.equals path). + assertThat(a).isNotEqualTo(JwtClaims.forPasskey(USER, new byte[] {2}, "web", List.of("u"))); + // Differ by method. + assertThat(JwtClaims.forBackupCode(USER, "web", List.of("u"))) + .isNotEqualTo(JwtClaims.forMagicLink(USER, "web", List.of("u"))); + // Differ by audience. + assertThat(JwtClaims.forBackupCode(USER, "web", List.of("u"))) + .isNotEqualTo(JwtClaims.forBackupCode(USER, "cli", List.of("u"))); + // Differ by amr. + assertThat(JwtClaims.forBackupCode(USER, "web", List.of("u"))) + .isNotEqualTo(JwtClaims.forBackupCode(USER, "web", List.of("v"))); + } + + @Test + void toStringHexEncodesCredentialIdAndRedactsNothingSensitive() { + String withCred = + JwtClaims.forPasskey(USER, new byte[] {(byte) 0xAB, 0x01}, List.of("u")).toString(); + assertThat(withCred).contains("method=PASSKEY").contains("credentialId=ab01"); + + String withoutCred = JwtClaims.forBackupCode(USER, List.of("u")).toString(); + assertThat(withoutCred).contains("credentialId=null"); + } +} diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java new file mode 100644 index 0000000..bb9d45e --- /dev/null +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** + * Exercises the malformed / missing-claim / wrong-shape branches of {@link + * PkAuthJwtValidator#validate(String)} that the happy-path round-trip tests don't reach. Each test + * hand-builds a structurally valid, correctly signed JWT and then perturbs exactly one claim so the + * validator's defensive branch fires. + */ +class PkAuthJwtValidatorEdgeCasesTest { + + private static final Instant NOW = Instant.parse("2026-05-13T12:00:00Z"); + private static final String ISSUER = "https://pkauth.example.com"; + private static final String AUDIENCE = "api.example.com"; + // HS256 keyset built from a fixed 32-byte secret. JwtKeyset.hs256 assigns kid="current", so the + // raw tokens below must carry a matching kid header to pass the kid filter. + private static final byte[] SECRET = new byte[32]; + private static final JwtKeyset KEYSET = JwtKeyset.hs256(SECRET); + + private final PkAuthJwtValidator validator = + new PkAuthJwtValidator(JwtConfig.defaults(ISSUER, AUDIENCE), KEYSET, fixedClock(NOW)); + + @Test + void wrongAlgorithmHeaderIsInvalidSignature() { + // A perfectly good HS256 token offered to an ES256-only validator: the alg check trips before + // any key is tried. + JwtKeyset es = JwtKeyset.es256(generateEcKeyset()); + PkAuthJwtIssuer issuer = + new PkAuthJwtIssuer(JwtConfig.defaults(ISSUER, AUDIENCE), KEYSET, fixedClock(NOW)); + String hsToken = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), List.of("user"))); + PkAuthJwtValidator esValidator = + new PkAuthJwtValidator(JwtConfig.defaults(ISSUER, AUDIENCE), es, fixedClock(NOW)); + assertThat(esValidator.validate(hsToken)) + .isInstanceOf(JwtVerificationResult.InvalidSignature.class); + } + + @Test + void missingExpClaimIsMissingClaim() { + String token = signed(b -> b.expirationTime(null)); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.MissingClaim.class, r -> assertThat(r.name()).isEqualTo("exp")); + } + + @Test + void missingSubjectIsMissingClaim() { + String token = signed(b -> b.subject(null)); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.MissingClaim.class, r -> assertThat(r.name()).isEqualTo("sub")); + } + + @Test + void undecodableSubjectIsMalformed() { + // '!' is outside the base64url alphabet → Base64Url.decode throws → Malformed. + String token = signed(b -> b.subject("!!!not-base64url!!!")); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.Malformed.class, + r -> assertThat(r.detail()).contains("invalid sub")); + } + + @Test + void missingMethodClaimIsMissingClaim() { + String token = signed(b -> b.claim(PkAuthJwtIssuer.CLAIM_METHOD, null)); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.MissingClaim.class, + r -> assertThat(r.name()).isEqualTo(PkAuthJwtIssuer.CLAIM_METHOD)); + } + + @Test + void unknownMethodClaimIsMalformed() { + String token = signed(b -> b.claim(PkAuthJwtIssuer.CLAIM_METHOD, "telepathy")); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.Malformed.class, + r -> assertThat(r.detail()).contains("unknown pkauth.method")); + } + + @Test + void passkeyMethodWithoutCredentialIsMissingClaim() { + String token = signed(b -> b.claim(PkAuthJwtIssuer.CLAIM_METHOD, "passkey")); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.MissingClaim.class, + r -> assertThat(r.name()).isEqualTo(PkAuthJwtIssuer.CLAIM_CRED)); + } + + @Test + void passkeyMethodWithUndecodableCredentialIsMalformed() { + String token = + signed( + b -> + b.claim(PkAuthJwtIssuer.CLAIM_METHOD, "passkey") + .claim(PkAuthJwtIssuer.CLAIM_CRED, "!!!bad!!!")); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.Malformed.class, + r -> assertThat(r.detail()).contains("invalid pkauth.cred")); + } + + @Test + void credentialOnNonPasskeyMethodIsMalformed() { + String token = + signed( + b -> + b.claim(PkAuthJwtIssuer.CLAIM_METHOD, "backup-code") + .claim(PkAuthJwtIssuer.CLAIM_CRED, Base64Url.encode(new byte[] {1, 2}))); + assertThat(validator.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.Malformed.class, + r -> assertThat(r.detail()).contains("pkauth.cred must not appear")); + } + + @Test + void passkeyTokenWithCredentialRoundTripsCredentialId() { + byte[] credId = {7, 7, 9, 9}; + String token = + signed( + b -> + b.claim(PkAuthJwtIssuer.CLAIM_METHOD, "passkey") + .claim(PkAuthJwtIssuer.CLAIM_CRED, Base64Url.encode(credId))); + JwtVerificationResult result = validator.validate(token); + assertThat(result).isInstanceOf(JwtVerificationResult.Success.class); + JwtClaims claims = ((JwtVerificationResult.Success) result).claims(); + assertThat(claims.method()).isEqualTo(AuthMethod.PASSKEY); + assertThat(claims.credentialId()).containsExactly(credId); + } + + @Test + void absentAmrClaimBecomesEmptyList() { + String token = signed(b -> b.claim(PkAuthJwtIssuer.CLAIM_AMR, null)); + JwtVerificationResult result = validator.validate(token); + assertThat(result).isInstanceOf(JwtVerificationResult.Success.class); + assertThat(((JwtVerificationResult.Success) result).claims().amr()).isEmpty(); + } + + @Test + void unknownExtraClaimsSurviveAsAdditionalClaims() { + String token = signed(b -> b.claim("tenant", "acme").claim("plan", "pro")); + JwtVerificationResult result = validator.validate(token); + assertThat(result).isInstanceOf(JwtVerificationResult.Success.class); + assertThat(((JwtVerificationResult.Success) result).claims().additionalClaims()) + .containsEntry("tenant", "acme") + .containsEntry("plan", "pro"); + } + + @Test + void statefulStoreRejectsTokenWithoutJtiAsMissingClaim() { + // A stateful store (non-noop) whose exists() always returns true — so the only way to reach a + // failure here is the jti==null guard, not a store miss. + AccessTokenStore store = + new AccessTokenStore() { + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + java.util.Optional deviceId, + Instant issuedAt, + Instant expiresAt) {} + + @Override + public boolean exists(String jti) { + return true; + } + + @Override + public boolean delete(UserHandle userHandle, String jti) { + return false; + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + return 0; + } + + @Override + public int deleteExpiredBefore(Instant before) { + return 0; + } + }; + PkAuthJwtValidator stateful = + new PkAuthJwtValidator( + JwtConfig.defaults(ISSUER, AUDIENCE), + KEYSET, + fixedClock(NOW), + RevocationCheck.allow(), + store); + String token = signed(b -> b.jwtID(null)); + assertThat(stateful.validate(token)) + .isInstanceOfSatisfying( + JwtVerificationResult.MissingClaim.class, r -> assertThat(r.name()).isEqualTo("jti")); + } + + // -- helpers ------------------------------------------------------------------------------ + + /** + * Builds a fully valid backup-code JWT (kid="current", correct iss/aud/sub/exp/method/amr) then + * applies {@code customizer} so a single test can null out or override one claim. + */ + private static String signed(Consumer customizer) { + JWTClaimsSet.Builder builder = + new JWTClaimsSet.Builder() + .issuer(ISSUER) + .audience(AUDIENCE) + .subject(Base64Url.encode(new byte[] {1, 2, 3})) + .issueTime(Date.from(NOW)) + .notBeforeTime(Date.from(NOW)) + .expirationTime(Date.from(NOW.plus(Duration.ofMinutes(15)))) + .jwtID(UUID.randomUUID().toString()) + .claim(PkAuthJwtIssuer.CLAIM_METHOD, "backup-code") + .claim(PkAuthJwtIssuer.CLAIM_AMR, List.of("user")); + customizer.accept(builder); + try { + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256).keyID("current").build(); + SignedJWT jwt = new SignedJWT(header, builder.build()); + jwt.sign(new MACSigner(SECRET)); + return jwt.serialize(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static com.nimbusds.jose.jwk.ECKey generateEcKeyset() { + try { + return new com.nimbusds.jose.jwk.gen.ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256) + .keyID("ec-1") + .generate(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static ClockProvider fixedClock(Instant instant) { + return ClockProvider.fromClock(Clock.fixed(instant, ZoneOffset.UTC)); + } +} diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java new file mode 100644 index 0000000..f6bc79b --- /dev/null +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Validation and dispatch for both built-in {@link TokenTtlPolicy} factories. */ +class TokenTtlPolicyTest { + + @Test + void singleAppliesSameTtlAndKnowsNoAudiences() { + TokenTtlPolicy policy = TokenTtlPolicy.single(Duration.ofMinutes(30)); + assertThat(policy.accessTtl("web")).isEqualTo(Duration.ofMinutes(30)); + assertThat(policy.accessTtl("anything")).isEqualTo(Duration.ofMinutes(30)); + assertThat(policy.knownAudiences()).isEmpty(); + assertThat(policy.toString()).contains("single"); + } + + @Test + void singleRejectsNullZeroNegative() { + assertThatThrownBy(() -> TokenTtlPolicy.single(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> TokenTtlPolicy.single(Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> TokenTtlPolicy.single(Duration.ofMinutes(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fixedDispatchesWithDefaultFallbackAndExposesKnownAudiences() { + TokenTtlPolicy policy = + TokenTtlPolicy.fixed( + Duration.ofHours(1), Map.of("web", Duration.ofMinutes(15), "cli", Duration.ofHours(8))); + assertThat(policy.accessTtl("web")).isEqualTo(Duration.ofMinutes(15)); + assertThat(policy.accessTtl("cli")).isEqualTo(Duration.ofHours(8)); + assertThat(policy.accessTtl("unmapped")).isEqualTo(Duration.ofHours(1)); + assertThat(policy.knownAudiences()).containsExactlyInAnyOrder("web", "cli"); + assertThat(policy.toString()).contains("fixed"); + } + + @Test + void fixedRejectsNullArgumentsAndNonPositiveDurations() { + assertThatThrownBy(() -> TokenTtlPolicy.fixed(null, Map.of())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> TokenTtlPolicy.fixed(Duration.ofHours(1), null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> TokenTtlPolicy.fixed(Duration.ZERO, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("defaultTtl"); + assertThatThrownBy( + () -> TokenTtlPolicy.fixed(Duration.ofHours(1), Map.of("web", Duration.ZERO))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("web"); + } + + @Test + void fixedRejectsNullKeyOrValue() { + Map nullValue = new HashMap<>(); + nullValue.put("web", null); + assertThatThrownBy(() -> TokenTtlPolicy.fixed(Duration.ofHours(1), nullValue)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java new file mode 100644 index 0000000..e08a8a3 --- /dev/null +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.micronaut; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.jwt.JwtClaims; +import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; +import com.codeheadsystems.pkauth.spi.UserLookup; +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Drives the authenticated admin endpoints the smoke tests don't reach, covering the {@code actor + * != null} happy branch and the non-null request-body path of each handler. + */ +@MicronautTest +@Property(name = "pkauth.relying-party.id", value = "example.com") +@Property(name = "pkauth.relying-party.name", value = "test") +@Property(name = "pkauth.relying-party.origins[0]", value = "https://example.com") +@Property(name = "pkauth.jwt.issuer", value = "https://pkauth.example.com") +@Property(name = "pkauth.jwt.audience", value = "https://app.example.com") +@Property(name = "pkauth.jwt.secret", value = "pk-auth-micronaut-test-secret-32b!") +@Property(name = "pkauth.dev-mode", value = "true") +class PkAuthAdminEndpointsCoverageTest { + + @Inject + @Client("/") + HttpClient client; + + @Inject PkAuthJwtIssuer issuer; + @Inject UserLookup users; + + private String tokenFor(String username) { + UserHandle uh = users.getOrCreateHandle(username); + return issuer.issue(JwtClaims.forBackupCode(uh, List.of("test"))); + } + + private int status(HttpRequest req) { + HttpResponse resp = client.toBlocking().exchange(req, String.class); + return resp.getStatus().getCode(); + } + + @Test + void listCredentialsReturnsOkEmptyArrayForFreshUser() { + HttpResponse resp = + client + .toBlocking() + .exchange( + HttpRequest.GET("/auth/admin/credentials").bearerAuth(tokenFor("list-user")), + String.class); + Assertions.assertThat(resp.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + Assertions.assertThat(resp.body()).startsWith("["); + } + + @Test + void remainingBackupCodesCountReturnsOk() { + Assertions.assertThat( + status( + HttpRequest.GET("/auth/admin/backup-codes/count") + .bearerAuth(tokenFor("count-user")))) + .isEqualTo(HttpStatus.OK.getCode()); + } + + @Test + void startEmailVerificationWithValidEmailReturnsNoContent() { + // Privacy invariant: a successful (or email-mismatch) send returns 204 with no body so a + // caller can't probe which address is bound to the user. + Assertions.assertThat( + status( + HttpRequest.POST( + "/auth/admin/email/start-verification", "{\"email\":\"alice@example.com\"}") + .bearerAuth(tokenFor("email-user")) + .contentType(MediaType.APPLICATION_JSON))) + .isEqualTo(HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void finishPhoneVerificationWithNoActiveOtpReturnsOkExpired() { + HttpResponse resp = + client + .toBlocking() + .exchange( + HttpRequest.POST( + "/auth/admin/phone/complete-verification", + "{\"phone\":\"+15557654321\",\"code\":\"000000\"}") + .bearerAuth(tokenFor("phone-finish-user")) + .contentType(MediaType.APPLICATION_JSON), + String.class); + Assertions.assertThat(resp.getStatus().getCode()).isEqualTo(HttpStatus.OK.getCode()); + } + + @Test + void renameUnknownCredentialReturnsNotFound() { + HttpClientResponseException ex = + org.junit.jupiter.api.Assertions.assertThrows( + HttpClientResponseException.class, + () -> + client + .toBlocking() + .exchange( + HttpRequest.PATCH( + "/auth/admin/credentials/" + Base64Url.encode(new byte[] {1, 2, 3}), + "{\"label\":\"Renamed\"}") + .bearerAuth(tokenFor("rename-user")) + .contentType(MediaType.APPLICATION_JSON))); + Assertions.assertThat(ex.getStatus().getCode()).isEqualTo(HttpStatus.NOT_FOUND.getCode()); + } + + @Test + void renameWithBlankLabelReturnsBadRequest() { + HttpClientResponseException ex = + org.junit.jupiter.api.Assertions.assertThrows( + HttpClientResponseException.class, + () -> + client + .toBlocking() + .exchange( + HttpRequest.PATCH( + "/auth/admin/credentials/" + Base64Url.encode(new byte[] {4, 5, 6}), + "{\"label\":\" \"}") + .bearerAuth(tokenFor("rename-blank-user")) + .contentType(MediaType.APPLICATION_JSON))); + Assertions.assertThat(ex.getStatus().getCode()).isEqualTo(HttpStatus.BAD_REQUEST.getCode()); + } + + @Test + void deleteUnknownCredentialReturnsNotFound() { + HttpClientResponseException ex = + org.junit.jupiter.api.Assertions.assertThrows( + HttpClientResponseException.class, + () -> + client + .toBlocking() + .exchange( + HttpRequest.DELETE( + "/auth/admin/credentials/" + Base64Url.encode(new byte[] {7, 8, 9})) + .bearerAuth(tokenFor("delete-user")))); + Assertions.assertThat(ex.getStatus().getCode()).isEqualTo(HttpStatus.NOT_FOUND.getCode()); + } +} diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactoryConfigTest.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactoryConfigTest.java new file mode 100644 index 0000000..2b6ead5 --- /dev/null +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactoryConfigTest.java @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.micronaut; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.jwt.JwtConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit-tests the config-building {@link PkAuthFactory} bean methods directly, covering the + * validation and TTL-defaulting branches that the integration context (which only ever supplies one + * valid configuration) cannot reach. + */ +class PkAuthFactoryConfigTest { + + private final PkAuthFactory factory = new PkAuthFactory(); + + private static PkAuthConfiguration config() { + PkAuthConfiguration c = new PkAuthConfiguration(); + PkAuthConfiguration.RelyingParty rp = new PkAuthConfiguration.RelyingParty(); + rp.setId("example.com"); + rp.setName("Example"); + rp.setOrigins(List.of("https://example.com")); + c.setRelyingParty(rp); + PkAuthConfiguration.Jwt jwt = new PkAuthConfiguration.Jwt(); + jwt.setIssuer("https://pkauth.example.com"); + jwt.setAudience("https://app.example.com"); + jwt.setSecret("pk-auth-micronaut-test-secret-32b!"); + c.setJwt(jwt); + PkAuthConfiguration.Ceremony ceremony = new PkAuthConfiguration.Ceremony(); + ceremony.setChallengeTtl(Duration.ofMinutes(5)); + c.setCeremony(ceremony); + c.setRefresh(new PkAuthConfiguration.Refresh()); + return c; + } + + // -- relyingPartyConfig ------------------------------------------------------------------- + + @Test + void relyingPartyConfigBuildsFromValidConfig() { + RelyingPartyConfig rp = factory.relyingPartyConfig(config()); + assertThat(rp.id()).isEqualTo("example.com"); + assertThat(rp.origins()).containsExactly("https://example.com"); + } + + @Test + void relyingPartyConfigRejectsBlankId() { + PkAuthConfiguration c = config(); + c.getRelyingParty().setId(" "); + assertThatThrownBy(() -> factory.relyingPartyConfig(c)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("relying-party"); + } + + @Test + void relyingPartyConfigRejectsNullOrigins() { + PkAuthConfiguration c = config(); + c.getRelyingParty().setOrigins(null); + assertThatThrownBy(() -> factory.relyingPartyConfig(c)) + .isInstanceOf(IllegalStateException.class); + } + + // -- jwtConfig ---------------------------------------------------------------------------- + + @Test + void jwtConfigDefaultsTtlAndUsesSinglePolicyWhenNoOverrides() { + JwtConfig cfg = factory.jwtConfig(config()); + assertThat(cfg.issuer()).isEqualTo("https://pkauth.example.com"); + // No overrides → single-policy → only the default audience is allowed. + assertThat(cfg.allowedAudiences()).containsExactly("https://app.example.com"); + } + + @Test + void jwtConfigUsesFixedPolicyWhenOverridesPresent() { + PkAuthConfiguration c = config(); + c.getJwt().setDefaultTtl(Duration.ofMinutes(30)); + c.getJwt().setTtlsByAudience(Map.of("cli", Duration.ofHours(8))); + JwtConfig cfg = factory.jwtConfig(c); + assertThat(cfg.allowedAudiences()).contains("https://app.example.com", "cli"); + } + + @Test + void jwtConfigRejectsBlankIssuerOrAudience() { + PkAuthConfiguration blankIssuer = config(); + blankIssuer.getJwt().setIssuer(" "); + assertThatThrownBy(() -> factory.jwtConfig(blankIssuer)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("issuer"); + + PkAuthConfiguration nullAud = config(); + nullAud.getJwt().setAudience(null); + assertThatThrownBy(() -> factory.jwtConfig(nullAud)).isInstanceOf(IllegalStateException.class); + } + + // -- ceremonyConfig / refreshTokenConfig -------------------------------------------------- + + @Test + void ceremonyConfigCarriesChallengeTtlAndDefaultsTheRest() { + var cc = factory.ceremonyConfig(config()); + assertThat(cc.challengeTtl()).isEqualTo(Duration.ofMinutes(5)); + assertThat(cc.userVerification()).isNotNull(); + } + + @Test + void refreshTokenConfigDefaultsWhenUnset() { + RefreshTokenConfig rc = factory.refreshTokenConfig(config()); + assertThat(rc.ttlPolicy().refreshTtl("any")).isEqualTo(RefreshTokenConfig.DEFAULT_REFRESH_TTL); + assertThat(rc.cleanupRetention()).isEqualTo(RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION); + } + + @Test + void refreshTokenConfigUsesFixedPolicyAndExplicitRetentionWhenSet() { + PkAuthConfiguration c = config(); + c.getRefresh().setDefaultTtl(Duration.ofDays(7)); + c.getRefresh().setTtlsByAudience(Map.of("cli", Duration.ofDays(90))); + c.getRefresh().setCleanupRetention(Duration.ofDays(10)); + RefreshTokenConfig rc = factory.refreshTokenConfig(c); + assertThat(rc.ttlPolicy().refreshTtl("cli")).isEqualTo(Duration.ofDays(90)); + assertThat(rc.ttlPolicy().refreshTtl("other")).isEqualTo(Duration.ofDays(7)); + assertThat(rc.cleanupRetention()).isEqualTo(Duration.ofDays(10)); + } +} diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandlerTest.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandlerTest.java new file mode 100644 index 0000000..e12100e --- /dev/null +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandlerTest.java @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.micronaut; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Maps {@link PkAuthPersistenceException} to a stable 503 JSON body. */ +class PkAuthPersistenceExceptionHandlerTest { + + private final PkAuthPersistenceExceptionHandler handler = new PkAuthPersistenceExceptionHandler(); + + @Test + void mapsToServiceUnavailableWithOperationTag() { + HttpResponse response = + handler.handle( + mock(HttpRequest.class), + new PkAuthPersistenceException("credentials.save", "db unreachable")); + + assertThat(response.getStatus().getCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE.getCode()); + @SuppressWarnings("unchecked") + Map body = (Map) response.body(); + assertThat(body) + .containsEntry("error", "persistence_failure") + .containsEntry("operation", "credentials.save"); + } +} diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAltFlowsIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAltFlowsIntegrationTest.java index afdcafd..256b04a 100644 --- a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAltFlowsIntegrationTest.java +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAltFlowsIntegrationTest.java @@ -52,6 +52,48 @@ void backupCodeRoundTrip() { assertThat(backupCodes.findByUserHandle(user)).isEmpty(); } + @Test + void backupCodeReplaceAllSwapsTheEntireCodeSet() { + UserHandle user = UserHandle.random(); + Instant now = Instant.parse("2026-05-14T12:00:00Z"); + backupCodes.save( + new BackupCodeRepository.StoredBackupCode("old1", user, "h1", false, now, null)); + backupCodes.save( + new BackupCodeRepository.StoredBackupCode("old2", user, "h2", false, now, null)); + + backupCodes.replaceAll( + user, + java.util.List.of( + new BackupCodeRepository.StoredBackupCode("new1", user, "n1", false, now, null), + new BackupCodeRepository.StoredBackupCode("new2", user, "n2", false, now, null), + new BackupCodeRepository.StoredBackupCode("new3", user, "n3", false, now, null))); + + assertThat(backupCodes.findByUserHandle(user)) + .extracting(BackupCodeRepository.StoredBackupCode::codeId) + .containsExactlyInAnyOrder("new1", "new2", "new3"); + } + + @Test + void otpDeleteByUserHandleRemovesEveryRowForTheUser() { + UserHandle user = UserHandle.random(); + UserHandle other = UserHandle.random(); + Instant t0 = Instant.parse("2026-05-14T12:00:00Z"); + otp.save( + new OtpRepository.StoredOtp( + "d1", user, "+15551110000", "h", 0, 5, false, t0, t0.plusSeconds(300))); + otp.save( + new OtpRepository.StoredOtp( + "d2", user, "+15551110000", "h", 0, 5, false, t0, t0.plusSeconds(300))); + otp.save( + new OtpRepository.StoredOtp( + "k1", other, "+15552220000", "h", 0, 5, false, t0, t0.plusSeconds(300))); + + int removed = otp.deleteByUserHandle(user); + assertThat(removed).isEqualTo(2); + assertThat(otp.findLatestActive(user, "+15551110000")).isEmpty(); + assertThat(otp.findLatestActive(other, "+15552220000")).isPresent(); + } + @Test void otpRoundTripAndCountSince() { UserHandle user = UserHandle.random(); diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java new file mode 100644 index 0000000..44ce988 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.CredentialId; +import com.codeheadsystems.pkauth.api.Transport; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.credential.CredentialRecord; +import java.time.Instant; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Direct CRUD against {@link DynamoDbCredentialRepository}, covering the mutation paths ({@code + * updateSignCount}, {@code updateLabel}, {@code delete}, {@code deleteByUserHandle}) the shared + * ceremony scenarios don't exercise — including the clone-detection counter guard and the + * ownership-mismatch no-ops. + */ +@Testcontainers +@DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") +class DynamoDbCredentialRepositoryIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-05-14T12:00:00Z"); + + private DynamoDbCredentialRepository repository; + + @BeforeEach + void setUp() { + var client = DynamoDbLocalFixture.client(); + var enhanced = DynamoDbLocalFixture.enhanced(); + String suffix = UUID.randomUUID().toString().substring(0, 8); + PkAuthDynamoTables tables = + new PkAuthDynamoTables("PkAuthCore_" + suffix, "PkAuthUsers_" + suffix); + new DynamoDbSchemaBootstrapper(client, tables).bootstrap(); + repository = new DynamoDbCredentialRepository(enhanced, tables); + } + + private static CredentialRecord cred( + CredentialId id, UserHandle user, long signCount, String label) { + return new CredentialRecord( + id, + user, + new byte[] {1, 2, 3, 4}, // repo round-trips raw COSE bytes; content isn't parsed here + signCount, + label, + null, + Set.of(Transport.USB), + true, + false, + NOW, + null); + } + + private static CredentialId credId(byte... bytes) { + return CredentialId.of(bytes); + } + + @Test + void saveFindAndListRoundTrip() { + UserHandle user = UserHandle.random(); + CredentialId id = credId((byte) 1, (byte) 2, (byte) 3); + repository.save(cred(id, user, 0L, "Laptop")); + + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying( + r -> { + assertThat(r.label()).isEqualTo("Laptop"); + assertThat(r.transports()).contains(Transport.USB); + }); + assertThat(repository.findByUserHandle(user)).hasSize(1); + assertThat(repository.findByCredentialId(credId((byte) 9))).isEmpty(); + } + + @Test + void updateSignCountAdvancesCounter() { + UserHandle user = UserHandle.random(); + CredentialId id = credId((byte) 4, (byte) 5); + repository.save(cred(id, user, 0L, "k")); + + repository.updateSignCount(id, 42L, NOW.plusSeconds(60)); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.signCount()).isEqualTo(42L)); + } + + @Test + void updateSignCountRejectsRegressionAndIsNoOpForUnknown() { + UserHandle user = UserHandle.random(); + CredentialId id = credId((byte) 6, (byte) 7); + repository.save(cred(id, user, 10L, "k")); + + // Counter regression: trying to lower the stored count must be refused (clone defence). + repository.updateSignCount(id, 5L, NOW.plusSeconds(60)); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.signCount()).isEqualTo(10L)); + + // Unknown credential is a silent no-op. + repository.updateSignCount(credId((byte) 99), 1L, NOW); + } + + @Test + void updateLabelChangesLabelButHonoursOwnership() { + UserHandle owner = UserHandle.random(); + UserHandle stranger = UserHandle.random(); + CredentialId id = credId((byte) 8, (byte) 9); + repository.save(cred(id, owner, 0L, "Old")); + + // Wrong owner → silent no-op. + repository.updateLabel(stranger, id, "Hacked"); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.label()).isEqualTo("Old")); + + // Correct owner → renamed. + repository.updateLabel(owner, id, "New"); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.label()).isEqualTo("New")); + + // Unknown credential → no-op (no throw). + repository.updateLabel(owner, credId((byte) 70), "X"); + } + + @Test + void deleteHonoursOwnershipAndRemovesRow() { + UserHandle owner = UserHandle.random(); + UserHandle stranger = UserHandle.random(); + CredentialId id = credId((byte) 10, (byte) 11); + repository.save(cred(id, owner, 0L, "k")); + + // Wrong owner → not deleted. + repository.delete(stranger, id); + assertThat(repository.findByCredentialId(id)).isPresent(); + + // Correct owner → deleted. + repository.delete(owner, id); + assertThat(repository.findByCredentialId(id)).isEmpty(); + + // Unknown credential → no-op. + repository.delete(owner, credId((byte) 71)); + } + + @Test + void deleteByUserHandleRemovesAllOfTheUsersCredentials() { + UserHandle user = UserHandle.random(); + UserHandle other = UserHandle.random(); + repository.save(cred(credId((byte) 20), user, 0L, "a")); + repository.save(cred(credId((byte) 21), user, 0L, "b")); + repository.save(cred(credId((byte) 22), other, 0L, "c")); + + int removed = repository.deleteByUserHandle(user); + assertThat(removed).isEqualTo(2); + assertThat(repository.findByUserHandle(user)).isEmpty(); + assertThat(repository.findByUserHandle(other)).hasSize(1); + } +} diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java index 2a037d6..26fa18c 100644 --- a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java @@ -92,6 +92,56 @@ void nativeTtlExtendsExpiryByCleanupRetention() { .isEqualTo(expiresAt.plus(retention).getEpochSecond()); } + @Test + void deleteExpiredBeforeRemovesRevokedExpiredRowsButKeepsFresh() { + UserHandle user = UserHandle.of(new byte[] {1, 2, 3, 4}); + + // An expired + revoked row that is past the cutoff on both axes — eligible for cleanup. + String staleId = "stale-" + UUID.randomUUID(); + repository.create( + new RefreshTokenRecord( + staleId, + new byte[32], + user, + "web", + Optional.empty(), + staleId, + Optional.empty(), + Instant.parse("2020-01-01T00:00:00Z"), + Instant.parse("2020-02-01T00:00:00Z"), // expiresAt in the past + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("pkauth"))); + repository.revokeFamily( + staleId, + Instant.parse("2020-02-02T00:00:00Z"), + com.codeheadsystems.pkauth.refresh.RevokeReason.ADMIN); + + // A still-valid row well after the cutoff — must survive cleanup. + String freshId = "fresh-" + UUID.randomUUID(); + repository.create( + new RefreshTokenRecord( + freshId, + new byte[32], + user, + "web", + Optional.empty(), + freshId, + Optional.empty(), + Instant.parse("2030-01-01T00:00:00Z"), + Instant.parse("2030-02-01T00:00:00Z"), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("pkauth"))); + + int removed = repository.deleteExpiredBefore(Instant.parse("2021-01-01T00:00:00Z")); + assertThat(removed).isGreaterThanOrEqualTo(1); + assertThat(repository.findByRefreshId(staleId)).isEmpty(); + assertThat(repository.findByRefreshId(freshId)).isPresent(); + } + @Test void issueRotateRevokeHappyPath() { new RefreshTokenScenarios(repository).issueRotateRevokeHappyPath(); diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java new file mode 100644 index 0000000..2b420b5 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.UserLookup.UserView; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Covers {@link DynamoDbUserLookup}'s registration / lookup surface — {@code getOrCreateHandle} + * (create + idempotent re-fetch), {@code findHandleByUsername}, {@code register}, and {@code + * findViewByHandle} — which the ceremony scenarios only touch via {@code getOrCreateHandle}. + */ +@Testcontainers +@DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") +class DynamoDbUserLookupIntegrationTest { + + private DynamoDbUserLookup users; + + @BeforeEach + void setUp() { + var enhanced = DynamoDbLocalFixture.enhanced(); + var client = DynamoDbLocalFixture.client(); + String suffix = UUID.randomUUID().toString().substring(0, 8); + PkAuthDynamoTables tables = + new PkAuthDynamoTables("PkAuthCore_" + suffix, "PkAuthUsers_" + suffix); + new DynamoDbSchemaBootstrapper(client, tables).bootstrap(); + users = new DynamoDbUserLookup(enhanced, tables); + } + + @Test + void getOrCreateHandleIsIdempotentForSameUsername() { + UserHandle first = users.getOrCreateHandle("alice"); + UserHandle second = users.getOrCreateHandle("alice"); + assertThat(second).isEqualTo(first); + } + + @Test + void findHandleByUsernameReflectsCreationAndMissesUnknown() { + UserHandle handle = users.getOrCreateHandle("bob"); + assertThat(users.findHandleByUsername("bob")).hasValue(handle); + assertThat(users.findHandleByUsername("nobody")).isEmpty(); + } + + @Test + void findViewByHandleReturnsUsernameForKnownAndEmptyForUnknown() { + UserHandle handle = users.getOrCreateHandle("carol"); + Optional view = users.findViewByHandle(handle); + assertThat(view) + .hasValueSatisfying( + v -> { + assertThat(v.handle()).isEqualTo(handle); + assertThat(v.username()).isEqualTo("carol"); + }); + assertThat(users.findViewByHandle(UserHandle.random())).isEmpty(); + } + + @Test + void registerPersistsUsernameAndDisplayName() { + UserHandle handle = users.register("dave", "Dave Display"); + assertThat(users.findHandleByUsername("dave")).hasValue(handle); + assertThat(users.findViewByHandle(handle)) + .hasValueSatisfying( + v -> { + assertThat(v.username()).isEqualTo("dave"); + assertThat(v.displayName()).isEqualTo("Dave Display"); + }); + } +} diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAltFlowsIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAltFlowsIntegrationTest.java index 3e4d92d..1dd9bb0 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAltFlowsIntegrationTest.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAltFlowsIntegrationTest.java @@ -52,6 +52,57 @@ void backupCodeRoundTrip() { assertThat(backupCodes.findByUserHandle(user)).isEmpty(); } + @Test + void backupCodeReplaceAllSwapsTheEntireCodeSet() { + UserHandle user = UserHandle.random(); + Instant now = Instant.parse("2026-05-14T12:00:00Z"); + backupCodes.save( + new BackupCodeRepository.StoredBackupCode("old1", user, "h1", false, now, null)); + backupCodes.save( + new BackupCodeRepository.StoredBackupCode("old2", user, "h2", false, now, null)); + + backupCodes.replaceAll( + user, + java.util.List.of( + new BackupCodeRepository.StoredBackupCode("new1", user, "n1", false, now, null), + new BackupCodeRepository.StoredBackupCode("new2", user, "n2", false, now, null), + new BackupCodeRepository.StoredBackupCode("new3", user, "n3", false, now, null))); + + assertThat(backupCodes.findByUserHandle(user)) + .extracting(BackupCodeRepository.StoredBackupCode::codeId) + .containsExactlyInAnyOrder("new1", "new2", "new3"); + } + + @Test + void recordVerifyFailureWritesAuditEventWithoutAffectingActiveCodes() { + UserHandle user = UserHandle.random(); + Instant now = Instant.parse("2026-05-14T12:00:00Z"); + backupCodes.save(new BackupCodeRepository.StoredBackupCode("c1", user, "h1", false, now, null)); + + // Records an audit row; must not consume or revoke the active code. + backupCodes.recordVerifyFailure("c1", user); + + assertThat(backupCodes.findByUserHandle(user)).hasSize(1); + } + + @Test + void otpDeleteByUserHandleRemovesEveryRowForTheUser() { + UserHandle user = UserHandle.random(); + UserHandle other = UserHandle.random(); + Instant t0 = Instant.parse("2026-05-14T12:00:00Z"); + otp.save( + new OtpRepository.StoredOtp( + "d1", user, "+15551110000", "h", 0, 5, false, t0, t0.plusSeconds(300))); + otp.save( + new OtpRepository.StoredOtp( + "k1", other, "+15552220000", "h", 0, 5, false, t0, t0.plusSeconds(300))); + + int removed = otp.deleteByUserHandle(user); + assertThat(removed).isEqualTo(1); + assertThat(otp.findLatestActive(user, "+15551110000")).isEmpty(); + assertThat(otp.findLatestActive(other, "+15552220000")).isPresent(); + } + @Test void incrementAttempts_advancesPastCap() { UserHandle user = UserHandle.random(); diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java new file mode 100644 index 0000000..1c8f3c7 --- /dev/null +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.CredentialId; +import com.codeheadsystems.pkauth.api.Transport; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.credential.CredentialRecord; +import java.time.Instant; +import java.util.Set; +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Direct CRUD against {@link JdbiCredentialRepository}, covering the mutation paths ({@code + * updateSignCount}, {@code updateLabel}, {@code delete}, {@code deleteByUserHandle}) and the + * transport (de)serialization the shared ceremony scenarios don't exercise — including the + * clone-detection counter guard and the ownership-scoped no-ops. + */ +@Testcontainers +@DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") +class JdbiCredentialRepositoryIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-05-14T12:00:00Z"); + + private JdbiCredentialRepository repository; + + @BeforeEach + void setUp() { + Jdbi jdbi = PostgresFixture.ready(); + PostgresFixture.reset(); + repository = new JdbiCredentialRepository(jdbi); + } + + private static CredentialRecord cred( + CredentialId id, UserHandle user, long signCount, String label, Set transports) { + return new CredentialRecord( + id, + user, + new byte[] {1, 2, 3, 4}, + signCount, + label, + null, + transports, + true, + false, + NOW, + null); + } + + private static CredentialId credId(byte... bytes) { + return CredentialId.of(bytes); + } + + @Test + void saveFindAndListRoundTripPreservesTransports() { + UserHandle user = UserHandle.random(); + CredentialId multi = credId((byte) 1, (byte) 2); + CredentialId none = credId((byte) 3, (byte) 4); + repository.save(cred(multi, user, 0L, "Laptop", Set.of(Transport.USB, Transport.NFC))); + repository.save(cred(none, user, 0L, "Phone", Set.of())); + + assertThat(repository.findByCredentialId(multi)) + .hasValueSatisfying( + r -> + assertThat(r.transports()).containsExactlyInAnyOrder(Transport.USB, Transport.NFC)); + assertThat(repository.findByCredentialId(none)) + .hasValueSatisfying(r -> assertThat(r.transports()).isEmpty()); + assertThat(repository.findByUserHandle(user)).hasSize(2); + assertThat(repository.findByCredentialId(credId((byte) 9))).isEmpty(); + } + + @Test + void updateSignCountAdvancesButRejectsRegression() { + UserHandle user = UserHandle.random(); + CredentialId id = credId((byte) 5, (byte) 6); + repository.save(cred(id, user, 10L, "k", Set.of())); + + repository.updateSignCount(id, 42L, NOW.plusSeconds(60)); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.signCount()).isEqualTo(42L)); + + // Regression (lower than stored) must be refused — clone-detection guard. + repository.updateSignCount(id, 7L, NOW.plusSeconds(120)); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.signCount()).isEqualTo(42L)); + } + + @Test + void updateLabelHonoursOwnership() { + UserHandle owner = UserHandle.random(); + UserHandle stranger = UserHandle.random(); + CredentialId id = credId((byte) 7, (byte) 8); + repository.save(cred(id, owner, 0L, "Old", Set.of())); + + repository.updateLabel(stranger, id, "Hacked"); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.label()).isEqualTo("Old")); + + repository.updateLabel(owner, id, "New"); + assertThat(repository.findByCredentialId(id)) + .hasValueSatisfying(r -> assertThat(r.label()).isEqualTo("New")); + } + + @Test + void deleteHonoursOwnershipAndRemovesRow() { + UserHandle owner = UserHandle.random(); + UserHandle stranger = UserHandle.random(); + CredentialId id = credId((byte) 9, (byte) 10); + repository.save(cred(id, owner, 0L, "k", Set.of())); + + repository.delete(stranger, id); + assertThat(repository.findByCredentialId(id)).isPresent(); + + repository.delete(owner, id); + assertThat(repository.findByCredentialId(id)).isEmpty(); + } + + @Test + void deleteByUserHandleRemovesAllOfTheUsersCredentials() { + UserHandle user = UserHandle.random(); + UserHandle other = UserHandle.random(); + repository.save(cred(credId((byte) 20), user, 0L, "a", Set.of())); + repository.save(cred(credId((byte) 21), user, 0L, "b", Set.of())); + repository.save(cred(credId((byte) 22), other, 0L, "c", Set.of())); + + int removed = repository.deleteByUserHandle(user); + assertThat(removed).isEqualTo(2); + assertThat(repository.findByUserHandle(user)).isEmpty(); + assertThat(repository.findByUserHandle(other)).hasSize(1); + } +} diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java index 8d900fb..884ad6e 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.persistence.jdbi; +import static org.assertj.core.api.Assertions.assertThat; + import com.codeheadsystems.pkauth.testkit.RefreshTokenScenarios; import org.jdbi.v3.core.Jdbi; import org.junit.jupiter.api.BeforeEach; @@ -21,6 +23,57 @@ void setUp() { repository = new JdbiRefreshTokenRepository(jdbi); } + @Test + void deleteExpiredBeforeRemovesRevokedExpiredRowsButKeepsFresh() { + com.codeheadsystems.pkauth.api.UserHandle user = + com.codeheadsystems.pkauth.api.UserHandle.of(new byte[] {1, 2, 3, 4}); + + // Expired + revoked, both before the cutoff → eligible for cleanup. + String staleId = "stale-" + java.util.UUID.randomUUID(); + repository.create( + new com.codeheadsystems.pkauth.refresh.RefreshTokenRecord( + staleId, + new byte[32], + user, + "web", + java.util.Optional.empty(), + staleId, + java.util.Optional.empty(), + java.time.Instant.parse("2020-01-01T00:00:00Z"), + java.time.Instant.parse("2020-02-01T00:00:00Z"), + java.util.Optional.empty(), + java.util.Optional.empty(), + java.util.Optional.empty(), + java.util.List.of("pkauth"))); + repository.revokeFamily( + staleId, + java.time.Instant.parse("2020-02-02T00:00:00Z"), + com.codeheadsystems.pkauth.refresh.RevokeReason.ADMIN); + + // Still valid, well after the cutoff → must survive. + String freshId = "fresh-" + java.util.UUID.randomUUID(); + repository.create( + new com.codeheadsystems.pkauth.refresh.RefreshTokenRecord( + freshId, + new byte[32], + user, + "web", + java.util.Optional.empty(), + freshId, + java.util.Optional.empty(), + java.time.Instant.parse("2030-01-01T00:00:00Z"), + java.time.Instant.parse("2030-02-01T00:00:00Z"), + java.util.Optional.empty(), + java.util.Optional.empty(), + java.util.Optional.empty(), + java.util.List.of("pkauth"))); + + int removed = repository.deleteExpiredBefore(java.time.Instant.parse("2021-01-01T00:00:00Z")); + assertThat(removed).isGreaterThanOrEqualTo(1); + assertThat(repository.findByRefreshId(staleId)).isEmpty(); + assertThat(repository.findByRefreshId(freshId)).isPresent(); + } + @Test void issueRotateRevokeHappyPath() { new RefreshTokenScenarios(repository).issueRotateRevokeHappyPath(); diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java new file mode 100644 index 0000000..b920108 --- /dev/null +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.UserLookup.UserView; +import java.util.Optional; +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Covers {@link JdbiUserLookup}'s registration / lookup surface — {@code getOrCreateHandle} (create + * + idempotent re-fetch), {@code findHandleByUsername}, {@code register}, and {@code + * findViewByHandle} / {@code readView} — which the ceremony scenarios only touch via {@code + * getOrCreateHandle}. + */ +@Testcontainers +@DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") +class JdbiUserLookupIntegrationTest { + + private JdbiUserLookup users; + + @BeforeEach + void setUp() { + Jdbi jdbi = PostgresFixture.ready(); + PostgresFixture.reset(); + users = new JdbiUserLookup(jdbi); + } + + @Test + void getOrCreateHandleIsIdempotentForSameUsername() { + UserHandle first = users.getOrCreateHandle("alice"); + UserHandle second = users.getOrCreateHandle("alice"); + assertThat(second).isEqualTo(first); + } + + @Test + void findHandleByUsernameReflectsCreationAndMissesUnknown() { + UserHandle handle = users.getOrCreateHandle("bob"); + assertThat(users.findHandleByUsername("bob")).hasValue(handle); + assertThat(users.findHandleByUsername("nobody")).isEmpty(); + } + + @Test + void findViewByHandleReturnsUsernameForKnownAndEmptyForUnknown() { + UserHandle handle = users.getOrCreateHandle("carol"); + Optional view = users.findViewByHandle(handle); + assertThat(view) + .hasValueSatisfying( + v -> { + assertThat(v.handle()).isEqualTo(handle); + assertThat(v.username()).isEqualTo("carol"); + }); + assertThat(users.findViewByHandle(UserHandle.random())).isEmpty(); + } + + @Test + void registerPersistsUsernameAndDisplayName() { + UserHandle handle = users.register("dave", "Dave Display"); + assertThat(users.findHandleByUsername("dave")).hasValue(handle); + assertThat(users.findViewByHandle(handle)) + .hasValueSatisfying( + v -> { + assertThat(v.username()).isEqualTo("dave"); + assertThat(v.displayName()).isEqualTo("Dave Display"); + }); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshRecordsTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshRecordsTest.java new file mode 100644 index 0000000..4c433c6 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshRecordsTest.java @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Validation and projection guards for the refresh DTO records. */ +class RefreshRecordsTest { + + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + + @Test + void rotatedClaimsRejectsEmptyAmr() { + assertThatThrownBy(() -> new RotatedClaims(USER, "web", Optional.empty(), List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("amr"); + } + + @Test + void rotatedClaimsRejectsNullArguments() { + List amr = List.of("user"); + assertThatThrownBy(() -> new RotatedClaims(null, "web", Optional.empty(), amr)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new RotatedClaims(USER, null, Optional.empty(), amr)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new RotatedClaims(USER, "web", null, amr)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new RotatedClaims(USER, "web", Optional.empty(), null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rotatedClaimsCopiesAmrDefensively() { + List mutable = new ArrayList<>(List.of("pkauth")); + RotatedClaims claims = new RotatedClaims(USER, "web", Optional.empty(), mutable); + mutable.add("tampered"); + assertThat(claims.amr()).containsExactly("pkauth"); + } + + @Test + void summaryFromCopiesEveryNonSecretFieldAndDropsHashAndParent() { + RefreshTokenRecord record = + new RefreshTokenRecord( + "rid-1", + new byte[] {1, 2, 3, 4}, + USER, + "web", + Optional.of("dev-9"), + "fam-1", + Optional.of("parent-0"), + NOW, + NOW.plusSeconds(3600), + Optional.of(NOW.plusSeconds(10)), + Optional.of(NOW.plusSeconds(20)), + Optional.of(RevokeReason.LOGOUT), + List.of("pkauth")); + + RefreshTokenSummary summary = RefreshTokenSummary.from(record); + + assertThat(summary.refreshId()).isEqualTo("rid-1"); + assertThat(summary.audience()).isEqualTo("web"); + assertThat(summary.familyId()).isEqualTo("fam-1"); + assertThat(summary.deviceId()).hasValue("dev-9"); + assertThat(summary.issuedAt()).isEqualTo(NOW); + assertThat(summary.expiresAt()).isEqualTo(NOW.plusSeconds(3600)); + assertThat(summary.usedAt()).hasValue(NOW.plusSeconds(10)); + assertThat(summary.revokedAt()).hasValue(NOW.plusSeconds(20)); + } + + @Test + void summaryFromRejectsNullRecord() { + assertThatThrownBy(() -> RefreshTokenSummary.from(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void summaryConstructorRejectsNullFields() { + assertThatThrownBy( + () -> + new RefreshTokenSummary( + null, + "web", + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty())) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java new file mode 100644 index 0000000..8817238 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +/** Validates {@link RefreshTokenConfig} compact-constructor guards and the {@code defaults()}. */ +class RefreshTokenConfigTest { + + private final RefreshTtlPolicy policy = RefreshTtlPolicy.single(Duration.ofDays(14)); + + @Test + void defaultsUsePinnedConstants() { + RefreshTokenConfig config = RefreshTokenConfig.defaults(); + assertThat(config.secretBytes()).isEqualTo(RefreshTokenConfig.DEFAULT_SECRET_BYTES); + assertThat(config.refreshIdBytes()).isEqualTo(RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES); + assertThat(config.cleanupRetention()).isEqualTo(RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION); + assertThat(config.ttlPolicy().refreshTtl("web")) + .isEqualTo(RefreshTokenConfig.DEFAULT_REFRESH_TTL); + } + + @Test + void rejectsNullTtlPolicy() { + assertThatThrownBy(() -> new RefreshTokenConfig(null, 32, 16, Duration.ofDays(30))) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejectsSecretBytesBelowMinimum() { + assertThatThrownBy(() -> new RefreshTokenConfig(policy, 15, 16, Duration.ofDays(30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("secretBytes"); + } + + @Test + void rejectsRefreshIdBytesBelowMinimum() { + assertThatThrownBy(() -> new RefreshTokenConfig(policy, 32, 7, Duration.ofDays(30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("refreshIdBytes"); + } + + @Test + void rejectsNullCleanupRetention() { + assertThatThrownBy(() -> new RefreshTokenConfig(policy, 32, 16, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rejectsNegativeCleanupRetention() { + assertThatThrownBy(() -> new RefreshTokenConfig(policy, 32, 16, Duration.ofDays(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cleanupRetention"); + } + + @Test + void acceptsMinimumBoundaryValues() { + RefreshTokenConfig config = new RefreshTokenConfig(policy, 16, 8, Duration.ZERO); + assertThat(config.secretBytes()).isEqualTo(16); + assertThat(config.refreshIdBytes()).isEqualTo(8); + assertThat(config.cleanupRetention()).isEqualTo(Duration.ZERO); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecordTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecordTest.java new file mode 100644 index 0000000..336faa8 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecordTest.java @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** + * Compact-constructor validation, defensive copies, and equality for {@link RefreshTokenRecord}. + */ +class RefreshTokenRecordTest { + + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + + /** Builds a valid root record, overriding nothing. */ + private static RefreshTokenRecord valid() { + return new RefreshTokenRecord( + "rid", + new byte[] {1, 2, 3, 4}, + USER, + "web", + Optional.empty(), + "rid", + Optional.empty(), + NOW, + NOW.plusSeconds(3600), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("pkauth")); + } + + @Test + void validRecordRoundTrips() { + RefreshTokenRecord r = valid(); + assertThat(r.refreshId()).isEqualTo("rid"); + assertThat(r.audience()).isEqualTo("web"); + assertThat(r.amr()).containsExactly("pkauth"); + } + + @Test + void rejectsBlankRefreshId() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + " ", + new byte[] {1}, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("a"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("refreshId"); + } + + @Test + void rejectsEmptyTokenHash() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[0], + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("a"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tokenHash"); + } + + @Test + void rejectsBlankAudience() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + " ", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("a"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("audience"); + } + + @Test + void rejectsBlankFamilyId() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + "web", + Optional.empty(), + " ", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("a"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("familyId"); + } + + @Test + void rejectsRevokedAtWithoutReason() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.of(NOW), + Optional.empty(), + List.of("a"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("revokedAt and revokedReason"); + } + + @Test + void rejectsReasonWithoutRevokedAt() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.of(RevokeReason.ADMIN), + List.of("a"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("revokedAt and revokedReason"); + } + + @Test + void rejectsEmptyAmr() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("amr must be non-empty"); + } + + @Test + void rejectsBlankAmrEntry() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of(" "))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("amr entries must be non-blank"); + } + + @Test + void rejectsAmrEntryWithComma() { + assertThatThrownBy( + () -> + new RefreshTokenRecord( + "rid", + new byte[] {1}, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("a,b"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not contain ','"); + } + + @Test + void tokenHashAccessorReturnsDefensiveCopy() { + byte[] source = {9, 8, 7}; + RefreshTokenRecord r = + new RefreshTokenRecord( + "rid", + source, + USER, + "web", + Optional.empty(), + "fam", + Optional.empty(), + NOW, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("a")); + source[0] = 0; // mutating the source must not affect the stored copy + byte[] got = r.tokenHash(); + assertThat(got).containsExactly(9, 8, 7); + got[0] = 0; // mutating the returned array must not affect the stored copy either + assertThat(r.tokenHash()).containsExactly(9, 8, 7); + } + + @Test + void equalsAndHashCodeComparePerFieldIncludingHashContents() { + RefreshTokenRecord a = valid(); + RefreshTokenRecord b = valid(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(null).isNotEqualTo("not a record"); + + // A differing tokenHash (array content) breaks equality — the record uses Arrays.equals. + RefreshTokenRecord differentHash = + new RefreshTokenRecord( + "rid", + new byte[] {9, 9, 9, 9}, + USER, + "web", + Optional.empty(), + "rid", + Optional.empty(), + NOW, + NOW.plusSeconds(3600), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("pkauth")); + assertThat(a).isNotEqualTo(differentHash); + + // A differing scalar field also breaks equality. + RefreshTokenRecord differentAudience = + new RefreshTokenRecord( + "rid", + new byte[] {1, 2, 3, 4}, + USER, + "cli", + Optional.empty(), + "rid", + Optional.empty(), + NOW, + NOW.plusSeconds(3600), + Optional.empty(), + Optional.empty(), + Optional.empty(), + List.of("pkauth")); + assertThat(a).isNotEqualTo(differentAudience); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListenerTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListenerTest.java new file mode 100644 index 0000000..66cdf25 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListenerTest.java @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import com.codeheadsystems.pkauth.testkit.InMemoryRefreshTokenRepository; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Verifies the user-deletion bridge revokes every refresh family with {@code USER_DELETED}. */ +class RefreshTokenServiceDeletionListenerTest { + + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final List AMR = List.of("pkauth"); + + private final InMemoryRefreshTokenRepository repository = new InMemoryRefreshTokenRepository(); + private final RefreshTokenService service = + new RefreshTokenService( + repository, + RefreshTokenConfig.defaults(), + ClockProvider.fromClock(Clock.fixed(NOW, ZoneOffset.UTC)), + new SecureRandom()); + + @Test + void onUserDeletedRevokesEveryFamilyWithUserDeletedReason() { + service.issue(USER, "web", Optional.empty(), AMR); + service.issue(USER, "cli", Optional.empty(), AMR); + + new RefreshTokenServiceDeletionListener(service).onUserDeleted(USER); + + assertThat(repository.findByUserHandle(USER)) + .isNotEmpty() + .allSatisfy( + r -> { + assertThat(r.revokedAt()).isPresent(); + assertThat(r.revokedReason()).hasValue(RevokeReason.USER_DELETED); + }); + } + + @Test + void constructorRejectsNullService() { + assertThatThrownBy(() -> new RefreshTokenServiceDeletionListener(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java new file mode 100644 index 0000000..a5acaa5 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import com.codeheadsystems.pkauth.testkit.InMemoryRefreshTokenRepository; +import com.codeheadsystems.pkauth.testkit.RefreshTokenScenarios; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** + * Drives the shared {@link RefreshTokenScenarios} (so {@code RefreshTokenService} coverage is + * attributed to this module and its JaCoCo gate is actually enforced) plus the service-level edge + * cases the cross-backend parity scenarios don't reach: wire-format parsing, the deprecated default + * {@code amr} overload, argument validation, revoked-vs-replayed disambiguation, and the + * deterministic-RNG constructor. + */ +class RefreshTokenServiceTest { + + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final String AUDIENCE = "web"; + private static final List AMR = List.of("pkauth", "webauthn"); + + private final InMemoryRefreshTokenRepository repository = new InMemoryRefreshTokenRepository(); + private final RefreshTokenService service = + new RefreshTokenService( + repository, RefreshTokenConfig.defaults(), fixedClock(NOW), new SecureRandom()); + + // -- Shared parity scenarios (coverage attribution + behavioural contract) ---------------- + + @Test + void scenario_issueRotateRevokeHappyPath() { + scenarios().issueRotateRevokeHappyPath(); + } + + @Test + void scenario_rotationUpdatesFamilyChainAndChildLinksToParent() { + scenarios().rotationUpdatesFamilyChainAndChildLinksToParent(); + } + + @Test + void scenario_replayOfUsedTokenScorchesEntireFamily() { + scenarios().replayOfUsedTokenScorchesEntireFamily(); + } + + @Test + void scenario_expiredTokenRotationReturnsExpired() { + scenarios().expiredTokenRotationReturnsExpired(); + } + + @Test + void scenario_unknownRefreshIdReturnsUnknown() { + scenarios().unknownRefreshIdReturnsUnknown(); + } + + @Test + void scenario_wrongSecretReturnsUnknownAndDoesNotBurnLegitToken() { + scenarios().wrongSecretReturnsUnknownAndDoesNotBurnLegitToken(); + } + + @Test + void scenario_revokeFamilyIsIdempotent() { + scenarios().revokeFamilyIsIdempotent(); + } + + @Test + void scenario_revokeAllForUserRevokesEveryActiveFamily() { + scenarios().revokeAllForUserRevokesEveryActiveFamily(); + } + + @Test + void scenario_concurrentRotationExactlyOneSucceedsFamilyRevoked() throws Exception { + scenarios().concurrentRotationExactlyOneSucceedsFamilyRevoked(); + } + + // -- issue() ------------------------------------------------------------------------------ + + @Test + @SuppressWarnings("deprecation") // intentionally exercises the deprecated default-amr overload + void deprecatedIssueDefaultsAmrToUser() { + RefreshTokenPair pair = service.issue(USER, AUDIENCE, Optional.empty()); + assertThat(pair.record().amr()).containsExactly("user"); + // The default amr is carried through a rotation unchanged. + RotateResult.Success rotated = (RotateResult.Success) service.rotate(pair.wireToken()); + assertThat(rotated.claimsForAccessIssue().amr()).containsExactly("user"); + } + + @Test + void issueRejectsBlankAudience() { + assertThatThrownBy(() -> service.issue(USER, " ", Optional.empty(), AMR)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("audience"); + } + + @Test + void issueRejectsNullArguments() { + assertThatThrownBy(() -> service.issue(null, AUDIENCE, Optional.empty(), AMR)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.issue(USER, null, Optional.empty(), AMR)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.issue(USER, AUDIENCE, null, AMR)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.issue(USER, AUDIENCE, Optional.empty(), null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void issueCarriesDeviceIdAndAudienceOntoRecord() { + RefreshTokenPair pair = service.issue(USER, "cli", Optional.of("device-7"), AMR); + assertThat(pair.record().audience()).isEqualTo("cli"); + assertThat(pair.record().deviceId()).hasValue("device-7"); + assertThat(pair.record().issuedAt()).isEqualTo(NOW); + // Default single-TTL policy is 14 days. + assertThat(pair.record().expiresAt()).isEqualTo(NOW.plus(Duration.ofDays(14))); + } + + // -- rotate() wire-format parsing --------------------------------------------------------- + + @Test + void rotateWithNoDotSeparatorIsUnknown() { + assertThat(service.rotate("noseparator")).isInstanceOf(RotateResult.Unknown.class); + } + + @Test + void rotateWithLeadingDotIsUnknown() { + assertThat(service.rotate(".secretpart")).isInstanceOf(RotateResult.Unknown.class); + } + + @Test + void rotateWithTrailingDotIsUnknown() { + assertThat(service.rotate("refreshId.")).isInstanceOf(RotateResult.Unknown.class); + } + + @Test + void rotateWithNonBase64SecretIsUnknown() { + // '*' is outside the base64url alphabet → decode throws → parse returns null → Unknown. + assertThat(service.rotate("refreshId.****")).isInstanceOf(RotateResult.Unknown.class); + } + + @Test + void rotateWithEmptySecretAfterDecodeIsUnknown() { + // An empty base64url string decodes to zero bytes → parse rejects → Unknown. + String emptySecret = Base64Url.encode(new byte[0]); + assertThat(service.rotate("refreshId." + emptySecret)).isInstanceOf(RotateResult.Unknown.class); + } + + @Test + void rotateRejectsNull() { + assertThatThrownBy(() -> service.rotate(null)).isInstanceOf(NullPointerException.class); + } + + // -- rotate() against revoked rows -------------------------------------------------------- + + @Test + void rotateOfFamilyRevokedForNonReplayReasonReturnsRevoked() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); + service.revokeFamily(root.record().familyId(), RevokeReason.ADMIN); + assertThat(service.rotate(root.wireToken())) + .isInstanceOfSatisfying( + RotateResult.Revoked.class, r -> assertThat(r.reason()).isEqualTo(RevokeReason.ADMIN)); + } + + @Test + void rotateOfReplayScorchedFamilyReturnsReplayedNotRevoked() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); + RotateResult.Success first = (RotateResult.Success) service.rotate(root.wireToken()); + // Re-present the now-used root: scorches the family with ROTATION_REPLAY. + assertThat(service.rotate(root.wireToken())).isInstanceOf(RotateResult.Replayed.class); + // The successor's family is ROTATION_REPLAY-revoked → Replayed (consistent outcome), not + // Revoked. + assertThat(service.rotate(first.pair().wireToken())).isInstanceOf(RotateResult.Replayed.class); + } + + // -- revoke / listing --------------------------------------------------------------------- + + @Test + void revokeAllForUserReturnsAffectedCount() { + service.issue(USER, AUDIENCE, Optional.empty(), AMR); + service.issue(USER, "cli", Optional.empty(), AMR); + assertThat(service.revokeAllForUser(USER, RevokeReason.LOGOUT)).isEqualTo(2); + // Idempotent: nothing left to revoke the second time. + assertThat(service.revokeAllForUser(USER, RevokeReason.LOGOUT)).isZero(); + } + + @Test + void listForUserProjectsSummariesWithoutSecrets() { + RefreshTokenPair pair = service.issue(USER, AUDIENCE, Optional.of("dev-1"), AMR); + List summaries = service.listForUser(USER); + assertThat(summaries).hasSize(1); + RefreshTokenSummary s = summaries.get(0); + assertThat(s.refreshId()).isEqualTo(pair.record().refreshId()); + assertThat(s.familyId()).isEqualTo(pair.record().familyId()); + assertThat(s.audience()).isEqualTo(AUDIENCE); + assertThat(s.deviceId()).hasValue("dev-1"); + assertThat(s.revokedAt()).isEmpty(); + } + + @Test + void listForUserIsEmptyForUnknownUser() { + assertThat(service.listForUser(UserHandle.of(new byte[] {9, 9}))).isEmpty(); + } + + @Test + void revokeAndListNullChecks() { + assertThatThrownBy(() -> service.revokeFamily(null, RevokeReason.ADMIN)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.revokeFamily("fam", null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.revokeAllForUser(null, RevokeReason.ADMIN)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.revokeAllForUser(USER, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> service.listForUser(null)).isInstanceOf(NullPointerException.class); + } + + // -- constructors ------------------------------------------------------------------------- + + @Test + void deterministicRngConstructorProducesStableWireToken() { + RefreshTokenRepository repoA = new InMemoryRefreshTokenRepository(); + RefreshTokenRepository repoB = new InMemoryRefreshTokenRepository(); + RefreshTokenService a = + new RefreshTokenService( + repoA, RefreshTokenConfig.defaults(), fixedClock(NOW), seededRandom()); + RefreshTokenService b = + new RefreshTokenService( + repoB, RefreshTokenConfig.defaults(), fixedClock(NOW), seededRandom()); + assertThat(a.issue(USER, AUDIENCE, Optional.empty(), AMR).wireToken()) + .isEqualTo(b.issue(USER, AUDIENCE, Optional.empty(), AMR).wireToken()); + } + + @Test + void constructorRejectsNullDependencies() { + RefreshTokenConfig config = RefreshTokenConfig.defaults(); + ClockProvider clock = fixedClock(NOW); + assertThatThrownBy(() -> new RefreshTokenService(null, config, clock)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new RefreshTokenService(repository, null, clock)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new RefreshTokenService(repository, config, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> new RefreshTokenService(repository, config, clock, (SecureRandom) null)) + .isInstanceOf(NullPointerException.class); + } + + // -- helpers ------------------------------------------------------------------------------ + + private RefreshTokenScenarios scenarios() { + return new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()); + } + + private static SecureRandom seededRandom() { + // SHA1PRNG with a fixed seed is reproducible across instances — fine for a test asserting + // determinism (never for production token generation). + try { + SecureRandom r = SecureRandom.getInstance("SHA1PRNG"); + r.setSeed(42L); + return r; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static ClockProvider fixedClock(Instant instant) { + return ClockProvider.fromClock(Clock.fixed(instant, ZoneOffset.UTC)); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java new file mode 100644 index 0000000..7909ced --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Covers both built-in {@link RefreshTtlPolicy} factories and their validation. */ +class RefreshTtlPolicyTest { + + @Test + void singleAppliesSameTtlToEveryAudienceAndKnowsNone() { + RefreshTtlPolicy policy = RefreshTtlPolicy.single(Duration.ofDays(7)); + assertThat(policy.refreshTtl("web")).isEqualTo(Duration.ofDays(7)); + assertThat(policy.refreshTtl("cli")).isEqualTo(Duration.ofDays(7)); + assertThat(policy.knownAudiences()).isEmpty(); + assertThat(policy.toString()).contains("single"); + } + + @Test + void singleRejectsNullZeroAndNegative() { + assertThatThrownBy(() -> RefreshTtlPolicy.single(null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> RefreshTtlPolicy.single(Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RefreshTtlPolicy.single(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fixedDispatchesByAudienceWithDefaultFallback() { + RefreshTtlPolicy policy = + RefreshTtlPolicy.fixed( + Duration.ofDays(14), Map.of("web", Duration.ofDays(14), "cli", Duration.ofDays(90))); + assertThat(policy.refreshTtl("cli")).isEqualTo(Duration.ofDays(90)); + assertThat(policy.refreshTtl("web")).isEqualTo(Duration.ofDays(14)); + assertThat(policy.refreshTtl("unmapped")).isEqualTo(Duration.ofDays(14)); // default fallback + assertThat(policy.knownAudiences()).containsExactlyInAnyOrder("web", "cli"); + assertThat(policy.toString()).contains("fixed"); + } + + @Test + void fixedRejectsNullArguments() { + assertThatThrownBy(() -> RefreshTtlPolicy.fixed(null, Map.of())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> RefreshTtlPolicy.fixed(Duration.ofDays(1), null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void fixedRejectsNonPositiveDefault() { + assertThatThrownBy(() -> RefreshTtlPolicy.fixed(Duration.ZERO, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("defaultTtl"); + } + + @Test + void fixedRejectsNonPositiveOverride() { + assertThatThrownBy( + () -> RefreshTtlPolicy.fixed(Duration.ofDays(1), Map.of("web", Duration.ofDays(-1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("web"); + } + + @Test + void fixedRejectsNullKeyOrValueInOverrides() { + Map nullValue = new HashMap<>(); + nullValue.put("web", null); + assertThatThrownBy(() -> RefreshTtlPolicy.fixed(Duration.ofDays(1), nullValue)) + .isInstanceOf(NullPointerException.class); + + Map nullKey = new HashMap<>(); + nullKey.put(null, Duration.ofDays(1)); + assertThatThrownBy(() -> RefreshTtlPolicy.fixed(Duration.ofDays(1), nullKey)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandlerTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandlerTest.java new file mode 100644 index 0000000..0ed4226 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandlerTest.java @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.jwt.JwtConfig; +import com.codeheadsystems.pkauth.jwt.JwtKeyset; +import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenPair; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import com.codeheadsystems.pkauth.testkit.InMemoryRefreshTokenRepository; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Exercises every {@link RefreshHandler.Outcome} branch plus the null/blank-request guard. */ +class RefreshHandlerTest { + + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final String AUDIENCE = "web"; + private static final List AMR = List.of("pkauth", "webauthn"); + + private InMemoryRefreshTokenRepository repository; + private RefreshTokenService service; + private RefreshHandler handler; + + @BeforeEach + void setUp() { + repository = new InMemoryRefreshTokenRepository(); + ClockProvider clock = ClockProvider.fromClock(Clock.fixed(NOW, ZoneOffset.UTC)); + service = + new RefreshTokenService( + repository, RefreshTokenConfig.defaults(), clock, new SecureRandom()); + PkAuthJwtIssuer issuer = + new PkAuthJwtIssuer( + JwtConfig.defaults("https://pkauth.example.com", AUDIENCE), + JwtKeyset.hs256(new byte[32]), + clock); + handler = new RefreshHandler(service, issuer); + } + + @Test + void nullRequestBecomesUnknownFailure() { + assertFailure(handler.handle(null), "unknown", null); + } + + @Test + void nullTokenBecomesUnknownFailure() { + assertFailure(handler.handle(new RefreshRequest(null)), "unknown", null); + } + + @Test + void blankTokenBecomesUnknownFailure() { + assertFailure(handler.handle(new RefreshRequest(" ")), "unknown", null); + } + + @Test + void successMintsAccessJwtAndReturnsRotatedRefreshToken() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); + RefreshHandler.Outcome outcome = handler.handle(new RefreshRequest(root.wireToken())); + + assertThat(outcome).isInstanceOf(RefreshHandler.Outcome.Success.class); + RefreshResponse response = ((RefreshHandler.Outcome.Success) outcome).response(); + assertThat(response.refreshToken()).isNotEqualTo(root.wireToken()).contains("."); + assertThat(response.accessToken()).isNotBlank(); + assertThat(response.accessToken().split("\\.")).hasSize(3); // header.payload.signature + assertThat(response.expiresAt()).isAfter(NOW); + } + + @Test + void unknownTokenIsUnknownFailure() { + assertFailure(handler.handle(new RefreshRequest("missing.aaaa")), "unknown", null); + } + + @Test + void expiredTokenIsExpiredFailure() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); + PkAuthJwtIssuer issuer = + new PkAuthJwtIssuer( + JwtConfig.defaults("https://pkauth.example.com", AUDIENCE), + JwtKeyset.hs256(new byte[32]), + ClockProvider.fromClock(Clock.fixed(NOW, ZoneOffset.UTC))); + RefreshTokenService later = + new RefreshTokenService( + repository, + RefreshTokenConfig.defaults(), + ClockProvider.fromClock(Clock.fixed(NOW.plus(Duration.ofDays(60)), ZoneOffset.UTC)), + new SecureRandom()); + RefreshHandler laterHandler = new RefreshHandler(later, issuer); + assertFailure(laterHandler.handle(new RefreshRequest(root.wireToken())), "expired", null); + } + + @Test + void replayedTokenIsReplayedFailure() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); + handler.handle(new RefreshRequest(root.wireToken())); // first use succeeds + assertFailure(handler.handle(new RefreshRequest(root.wireToken())), "replayed", null); + } + + @Test + void revokedTokenIsRevokedFailureCarryingReason() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); + service.revokeFamily(root.record().familyId(), RevokeReason.LOGOUT); + assertFailure(handler.handle(new RefreshRequest(root.wireToken())), "revoked", "LOGOUT"); + } + + @Test + void constructorRejectsNullDependencies() { + PkAuthJwtIssuer issuer = + new PkAuthJwtIssuer( + JwtConfig.defaults("https://pkauth.example.com", AUDIENCE), + JwtKeyset.hs256(new byte[32]), + ClockProvider.fromClock(Clock.fixed(NOW, ZoneOffset.UTC))); + assertThatThrownBy(() -> new RefreshHandler(null, issuer)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new RefreshHandler(service, null)) + .isInstanceOf(NullPointerException.class); + } + + private static void assertFailure(RefreshHandler.Outcome outcome, String detail, String reason) { + assertThat(outcome).isInstanceOf(RefreshHandler.Outcome.Failure.class); + RefreshErrorResponse response = ((RefreshHandler.Outcome.Failure) outcome).response(); + assertThat(response.detail()).isEqualTo(detail); + assertThat(response.reason()).isEqualTo(reason); + } +} diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthAdminIntegrationTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthAdminIntegrationTest.java index ca10c37..825cbeb 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthAdminIntegrationTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthAdminIntegrationTest.java @@ -2,6 +2,7 @@ package com.codeheadsystems.pkauth.spring; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -41,12 +42,19 @@ class PkAuthAdminIntegrationTest { @Autowired private ObjectMapper objectMapper; @Autowired private UserLookup userLookup; @Autowired private RelyingPartyConfig relyingParty; + @Autowired private com.codeheadsystems.pkauth.spi.CeremonyRateLimiter ceremonyRateLimiter; private MockMvc mockMvc; private FakeAuthenticator authenticator; @BeforeEach void setUp() { + // The in-memory ceremony rate limiter is a shared singleton across this class's reused + // context; without a per-test reset the cumulative start/finish calls trip the per-IP limit. + if (ceremonyRateLimiter + instanceof com.codeheadsystems.pkauth.ceremony.InMemoryCeremonyRateLimiter inMemory) { + inMemory.reset(); + } mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) .apply(SecurityMockMvcConfigurers.springSecurity()) @@ -158,6 +166,58 @@ void finishEmailVerificationWithBlankTokenReturns400() throws Exception { .andExpect(status().isBadRequest()); } + @Test + void authenticatedDeleteCredentialRemovesIt() throws Exception { + String token = registerAndLogin("ivan"); + // Deleting the last credential is refused (409) unless backup codes remain — generate them + // first so the delete reaches its success path. + mockMvc + .perform( + post("/auth/admin/backup-codes/regenerate").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + MvcResult list = + mockMvc + .perform(get("/auth/admin/credentials").header("Authorization", "Bearer " + token)) + .andReturn(); + String body = list.getResponse().getContentAsString(); + int idStart = body.indexOf("\"credentialId\":\"") + "\"credentialId\":\"".length(); + int idEnd = body.indexOf('"', idStart); + String credentialId = body.substring(idStart, idEnd); + + mockMvc + .perform( + delete("/auth/admin/credentials/" + credentialId) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + + // The credential list is now empty. + mockMvc + .perform(get("/auth/admin/credentials").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void finishPhoneVerificationAfterStartReturnsResult() throws Exception { + String token = registerAndLogin("judy"); + mockMvc + .perform( + post("/auth/admin/phone/start-verification") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"+15557654321\"}")) + .andExpect(status().isOk()); + // Complete with a (near-certainly) wrong code: the handler runs and maps the typed result to + // a 200 body regardless of match/mismatch. + mockMvc + .perform( + post("/auth/admin/phone/complete-verification") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"phone\":\"+15557654321\",\"code\":\"000000\"}")) + .andExpect(status().isOk()); + } + private String registerAndLogin(String username) throws Exception { // register StartRegistrationRequest sr = new StartRegistrationRequest(username, username, null, null); diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfigurationUnitTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfigurationUnitTest.java new file mode 100644 index 0000000..3b02a6f --- /dev/null +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfigurationUnitTest.java @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.spring.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.jwt.JwtConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.spring.config.PkAuthProperties; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Unit-tests the config-building {@code @Bean} methods of {@link PkAuthAutoConfiguration} directly, + * covering the required-block validation and TTL-defaulting branches that a single Spring context + * (one valid binding) cannot exercise. + */ +class PkAuthAutoConfigurationUnitTest { + + private final PkAuthAutoConfiguration autoConfig = new PkAuthAutoConfiguration(); + + private static PkAuthProperties.RelyingParty rp() { + return new PkAuthProperties.RelyingParty( + "example.com", "Example", Set.of("https://example.com")); + } + + private static PkAuthProperties.Jwt jwt(Duration defaultTtl, Map overrides) { + return new PkAuthProperties.Jwt( + "https://pkauth.example.com", + "https://app.example.com", + "pk-auth-spring-test-secret-bytes-32!!", + defaultTtl, + overrides); + } + + private static PkAuthProperties props( + PkAuthProperties.RelyingParty rp, + PkAuthProperties.Jwt jwt, + PkAuthProperties.Refresh refresh) { + return new PkAuthProperties(rp, jwt, null, null, refresh, false); + } + + // -- relyingParty ------------------------------------------------------------------------- + + @Test + void relyingPartyConfigBuildsFromValidProps() { + RelyingPartyConfig cfg = + autoConfig.pkAuthRelyingPartyConfig(props(rp(), jwt(null, null), null)); + assertThat(cfg.id()).isEqualTo("example.com"); + assertThat(cfg.origins()).containsExactly("https://example.com"); + } + + @Test + void relyingPartyConfigThrowsWhenBlockMissing() { + assertThatThrownBy( + () -> autoConfig.pkAuthRelyingPartyConfig(props(null, jwt(null, null), null))) + .isInstanceOf(IllegalStateException.class); + } + + // -- jwt ---------------------------------------------------------------------------------- + + @Test + void jwtConfigUsesSinglePolicyWhenNoOverrides() { + JwtConfig cfg = autoConfig.pkAuthJwtConfig(props(rp(), jwt(null, null), null)); + assertThat(cfg.issuer()).isEqualTo("https://pkauth.example.com"); + assertThat(cfg.allowedAudiences()).containsExactly("https://app.example.com"); + } + + @Test + void jwtConfigUsesFixedPolicyWhenOverridesPresent() { + JwtConfig cfg = + autoConfig.pkAuthJwtConfig( + props(rp(), jwt(Duration.ofMinutes(30), Map.of("cli", Duration.ofHours(8))), null)); + assertThat(cfg.allowedAudiences()).contains("https://app.example.com", "cli"); + } + + @Test + void jwtConfigThrowsWhenJwtBlockMissing() { + assertThatThrownBy(() -> autoConfig.pkAuthJwtConfig(props(rp(), null, null))) + .isInstanceOf(IllegalStateException.class); + } + + // -- refresh ------------------------------------------------------------------------------ + + @Test + void refreshTokenConfigDefaultsWhenUnset() { + RefreshTokenConfig cfg = + autoConfig.pkAuthRefreshTokenConfig(props(rp(), jwt(null, null), null)); + assertThat(cfg.ttlPolicy().refreshTtl("any")).isEqualTo(RefreshTokenConfig.DEFAULT_REFRESH_TTL); + assertThat(cfg.cleanupRetention()).isEqualTo(RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION); + } + + @Test + void refreshTokenConfigUsesFixedPolicyAndExplicitRetention() { + PkAuthProperties.Refresh refresh = + new PkAuthProperties.Refresh( + Duration.ofDays(7), Map.of("cli", Duration.ofDays(90)), Duration.ofDays(10), null); + RefreshTokenConfig cfg = + autoConfig.pkAuthRefreshTokenConfig(props(rp(), jwt(null, null), refresh)); + assertThat(cfg.ttlPolicy().refreshTtl("cli")).isEqualTo(Duration.ofDays(90)); + assertThat(cfg.ttlPolicy().refreshTtl("other")).isEqualTo(Duration.ofDays(7)); + assertThat(cfg.cleanupRetention()).isEqualTo(Duration.ofDays(10)); + } +}