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..680788de 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,40 @@ 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 signupWithoutVerificationTokenReportsThatVerificationIsNotRequired() { + var registration = mock(EmailRegistrationService.class); + var authentication = mock(EmailAuthenticationService.class); + var delivery = mock(VerificationDeliveryPort.class); + UUID accountId = UUID.randomUUID(); + when(registration.signup(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SignupResult(accountId, null, null)); + var controller = new IdentityAuthController(registration, authentication, delivery, jwt(), cookies()); + + var response = controller.signup( + new IdentityAuthController.SignupRequest("person@example.com", "ValidPass!2026"), + UUID.randomUUID().toString(), + "192.0.2.0/24"); + + assertThat(response.getStatusCode().value()).isEqualTo(202); + assertThat(response.getBody().verificationRequired()).isFalse(); + assertThat(response.getBody().verificationExpiresAt()).isNull(); + verifyNoInteractions(delivery); + } + + @Test + 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 +105,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/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java index 1e8db968..06a98bdb 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java @@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.idea2strategy.backend.api.identity.AccountVerificationEmailRequested; import java.nio.charset.StandardCharsets; import java.util.Base64; import org.junit.jupiter.api.Test; @@ -15,8 +14,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.event.ApplicationEvents; -import org.springframework.test.context.event.RecordApplicationEvents; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; @@ -41,7 +38,6 @@ */ @Testcontainers(disabledWithoutDocker = true) @SpringBootTest -@RecordApplicationEvents class ExternalToolDelegatedEditJourneyIntegrationTest { private static final String EMAIL = "delegated-edit@example.com"; private static final String PASSWORD = "CorrectHorse!2026"; @@ -69,28 +65,20 @@ static void properties(DynamicPropertyRegistry registry) { @Autowired WebApplicationContext context; @Autowired ObjectMapper json; - @Autowired ApplicationEvents events; @Test void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception { MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build(); - mvc.perform(post("/api/v1/auth/signup") + JsonNode signup = json.readTree(mvc.perform(post("/api/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(""" {"email":"%s","password":"%s","nickname":"delegator"} """.formatted(EMAIL, PASSWORD))) - .andExpect(status().isAccepted()); - String verificationToken = events.stream(AccountVerificationEmailRequested.class) - .findFirst() - .map(AccountVerificationEmailRequested::verificationToken) - .orElseThrow(); - mvc.perform(post("/api/v1/auth/verify-email") - .contentType(MediaType.APPLICATION_JSON) - .content(""" - {"verificationToken":"%s"} - """.formatted(verificationToken))) - .andExpect(status().isNoContent()); + .andExpect(status().isAccepted()) + .andReturn().getResponse().getContentAsString()); + assertThat(signup.path("verificationRequired").asBoolean()).isFalse(); + assertThat(signup.path("verificationExpiresAt").isNull()).isTrue(); String accessToken = json.readTree(mvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) 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/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java index 21060438..34e0a557 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java @@ -15,10 +15,15 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.UUID; public final class BasicBlockAssemblyValidator { + private static final int MAX_SECTIONS = 4; + private static final int MAX_CONTAINERS_PER_SIDE = MAX_SECTIONS; + private static final int MAX_INSTRUMENTS_PER_GROUP = 5; + private static final int MAX_CONDITIONS_PER_GROUP = 5; private final ObjectMapper objectMapper; public BasicBlockAssemblyValidator() { @@ -43,6 +48,17 @@ public BasicBlockAssemblyValidationResult validate(BasicBlockAssembly assembly, catalog.instruments().forEach(instrument -> supportedInstruments.add(instrument.id())); boolean terminalStructurePublished = catalog.elements().stream().anyMatch(this::isTerminal); + if (assembly.groups().stream().filter(group -> group.container() == BasicBlockAssembly.TradeContainer.BUY).count() + > MAX_CONTAINERS_PER_SIDE) { + add(issues, "TOO_MANY_BUY_CONTAINERS", "groups", + "A Basic strategy may contain at most four buy containers"); + } + if (assembly.groups().stream().filter(group -> group.container() == BasicBlockAssembly.TradeContainer.SELL).count() + > MAX_CONTAINERS_PER_SIDE) { + add(issues, "TOO_MANY_SELL_CONTAINERS", "groups", + "A Basic strategy may contain at most four sell containers"); + } + for (int groupIndex = 0; groupIndex < assembly.groups().size(); groupIndex++) { validateGroup( assembly.groups().get(groupIndex), @@ -71,6 +87,13 @@ private void validateGroup( add(issues, "EQUAL_ALLOCATION_REQUIRED", groupPath + ".allocationMode", "Basic permits equal allocation only"); } + if (group.instrumentIds().isEmpty()) { + add(issues, "INSTRUMENT_REQUIRED", groupPath + ".instrumentIds", + "A Basic container needs at least one instrument"); + } else if (group.instrumentIds().size() > MAX_INSTRUMENTS_PER_GROUP) { + add(issues, "TOO_MANY_INSTRUMENTS", groupPath + ".instrumentIds", + "A Basic section may contain at most five instruments"); + } for (int instrumentIndex = 0; instrumentIndex < group.instrumentIds().size(); instrumentIndex++) { if (!supportedInstruments.contains(group.instrumentIds().get(instrumentIndex))) { add(issues, "UNSUPPORTED_INSTRUMENT", groupPath + ".instrumentIds[" + instrumentIndex + "]", @@ -96,6 +119,16 @@ private void validateGroup( if (terminalStructurePublished) { validateTerminalStructure(group, groupIndex, blockDefinitions, issues); + long conditionCount = group.blocks().stream() + .map(block -> blockDefinitions.get(block.id())) + .filter(Objects::nonNull) + .filter(definition -> !isTerminal(definition)) + .filter(definition -> !"TRIGGER".equals(definition.elementKind())) + .count(); + if (conditionCount > MAX_CONDITIONS_PER_GROUP) { + add(issues, "TOO_MANY_CONDITIONS", groupPath + ".blocks", + "A Basic container may contain at most five conditions"); + } } if (!isSequential(group)) { 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-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java index 1dba617e..bad6d38b 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java @@ -150,6 +150,40 @@ void rejectsImpossiblePeriodsAndNonNumericThresholdsBeforeRelease() { .contains("IMPOSSIBLE_PERIOD_COMBINATION", "INVALID_PARAMETER_VALUE"); } + @Test + void enforcesPublishedBasicCompositionLimits() { + List sixInstruments = java.util.stream.IntStream.range(0, 6) + .mapToObj(index -> index == 0 ? AAPL_ID : UUID.randomUUID()) + .toList(); + List blocks = new java.util.ArrayList<>(); + for (int index = 0; index < 6; index++) { + blocks.add(new BasicBlock("condition-" + index, "RSI", Map.of("period", 14))); + } + blocks.add(new BasicBlock("order", "BUY_ORDER", Map.of())); + List connections = new java.util.ArrayList<>(); + for (int index = 0; index < blocks.size() - 1; index++) { + connections.add(new BasicBlockConnection( + blocks.get(index).id(), "result", blocks.get(index + 1).id(), "input")); + } + var oversized = new BasicBlockGroup( + "oversized", TradeContainer.BUY, EvaluationMode.INDEPENDENT, AllocationMode.EQUAL, + sixInstruments, blocks, connections); + List groups = new java.util.ArrayList<>(); + groups.add(oversized); + for (int index = 0; index < 4; index++) { + groups.add(group("extra-buy-" + index, TradeContainer.BUY, "BUY_ORDER")); + } + + BasicBlockAssemblyValidationResult result = validator.validate( + new BasicBlockAssembly(CATALOG_ID, groups), catalog()); + + assertThat(result.issues()).extracting(BasicBlockAssemblyIssue::code, BasicBlockAssemblyIssue::location) + .contains( + org.assertj.core.groups.Tuple.tuple("TOO_MANY_BUY_CONTAINERS", "groups"), + org.assertj.core.groups.Tuple.tuple("TOO_MANY_INSTRUMENTS", "groups[0].instrumentIds"), + org.assertj.core.groups.Tuple.tuple("TOO_MANY_CONDITIONS", "groups[0].blocks")); + } + private static BasicBlockGroup group(String id, TradeContainer container, String orderElement) { return new BasicBlockGroup( id, diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java index 7610408a..d2dcf930 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java @@ -242,7 +242,8 @@ private void requireObjectCoverage(UUID manifestId, OffsetDateTime requiredStart + "where dox.dataset_manifest_id = ? " + "order by dox.period_start, dox.period_end, dox.shard_key, dox.part_number, dox.id", manifestId); - OffsetDateTime coveredUntil = requiredStart; + OffsetDateTime latestEnd = null; + OffsetDateTime previousStart = null; for (Record receipt : receipts) { OffsetDateTime membershipStart = receipt.get("membership_period_start", OffsetDateTime.class); OffsetDateTime membershipEnd = receipt.get("membership_period_end", OffsetDateTime.class); @@ -257,17 +258,23 @@ private void requireObjectCoverage(UUID manifestId, OffsetDateTime requiredStart || membershipRows.longValue() != objectRows.longValue()) { throw incompleteObjects(); } + if (previousStart != null && membershipStart.isBefore(previousStart)) { + throw incompleteObjects(); + } + previousStart = membershipStart; if (!membershipEnd.isAfter(requiredStart)) { continue; } - if (membershipStart.isAfter(coveredUntil)) { - throw incompleteObjects(); - } - if (membershipEnd.isAfter(coveredUntil)) { - coveredUntil = membershipEnd; + if (latestEnd == null || membershipEnd.isAfter(latestEnd)) { + latestEnd = membershipEnd; } } - if (coveredUntil.isBefore(requiredEnd)) { + /* Feature rows are sparse on market holidays and begin only after the calculator's + warm-up window. The manifest/materialization interval is the authoritative requested + coverage; object receipts describe rows that actually exist and therefore must not be + forced into a gapless wall-clock interval. We still require authoritative receipt + metadata and an output reaching the evaluation boundary. */ + if (latestEnd == null || latestEnd.isBefore(requiredEnd)) { throw incompleteObjects(); } } 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/backtest/FeatureMaterializationPinResolverIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java index 91d10729..3f37067a 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java @@ -31,7 +31,7 @@ class FeatureMaterializationPinResolverIntegrationTest { private static final UUID PROVIDER = FeatureMaterializationPinResolver.deterministicUuid( "provider", "IDEA2STRATEGY_INTERNAL"); private static final UUID FEED = FeatureMaterializationPinResolver.deterministicUuid( - "feature-output-feed", DEFINITION_HASH, "rsi:1.0.0", "1d", + "feature-output-feed", DEFINITION_HASH, "rsi:test.0.0", "1d", FeatureMaterializationPinResolver.OUTPUT_SCHEMA); private static final UUID INSTRUMENT = id(4); private static final UUID FEATURE = UUID.fromString("eddfb2d4-8586-5260-8fc9-9c8125990270"); @@ -76,8 +76,8 @@ void seed() { var created = AS_OF.minusDays(1); jdbc.update("insert into market_data.feeds " + "(id, provider_id, code, data_kind, resolution, timezone_name, feed_version, created_at) " - + "values (?, ?, 'FEATURE_RSI_14_1D_RSI_1_0_0', 'FEATURE_SERIES', '1d', 'UTC', " - + "'rsi-1.0.0+feature-series.parquet.v1', ?)", + + "values (?, ?, 'FEATURE_RSI_14_1D_RSI_TEST_0_0', 'FEATURE_SERIES', '1d', 'UTC', " + + "'rsi-test.0.0+feature-series.parquet.v1', ?)", FEED, PROVIDER, created); jdbc.update("insert into market_data.instruments " + "(id, asset_type, primary_exchange_mic, currency_code) values (?, 'STOCK', 'XNAS', 'USD')", @@ -85,7 +85,7 @@ void seed() { jdbc.update("insert into market_data.feature_definitions " + "(id, element_catalog_version_id, feature_code, calculator_version, resolution, " + "normalized_parameters, output_value_type, required_history_points, definition_hash) " - + "values (?, ?, 'RSI_14', 'rsi:1.0.0', '1d', '{}'::jsonb, 'DECIMAL', 14, ?)", + + "values (?, ?, 'RSI_14', 'rsi:test.0.0', '1d', '{}'::jsonb, 'DECIMAL', 14, ?)", FEATURE, CATALOG, DEFINITION_HASH); seedMaterialization(MATERIALIZATION, PIPELINE, MANIFEST, OBJECT, DATASET_OBJECT, "b".repeat(64)); } @@ -176,13 +176,13 @@ void rejectsStaleUnavailableAndHashInconsistentPublicationMetadata() { @Test void rejectsAFeedThatDoesNotMatchTheDefinitionsDeterministicIdentity() { - jdbc.update("update market_data.feeds set code = 'FEATURE_OTHER_1D_RSI_1_0_0' where id = ?", FEED); + jdbc.update("update market_data.feeds set code = 'FEATURE_OTHER_1D_RSI_TEST_0_0' where id = ?", FEED); assertRejected("feature output feed identity"); - jdbc.update("update market_data.feeds set code = 'FEATURE_RSI_14_1D_RSI_1_0_0' where id = ?", FEED); + jdbc.update("update market_data.feeds set code = 'FEATURE_RSI_14_1D_RSI_TEST_0_0' where id = ?", FEED); jdbc.update("update market_data.feeds set feed_version = 'rsi-2.0.0+feature-series.parquet.v1' where id = ?", FEED); assertRejected("feature output feed identity"); - jdbc.update("update market_data.feeds set feed_version = 'rsi-1.0.0+feature-series.parquet.v1' where id = ?", FEED); + jdbc.update("update market_data.feeds set feed_version = 'rsi-test.0.0+feature-series.parquet.v1' where id = ?", FEED); jdbc.update("update market_data.providers set rights_version = 'wrong-rights' where id = ?", PROVIDER); assertRejected("feature output feed identity"); @@ -216,6 +216,17 @@ void rejectsObjectReceiptsWhosePeriodsOrRowsDoNotAuthoritativelyCoverTheManifest assertRejected("complete versioned"); } + @Test + void acceptsSparseFeatureRowsThatBeginAfterTheRequestedWarmupWindow() { + jdbc.update("update market_data.dataset_objects set period_start = '2024-01-05T00:00:00Z' where id = ?", + DATASET_OBJECT); + jdbc.update("update storage.objects set period_start = '2024-01-05T00:00:00Z' where id = ?", OBJECT); + + assertThat(resolver.resolve( + plan(), LocalDate.parse("2024-01-01"), LocalDate.parse("2024-12-31"), AS_OF)) + .containsExactly(new BacktestRunInputPinWriter.FeaturePin(MATERIALIZATION, "sha256:" + HASH)); + } + private void assertRejected(String message) { assertRejected(plan(), message); } @@ -267,7 +278,7 @@ private void seedMaterialization( private static String plan() { return "{\"requiredFeatures\":[{\"requirementId\":\"rsi-14-pt24h\",\"featureId\":\"" + FEATURE - + "\",\"featureVersion\":\"1.0.0\",\"instruments\":[\"" + INSTRUMENT + + "\",\"featureVersion\":\"test.0.0\",\"instruments\":[\"" + INSTRUMENT + "\"],\"resolution\":\"PT24H\",\"requiredObservations\":13}]}"; } 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();