diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java index 21ad577b..a48de5a5 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthConfiguration.java @@ -215,7 +215,8 @@ EmailRegistrationService emailRegistrationService( HmacVerificationTokens verificationTokens, Clock identityClock, @Value("${identity.preferences.default-language:ko}") String defaultLanguage, - @Value("${identity.preferences.default-timezone:America/New_York}") String defaultTimezone) { + @Value("${identity.preferences.default-timezone:America/New_York}") String defaultTimezone, + @Value("${identity.email-verification-required:false}") boolean emailVerificationRequired) { return new EmailRegistrationService( queries, commands, @@ -229,6 +230,7 @@ EmailRegistrationService emailRegistrationService( verificationTokens, verificationTokens, new AccountPreferenceDefaults(defaultLanguage, defaultTimezone, ThemePreference.SYSTEM), + emailVerificationRequired, identityClock); } diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java index 1250cfc1..8bc8fd44 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/identity/IdentityAuthController.java @@ -57,7 +57,8 @@ public ResponseEntity signup( if (result.verificationToken() != null) { verificationDelivery.send(result.accountId(), result.verificationToken(), result.expiresAt()); } - return ResponseEntity.accepted().body(new SignupResponse(result.accountId(), true, result.expiresAt())); + return ResponseEntity.accepted().body(new SignupResponse( + result.accountId(), result.verificationToken() != null, result.expiresAt())); } @GetMapping("/verify-email") diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java index e4224374..22576e14 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/identity/IdentityAuthControllerTest.java @@ -46,14 +46,13 @@ void rejectsMissingVerificationTokenAsBadRequest() throws Exception { } @Test - void signupDeliversVerificationSecretWithoutReturningItInTheApiBody() { + void signupReturnsAnImmediatelyUsableAccountWithoutSendingVerificationEmail() { var registration = mock(EmailRegistrationService.class); var authentication = mock(EmailAuthenticationService.class); var delivery = mock(VerificationDeliveryPort.class); UUID accountId = UUID.randomUUID(); - Instant expiresAt = Instant.parse("2026-08-02T12:00:00Z"); when(registration.signup(org.mockito.ArgumentMatchers.any())) - .thenReturn(new SignupResult(accountId, "raw-verification-secret", expiresAt)); + .thenReturn(new SignupResult(accountId, null, null)); var controller = new IdentityAuthController(registration, authentication, delivery, jwt(), cookies()); var response = controller.signup( @@ -62,18 +61,19 @@ void signupDeliversVerificationSecretWithoutReturningItInTheApiBody() { "192.0.2.0/24"); assertThat(response.getStatusCode().value()).isEqualTo(202); - assertThat(response.getBody().toString()).doesNotContain("raw-verification-secret"); - verify(delivery).send(accountId, "raw-verification-secret", expiresAt); + assertThat(response.getBody().verificationRequired()).isFalse(); + assertThat(response.getBody().verificationExpiresAt()).isNull(); + verifyNoInteractions(delivery); } @Test - void repeatedPendingSignupDoesNotSendAnotherVerificationEmail() { + void signupResponseReflectsWhetherTheServiceIssuedAVerificationToken() { var registration = mock(EmailRegistrationService.class); var delivery = mock(VerificationDeliveryPort.class); UUID accountId = UUID.randomUUID(); Instant expiresAt = Instant.parse("2026-08-02T12:00:00Z"); when(registration.signup(org.mockito.ArgumentMatchers.any())) - .thenReturn(new SignupResult(accountId, null, expiresAt)); + .thenReturn(new SignupResult(accountId, "raw-verification-secret", expiresAt)); var controller = new IdentityAuthController( registration, mock(EmailAuthenticationService.class), delivery, jwt(), cookies()); @@ -84,7 +84,8 @@ void repeatedPendingSignupDoesNotSendAnotherVerificationEmail() { assertThat(response.getStatusCode().value()).isEqualTo(202); assertThat(response.getBody().accountId()).isEqualTo(accountId); - verifyNoInteractions(delivery); + assertThat(response.getBody().verificationRequired()).isTrue(); + verify(delivery).send(accountId, "raw-verification-secret", expiresAt); } @Test diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ActiveEmailRegistration.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ActiveEmailRegistration.java new file mode 100644 index 00000000..33521d9f --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/ActiveEmailRegistration.java @@ -0,0 +1,30 @@ +package com.idea2strategy.backend.application.identity; + +import com.idea2strategy.backend.domain.identity.AccountPreferences; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +public record ActiveEmailRegistration( + UUID accountId, + UUID loginIdentityId, + ProtectedEmail email, + PasswordHash password, + Instant registeredAt, + UUID correlationId, + AccountPreferences preferences) { + public ActiveEmailRegistration { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(loginIdentityId, "loginIdentityId"); + Objects.requireNonNull(email, "email"); + Objects.requireNonNull(password, "password"); + Objects.requireNonNull(registeredAt, "registeredAt"); + Objects.requireNonNull(correlationId, "correlationId"); + Objects.requireNonNull(preferences, "preferences"); + } + + @Override + public String toString() { + return "ActiveEmailRegistration[accountId=" + accountId + ", protected=REDACTED]"; + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java index 5acb7595..45cc29f3 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/EmailRegistrationService.java @@ -18,6 +18,7 @@ public final class EmailRegistrationService { private final VerificationTokenIssuer tokenIssuer; private final VerificationTokenDigest tokenDigest; private final AccountPreferenceDefaults preferenceDefaults; + private final boolean verificationRequired; private final Clock clock; public EmailRegistrationService( @@ -38,6 +39,53 @@ public EmailRegistrationService( tokenIssuer, tokenDigest, new AccountPreferenceDefaults("ko", "America/New_York", ThemePreference.SYSTEM), + true, + clock); + } + + public EmailRegistrationService( + RegistrationQueryPort queryPort, + RegistrationCommandPort commandPort, + EmailProtector emailProtector, + PasswordPolicy passwordPolicy, + PasswordHasher passwordHasher, + VerificationTokenIssuer tokenIssuer, + VerificationTokenDigest tokenDigest, + boolean verificationRequired, + Clock clock) { + this( + queryPort, + commandPort, + emailProtector, + passwordPolicy, + passwordHasher, + tokenIssuer, + tokenDigest, + new AccountPreferenceDefaults("ko", "America/New_York", ThemePreference.SYSTEM), + verificationRequired, + clock); + } + + public EmailRegistrationService( + RegistrationQueryPort queryPort, + RegistrationCommandPort commandPort, + EmailProtector emailProtector, + PasswordPolicy passwordPolicy, + PasswordHasher passwordHasher, + VerificationTokenIssuer tokenIssuer, + VerificationTokenDigest tokenDigest, + AccountPreferenceDefaults preferenceDefaults, + Clock clock) { + this( + queryPort, + commandPort, + emailProtector, + passwordPolicy, + passwordHasher, + tokenIssuer, + tokenDigest, + preferenceDefaults, + true, clock); } @@ -50,6 +98,7 @@ public EmailRegistrationService( VerificationTokenIssuer tokenIssuer, VerificationTokenDigest tokenDigest, AccountPreferenceDefaults preferenceDefaults, + boolean verificationRequired, Clock clock) { this.queryPort = Objects.requireNonNull(queryPort, "queryPort"); this.commandPort = Objects.requireNonNull(commandPort, "commandPort"); @@ -59,6 +108,7 @@ public EmailRegistrationService( this.tokenIssuer = Objects.requireNonNull(tokenIssuer, "tokenIssuer"); this.tokenDigest = Objects.requireNonNull(tokenDigest, "tokenDigest"); this.preferenceDefaults = Objects.requireNonNull(preferenceDefaults, "preferenceDefaults"); + this.verificationRequired = verificationRequired; this.clock = Objects.requireNonNull(clock, "clock"); } @@ -69,44 +119,54 @@ public SignupResult signup(SignupCommand command) { validateEmail(email.normalized()); var existing = queryPort.findEmailRegistration(email.comparisonFingerprints()); if (existing.isPresent()) { - return continuePendingRegistration(existing.orElseThrow()); + return continueExistingRegistration(existing.orElseThrow(), command.correlationId()); } if (queryPort.emailExists(email.lookupHmac())) { throw new DuplicateEmailException(); } var now = clock.instant(); - var expiresAt = now.plus(VERIFICATION_LIFETIME); - VerificationToken token = tokenIssuer.issue(); UUID accountId = UUID.randomUUID(); try { - commandPort.createPending(new PendingRegistration( - accountId, - UUID.randomUUID(), - UUID.randomUUID(), - email, - passwordHasher.hash(command.password()), - token.digest(), - now, - expiresAt, - command.correlationId(), - command.requestIpPrefix(), - preferenceDefaults.at(now))); + if (verificationRequired) { + var expiresAt = now.plus(VERIFICATION_LIFETIME); + VerificationToken token = tokenIssuer.issue(); + commandPort.createPending(new PendingRegistration( + accountId, + UUID.randomUUID(), + UUID.randomUUID(), + email, + passwordHasher.hash(command.password()), + token.digest(), + now, + expiresAt, + command.correlationId(), + command.requestIpPrefix(), + preferenceDefaults.at(now))); + return new SignupResult(accountId, token.rawToken(), expiresAt); + } + commandPort.createActive(new ActiveEmailRegistration( + accountId, UUID.randomUUID(), email, passwordHasher.hash(command.password()), + now, command.correlationId(), preferenceDefaults.at(now))); } catch (DuplicateEmailException duplicate) { var racedRegistration = queryPort.findEmailRegistration(email.comparisonFingerprints()); if (racedRegistration.isPresent()) { - return continuePendingRegistration(racedRegistration.orElseThrow()); + return continueExistingRegistration(racedRegistration.orElseThrow(), command.correlationId()); } throw duplicate; } - return new SignupResult(accountId, token.rawToken(), expiresAt); + return new SignupResult(accountId, null, null); } - private SignupResult continuePendingRegistration(ExistingEmailRegistration existing) { + private SignupResult continueExistingRegistration(ExistingEmailRegistration existing, UUID correlationId) { if (!existing.awaitingVerification()) { throw new DuplicateEmailException(); } - return new SignupResult(existing.accountId(), null, existing.verificationExpiresAt()); + if (verificationRequired) { + return new SignupResult(existing.accountId(), null, existing.verificationExpiresAt()); + } + commandPort.activatePending(existing.accountId(), clock.instant(), correlationId); + return new SignupResult(existing.accountId(), null, null); } public void verify(VerifyEmailCommand command) { diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java index 1d66f330..450aea12 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/identity/RegistrationCommandPort.java @@ -4,6 +4,10 @@ import java.util.UUID; public interface RegistrationCommandPort { + void createActive(ActiveEmailRegistration registration); + + void activatePending(UUID accountId, Instant activatedAt, UUID correlationId); + void createPending(PendingRegistration registration); VerificationOutcome consumeVerification(String tokenDigest, Instant consumedAt, UUID correlationId); diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java index 797e4ef8..7d65618b 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/identity/EmailRegistrationServiceTest.java @@ -18,20 +18,20 @@ class EmailRegistrationServiceTest { private static final Instant NOW = Instant.parse("2026-08-01T12:00:00Z"); @Test - void signupStoresOnlyProtectedEmailPasswordHashAndTokenDigest() { + void signupCreatesAnActiveAccountWithoutIssuingAVerificationToken() { var commands = new RecordingRegistrationPort(); var service = service(false, commands); SignupResult result = service.signup(new SignupCommand( " Person@Example.com ", "ValidPass!2026", UUID.randomUUID(), "192.0.2.0/24")); - assertThat(result.verificationToken()).isEqualTo("raw-verification-token"); - assertThat(commands.registrations).singleElement().satisfies(registration -> { + assertThat(result.verificationToken()).isNull(); + assertThat(result.expiresAt()).isNull(); + assertThat(commands.activeRegistrations).singleElement().satisfies(registration -> { assertThat(registration.email().normalized()).isEqualTo("person@example.com"); assertThat(registration.email().ciphertext()).isEqualTo("ciphertext:person@example.com"); assertThat(registration.email().lookupHmac()).isEqualTo("lookup:person@example.com"); assertThat(registration.password().encodedHash()).isEqualTo("hash:ValidPass!2026"); - assertThat(registration.verificationTokenDigest()).isEqualTo("digest:raw-verification-token"); assertThat(registration.preferences().languageCode()).isEqualTo("ko"); assertThat(registration.preferences().timezoneName()).isEqualTo("America/New_York"); assertThat(registration.preferences().themePreference()).isEqualTo(ThemePreference.SYSTEM); @@ -48,7 +48,7 @@ void signupCarriesConfiguredValidatedPreferenceDefaultsIntoTheAtomicRegistration service.signup(new SignupCommand( "person@example.com", "ValidPass!2026", UUID.randomUUID(), null)); - assertThat(commands.registrations).singleElement().satisfies(registration -> { + assertThat(commands.activeRegistrations).singleElement().satisfies(registration -> { assertThat(registration.preferences().languageCode()).isEqualTo("en"); assertThat(registration.preferences().timezoneName()).isEqualTo("America/Chicago"); assertThat(registration.preferences().themePreference()).isEqualTo(ThemePreference.SYSTEM); @@ -68,7 +68,7 @@ void duplicateEmailAndShortPasswordAreRejected() { } @Test - void pendingEmailSignupKeepsTheOriginalPasswordAndVerificationToken() { + void pendingEmailSignupActivatesTheExistingAccountWithoutReplacingItsPassword() { UUID accountId = UUID.randomUUID(); var commands = new RecordingRegistrationPort(); var queries = new RegistrationQueryPort() { @@ -96,8 +96,11 @@ public Optional findEmailRegistration( assertThat(result.accountId()).isEqualTo(accountId); assertThat(result.verificationToken()).isNull(); + assertThat(result.expiresAt()).isNull(); assertThat(commands.registrations).isEmpty(); + assertThat(commands.activeRegistrations).isEmpty(); assertThat(commands.replacements).isEmpty(); + assertThat(commands.activatedAccounts).containsExactly(accountId); } @Test @@ -189,14 +192,27 @@ private static EmailRegistrationService service( () -> new VerificationToken("raw-verification-token", "digest:raw-verification-token"), raw -> "digest:" + raw, defaults, + false, Clock.fixed(NOW, ZoneOffset.UTC)); } private static final class RecordingRegistrationPort implements RegistrationCommandPort { + private final List activeRegistrations = new ArrayList<>(); private final List registrations = new ArrayList<>(); private final List replacements = new ArrayList<>(); + private final List activatedAccounts = new ArrayList<>(); private VerificationOutcome verificationOutcome = VerificationOutcome.VERIFIED; + @Override + public void createActive(ActiveEmailRegistration registration) { + activeRegistrations.add(registration); + } + + @Override + public void activatePending(UUID accountId, Instant activatedAt, UUID correlationId) { + activatedAccounts.add(accountId); + } + @Override public void createPending(PendingRegistration registration) { registrations.add(registration); diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java index 8ed007aa..e1199c23 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/identity/IdentityJpaCommandAdapter.java @@ -1,6 +1,7 @@ package com.idea2strategy.backend.persistence.identity; import com.idea2strategy.backend.application.identity.RefreshTokenFamily; +import com.idea2strategy.backend.application.identity.ActiveEmailRegistration; import com.idea2strategy.backend.application.identity.AuthenticationSuccess; import com.idea2strategy.backend.application.identity.AuthenticationRejectedException; import com.idea2strategy.backend.application.identity.ActivateOidcLink; @@ -141,6 +142,158 @@ public void createActiveRegistration(PendingOidcRegistration registration) { now); } + @Override + @Transactional + public void createActive(ActiveEmailRegistration registration) { + OffsetDateTime now = utc(registration.registeredAt()); + try { + guardIdentifierReuse("EMAIL", "PASSWORD", registration.email().comparisonFingerprints(), null); + } catch (AuthenticationRejectedException rejected) { + throw new DuplicateEmailException(); + } + entityManager.createNativeQuery(""" + insert into identity.accounts (id, lifecycle_status, status_changed_at, created_at) + values (:id, cast('ACTIVE' as identity.account_lifecycle_status), :now, :now) + """) + .setParameter("id", registration.accountId()) + .setParameter("now", now) + .executeUpdate(); + entityManager.createNativeQuery(""" + insert into identity.account_preferences + (account_id, language_code, timezone_name, theme_preference, + created_at, updated_at) + values (:accountId, :languageCode, :timezoneName, + cast(:themePreference as identity.theme_preference), :now, :updatedAt) + """) + .setParameter("accountId", registration.accountId()) + .setParameter("languageCode", registration.preferences().languageCode()) + .setParameter("timezoneName", registration.preferences().timezoneName()) + .setParameter("themePreference", registration.preferences().themePreference().name()) + .setParameter("now", now) + .setParameter("updatedAt", utc(registration.preferences().updatedAt())) + .executeUpdate(); + entityManager.createNativeQuery(""" + insert into identity.account_security_states (account_id, auth_epoch, updated_at) + values (:accountId, 1, :now) + """) + .setParameter("accountId", registration.accountId()) + .setParameter("now", now) + .executeUpdate(); + entityManager.createNativeQuery(""" + insert into identity.account_emails + (account_id, email_ciphertext, email_lookup_hmac, email_lookup_key_version, + encryption_key_version, status, verified_at, created_at) + values (:accountId, :ciphertext, :lookupHmac, :lookupVersion, :encryptionVersion, + cast('VERIFIED' as identity.email_status), :now, :now) + """) + .setParameter("accountId", registration.accountId()) + .setParameter("ciphertext", registration.email().ciphertext()) + .setParameter("lookupHmac", registration.email().lookupHmac()) + .setParameter("lookupVersion", registration.email().lookupKeyVersion()) + .setParameter("encryptionVersion", registration.email().encryptionKeyVersion()) + .setParameter("now", now) + .executeUpdate(); + entityManager.createNativeQuery(""" + insert into identity.login_identities + (id, account_id, provider_id, status, created_at, linked_at, activated_at) + select :id, :accountId, id, cast('ACTIVE' as identity.login_identity_status), + :now, :now, :now + from identity.auth_providers where code = 'PASSWORD' and is_active = true + """) + .setParameter("id", registration.loginIdentityId()) + .setParameter("accountId", registration.accountId()) + .setParameter("now", now) + .executeUpdate(); + entityManager.createNativeQuery(""" + insert into identity.password_credentials + (login_identity_id, password_hash, hash_scheme, hash_parameters, + credential_version, password_changed_at) + values (:loginId, :hash, :scheme, cast(:parameters as jsonb), 1, :now) + """) + .setParameter("loginId", registration.loginIdentityId()) + .setParameter("hash", registration.password().encodedHash()) + .setParameter("scheme", registration.password().scheme()) + .setParameter("parameters", registration.password().parametersJson()) + .setParameter("now", now) + .executeUpdate(); + insertAuthenticationEvent( + registration.accountId(), + "SIGNUP_COMPLETED", + registration.loginIdentityId(), + "USER", + null, + registration.correlationId(), + "signup:" + registration.correlationId(), + now); + } + + @Override + @Transactional + public void activatePending(UUID accountId, Instant activatedAt, UUID correlationId) { + Object[] row; + try { + row = (Object[]) entityManager.createNativeQuery(""" + select account.lifecycle_status::text, email.status::text, login.id + from identity.accounts account + join identity.account_emails email on email.account_id = account.id + join identity.login_identities login on login.account_id = account.id + join identity.auth_providers provider on provider.id = login.provider_id + where account.id = :accountId and provider.code = 'PASSWORD' + for update of account, email, login + """) + .setParameter("accountId", accountId) + .getSingleResult(); + } catch (NoResultException exception) { + throw new DuplicateEmailException(); + } + if (!"PENDING_VERIFICATION".equals(row[0]) || !"PENDING_VERIFICATION".equals(row[1])) { + throw new DuplicateEmailException(); + } + OffsetDateTime now = utc(activatedAt); + UUID loginId = (UUID) row[2]; + entityManager.createNativeQuery(""" + update identity.email_verification_requests set revoked_at = :now + where account_id = :accountId and consumed_at is null and revoked_at is null + """) + .setParameter("now", now) + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery(""" + update identity.account_emails + set status = cast('VERIFIED' as identity.email_status), verified_at = :now + where account_id = :accountId + """) + .setParameter("now", now) + .setParameter("accountId", accountId) + .executeUpdate(); + entityManager.createNativeQuery(""" + update identity.login_identities + set status = cast('ACTIVE' as identity.login_identity_status), linked_at = :now, + activated_at = :now + where id = :loginId and status = cast('PENDING' as identity.login_identity_status) + """) + .setParameter("now", now) + .setParameter("loginId", loginId) + .executeUpdate(); + transitionLifecycle( + accountId, + "PENDING_VERIFICATION", + "ACTIVE", + "EMAIL_VERIFICATION_BYPASSED", + correlationId, + "signup-activation:" + correlationId, + now); + insertAuthenticationEvent( + accountId, + "EMAIL_VERIFICATION_BYPASSED", + loginId, + "USER", + null, + correlationId, + "signup-activation:" + correlationId, + now); + } + @Override @Transactional public void createPending(PendingRegistration registration) { diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java index 15d39c78..81c08464 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/identity/IdentityPersistenceIntegrationTest.java @@ -201,6 +201,54 @@ void signupVerificationAndLoginShareOneTransactionalIdentityModel() { .isInstanceOf(DuplicateEmailException.class); } + @Test + void signupWithoutEmailDeliveryIsImmediatelyLoginReady() { + var tokenSequence = new AtomicInteger(); + var registration = registrationService(tokenSequence, false); + var signup = registration.signup(new SignupCommand( + "immediate@example.com", "ValidPass!2026", UUID.randomUUID(), "192.0.2.0/24")); + + assertThat(signup.verificationToken()).isNull(); + assertThat(signup.expiresAt()).isNull(); + assertThat(authenticationService().login(new LoginCommand( + "immediate@example.com", "ValidPass!2026", UUID.randomUUID())).accountId()) + .isEqualTo(signup.accountId()); + assertThat(jdbcTemplate.queryForObject( + "select lifecycle_status::text from identity.accounts where id = ?", + String.class, + signup.accountId())) + .isEqualTo("ACTIVE"); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.email_verification_requests where account_id = ?", + Integer.class, + signup.accountId())) + .isZero(); + } + + @Test + void signupWithoutEmailDeliveryActivatesAnExistingPendingAccountWithoutReplacingItsPassword() { + var tokenSequence = new AtomicInteger(); + var pending = registrationService(tokenSequence).signup(new SignupCommand( + "pending@example.com", "OriginalPass!2026", UUID.randomUUID(), "192.0.2.0/24")); + + var activated = registrationService(tokenSequence, false).signup(new SignupCommand( + "pending@example.com", "ReplacementPass!2026", UUID.randomUUID(), "192.0.2.0/24")); + + assertThat(activated.accountId()).isEqualTo(pending.accountId()); + assertThat(authenticationService().login(new LoginCommand( + "pending@example.com", "OriginalPass!2026", UUID.randomUUID())).accountId()) + .isEqualTo(pending.accountId()); + assertThatThrownBy(() -> authenticationService().login(new LoginCommand( + "pending@example.com", "ReplacementPass!2026", UUID.randomUUID()))) + .isInstanceOf(AuthenticationRejectedException.class); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from identity.email_verification_requests " + + "where account_id = ? and revoked_at is not null", + Integer.class, + pending.accountId())) + .isEqualTo(1); + } + @Test void verificationResendEnforcesCooldownAndFiveRequestsPerAccountPerHour() { var tokenSequence = new AtomicInteger(); @@ -682,6 +730,31 @@ private EmailRegistrationService registrationService(AtomicInteger tokenSequence }); } + private EmailRegistrationService registrationService( + AtomicInteger tokenSequence, boolean verificationRequired) { + return new EmailRegistrationService( + queryAdapter, + commandAdapter, + raw -> { + String normalized = raw.trim().toLowerCase(); + return new ProtectedEmail( + normalized, + "ciphertext:" + normalized, + "lookup:" + normalized, + (short) 1, + (short) 1); + }, + new NistPasswordPolicy(List.of()), + raw -> new PasswordHash("hash:" + raw, "TEST", "{}"), + () -> { + String raw = "raw-verification-token-" + tokenSequence.incrementAndGet() + "-" + UUID.randomUUID(); + return new VerificationToken(raw, "digest:" + raw); + }, + raw -> "digest:" + raw, + verificationRequired, + Clock.fixed(NOW, ZoneOffset.UTC)); + } + private EmailRegistrationService registrationService(AtomicInteger tokenSequence, Instant now) { return registrationService(tokenSequence, raw -> { String normalized = raw.trim().toLowerCase();