diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/SubscriptionProvisioningService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/SubscriptionProvisioningService.java new file mode 100644 index 00000000..0de71b5b --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/SubscriptionProvisioningService.java @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.service; + +import java.util.List; +import java.util.UUID; + +/** + * Provisions an {@link org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity} + * aggregate (Authorization + 1–2 Subscriptions) from an OAuth2 grant. + * + *

This is the data-custodian side of the AS↔DC back-channel: the AS calls into DC at + * token-mint time with the granted scope, the customer identity, and the set of usage points the + * customer chose to share. DC parses the scope (see {@link org.greenbuttonalliance.espi.common.scope.EspiScope}), + * creates the Authorization aggregate (PR B1 model), and returns the canonical resource URIs that + * the AS will include in the token response.

+ * + *

Invariants (from PR B1):

+ * + * + *

Not part of the ESPI standard — this is an implementation contract between the + * GBA Authorization Server and a Data Custodian sandbox.

+ * + * @see SubscriptionProvisionCommand + * @see SubscriptionProvisionResult + */ +public interface SubscriptionProvisioningService { + + /** + * Provisions the Authorization aggregate for a completed OAuth2 grant. + * + * @param command the grant details from the AS + * @return the canonical URIs and IDs the AS should publish in the token response + * @throws IllegalArgumentException if the command is null, references unknown entities, or + * carries an unparseable scope + */ + SubscriptionProvisionResult provisionFromGrant(SubscriptionProvisionCommand command); + + /** + * Command-side payload for {@link #provisionFromGrant(SubscriptionProvisionCommand)}. + * + * @param correlationId opaque AS-supplied trace id; logged but not persisted + * @param clientId OAuth2 {@code client_id} of the granted TP (must match a + * registered {@code ApplicationInformation}) + * @param grantedScope the ESPI scope string the customer approved (must parse via + * {@link org.greenbuttonalliance.espi.common.scope.EspiScope#parse}) + * @param retailCustomerId primary key of the {@code RetailCustomer} that authenticated + * (note: {@code RetailCustomerEntity} still uses {@code Long} + * ids; tracked separately from the UUID5 migration) + * @param selectedUsagePointIds usage points the customer chose to share with this TP; may be + * empty for grants that include only customer/PII scope + * @param customerResourceUri absolute URI for the customer/PII resource the TP may GET; + * must be present iff the scope includes a Customer/PII FB + */ + record SubscriptionProvisionCommand( + String correlationId, + String clientId, + String grantedScope, + Long retailCustomerId, + List selectedUsagePointIds, + String customerResourceUri + ) {} + + /** + * Result of a successful provisioning call. + * + * @param authorizationId UUID of the persisted Authorization aggregate + * @param resourceSubscriptionId UUID of the energy Subscription, or {@code null} for a + * PII-only grant (no usage points selected) + * @param customerSubscriptionId UUID of the customer/PII Subscription, or {@code null} if the + * grant did not include Customer/PII scope + * @param resourceUri absolute URI the TP polls for energy data + * ({@code .../resource/Subscription/{id}}), or {@code null} for + * a PII-only grant + * @param authorizationUri absolute URI of the Authorization resource + * ({@code .../resource/Authorization/{id}}) + * @param customerResourceUri absolute URI the TP polls for customer/PII data, or + * {@code null} if no Customer/PII scope was granted + */ + record SubscriptionProvisionResult( + UUID authorizationId, + UUID resourceSubscriptionId, + UUID customerSubscriptionId, + String resourceUri, + String authorizationUri, + String customerResourceUri + ) {} +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImpl.java new file mode 100644 index 00000000..001ae019 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImpl.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.repositories.usage.ApplicationInformationRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.greenbuttonalliance.espi.common.scope.EspiScope; +import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * Default implementation of {@link SubscriptionProvisioningService}. + * + *

Builds the {@link AuthorizationEntity} aggregate and persists it via the aggregate root: + * {@code cascade = ALL} on {@code AuthorizationEntity.subscriptions} (PR B1) carries the + * Subscriptions through with one {@code save}. URIs returned to the AS are absolute and use + * {@code espi.resources.base-uri}.

+ */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class SubscriptionProvisioningServiceImpl implements SubscriptionProvisioningService { + + private final AuthorizationRepository authorizationRepository; + private final ApplicationInformationRepository applicationInformationRepository; + private final RetailCustomerRepository retailCustomerRepository; + private final UsagePointRepository usagePointRepository; + private final EspiIdGeneratorService idGeneratorService; + + /** + * Canonical absolute base URI for ESPI resources, used to build {@code resource_uri} and + * {@code authorization_uri} returned to the AS. Defaulted for unit tests; overridden in + * production by {@code application.yml}. + */ + @Value("${espi.resources.base-uri:http://localhost:8081/DataCustodian/espi/1_1/resource}") + private String resourceBaseUri; + + @Override + public SubscriptionProvisionResult provisionFromGrant(SubscriptionProvisionCommand command) { + validate(command); + + EspiScope scope = EspiScope.parse(command.grantedScope()); + + ApplicationInformationEntity application = applicationInformationRepository + .findByClientId(command.clientId()) + .orElseThrow(() -> new IllegalArgumentException( + "Unknown client_id: " + command.clientId())); + + RetailCustomerEntity customer = retailCustomerRepository + .findById(command.retailCustomerId()) + .orElseThrow(() -> new IllegalArgumentException( + "Unknown retail_customer_id: " + command.retailCustomerId())); + + List usagePoints = resolveUsagePoints(command.selectedUsagePointIds(), customer); + boolean includesEnergy = !usagePoints.isEmpty(); + boolean includesPii = scope.includesCustomerPii(); + + if (!includesEnergy && !includesPii) { + throw new IllegalArgumentException( + "Grant must include at least one selected usage point OR a Customer/PII FB scope"); + } + if (includesPii && (command.customerResourceUri() == null || command.customerResourceUri().isBlank())) { + throw new IllegalArgumentException( + "customer_resource_uri is required when scope includes a Customer/PII FB (54-62)"); + } + if (!includesPii && command.customerResourceUri() != null && !command.customerResourceUri().isBlank()) { + throw new IllegalArgumentException( + "customer_resource_uri must be absent when scope does not include a Customer/PII FB"); + } + + AuthorizationEntity authorization = newAuthorization(command, application, customer, includesPii); + + SubscriptionEntity resourceSubscription = includesEnergy + ? newSubscription(command, application, customer, usagePoints, authorization) + : null; + SubscriptionEntity customerSubscription = includesPii + ? newSubscription(command, application, customer, List.of(), authorization) + : null; + + String resourceUri = resourceSubscription != null ? subscriptionUri(resourceSubscription.getId()) : null; + if (resourceUri != null) { + authorization.setResourceURI(resourceUri); + } + String authorizationUri = authorizationUri(authorization.getId()); + authorization.setAuthorizationURI(authorizationUri); + + AuthorizationEntity persisted = authorizationRepository.save(authorization); + + log.info("Provisioned authorization {} for client {} customer {} (correlation_id={}, pii={}, usagePoints={})", + persisted.getId(), command.clientId(), command.retailCustomerId(), + command.correlationId(), includesPii, usagePoints.size()); + + return new SubscriptionProvisionResult( + persisted.getId(), + resourceSubscription != null ? resourceSubscription.getId() : null, + customerSubscription != null ? customerSubscription.getId() : null, + resourceUri, + authorizationUri, + persisted.getCustomerResourceURI() + ); + } + + private void validate(SubscriptionProvisionCommand command) { + Objects.requireNonNull(command, "command"); + if (command.clientId() == null || command.clientId().isBlank()) { + throw new IllegalArgumentException("client_id is required"); + } + if (command.grantedScope() == null || command.grantedScope().isBlank()) { + throw new IllegalArgumentException("granted_scope is required"); + } + if (command.retailCustomerId() == null) { + throw new IllegalArgumentException("retail_customer_id is required"); + } + } + + private List resolveUsagePoints(List ids, RetailCustomerEntity customer) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + List resolved = new ArrayList<>(ids.size()); + for (UUID id : ids) { + UsagePointEntity up = usagePointRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Unknown usage_point_id: " + id)); + if (up.getRetailCustomer() == null || !customer.getId().equals(up.getRetailCustomer().getId())) { + throw new IllegalArgumentException( + "usage_point_id " + id + " does not belong to retail_customer_id " + customer.getId()); + } + resolved.add(up); + } + return resolved; + } + + private AuthorizationEntity newAuthorization(SubscriptionProvisionCommand command, + ApplicationInformationEntity application, + RetailCustomerEntity customer, + boolean includesPii) { + AuthorizationEntity authorization = new AuthorizationEntity(customer, application, command.grantedScope()); + authorization.setId(UUID.randomUUID()); + authorization.setThirdParty(command.clientId()); + authorization.setStatus(AuthorizationEntity.STATUS_ACTIVE); + if (includesPii) { + authorization.setCustomerResourceURI(command.customerResourceUri()); + } + return authorization; + } + + private SubscriptionEntity newSubscription(SubscriptionProvisionCommand command, + ApplicationInformationEntity application, + RetailCustomerEntity customer, + List usagePoints, + AuthorizationEntity authorization) { + UUID id = idGeneratorService.generateSubscriptionId(command.clientId(), String.valueOf(customer.getId())); + SubscriptionEntity subscription = new SubscriptionEntity(id); + subscription.setRetailCustomer(customer); + subscription.setApplicationInformation(application); + subscription.setAuthorization(authorization); + subscription.setUsagePoints(new ArrayList<>(usagePoints)); + authorization.getSubscriptions().add(subscription); + return subscription; + } + + private String subscriptionUri(UUID subscriptionId) { + return resourceBaseUri + "/Subscription/" + subscriptionId; + } + + private String authorizationUri(UUID authorizationId) { + return resourceBaseUri + "/Authorization/" + authorizationId; + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImplTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImplTest.java new file mode 100644 index 00000000..844a7d04 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/SubscriptionProvisioningServiceImplTest.java @@ -0,0 +1,290 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.repositories.usage.ApplicationInformationRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.RetailCustomerRepository; +import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionCommand; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link SubscriptionProvisioningServiceImpl}. Verifies aggregate construction, + * PII vs energy branch logic, and validation rejections against the PR B1 N:1 model. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SubscriptionProvisioningServiceImplTest { + + private static final String BASE_URI = "https://dc.example.com/DataCustodian/espi/1_1/resource"; + private static final String CLIENT_ID = "test-tp"; + private static final String CORRELATION_ID = "corr-123"; + private static final Long CUSTOMER_ID = 42L; + private static final String PII_CUSTOMER_URI = BASE_URI + "/RetailCustomer/42/Customer/xyz"; + + @Mock private AuthorizationRepository authorizationRepository; + @Mock private ApplicationInformationRepository applicationInformationRepository; + @Mock private RetailCustomerRepository retailCustomerRepository; + @Mock private UsagePointRepository usagePointRepository; + @Mock private EspiIdGeneratorService idGeneratorService; + + @InjectMocks + private SubscriptionProvisioningServiceImpl service; + + private RetailCustomerEntity customer; + private ApplicationInformationEntity application; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "resourceBaseUri", BASE_URI); + + customer = new RetailCustomerEntity(); + customer.setId(CUSTOMER_ID); + + application = new ApplicationInformationEntity(); + application.setId(UUID.randomUUID()); + application.setClientId(CLIENT_ID); + + when(idGeneratorService.generateSubscriptionId(anyString(), anyString())) + .thenAnswer(invocation -> UUID.randomUUID()); + when(authorizationRepository.save(any(AuthorizationEntity.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void energyOnlyGrant_createsResourceSubscriptionAndNoPiiSubscription() { + UUID upId = stubUsagePoint(customer); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + + SubscriptionProvisionResult result = service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), null)); + + assertThat(result.resourceSubscriptionId()).isNotNull(); + assertThat(result.customerSubscriptionId()).isNull(); + assertThat(result.resourceUri()).startsWith(BASE_URI + "/Subscription/"); + assertThat(result.authorizationUri()).startsWith(BASE_URI + "/Authorization/"); + assertThat(result.customerResourceUri()).isNull(); + + AuthorizationEntity saved = captureSavedAuthorization(); + assertThat(saved).extracting( + AuthorizationEntity::getScope, + AuthorizationEntity::getThirdParty, + AuthorizationEntity::getStatus, + AuthorizationEntity::getResourceURI, + AuthorizationEntity::getCustomerResourceURI) + .containsExactly( + "FB=4_5_15", + CLIENT_ID, + AuthorizationEntity.STATUS_ACTIVE, + result.resourceUri(), + null); + assertThat(saved.getSubscriptions()).hasSize(1); + assertThat(saved.getSubscriptions().get(0).getUsagePoints()).hasSize(1); + } + + @Test + void grantWithPiiScope_createsBothSubscriptions() { + UUID upId = stubUsagePoint(customer); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + + SubscriptionProvisionResult result = service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15_54", CUSTOMER_ID, List.of(upId), PII_CUSTOMER_URI)); + + assertThat(result.resourceSubscriptionId()).isNotNull(); + assertThat(result.customerSubscriptionId()).isNotNull(); + assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI); + + AuthorizationEntity saved = captureSavedAuthorization(); + assertThat(saved.getCustomerResourceURI()).isEqualTo(PII_CUSTOMER_URI); + assertThat(saved.getSubscriptions()).hasSize(2); + } + + @Test + void piiOnlyGrant_createsOnlyCustomerSubscription_noResourceUri() { + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + + SubscriptionProvisionResult result = service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_54", CUSTOMER_ID, List.of(), PII_CUSTOMER_URI)); + + assertThat(result.resourceSubscriptionId()).isNull(); + assertThat(result.resourceUri()).isNull(); + assertThat(result.customerSubscriptionId()).isNotNull(); + assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI); + + AuthorizationEntity saved = captureSavedAuthorization(); + assertThat(saved.getResourceURI()).isNull(); + assertThat(saved.getSubscriptions()).hasSize(1); + } + + @Test + void emptyGrant_isRejected() { + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4", CUSTOMER_ID, List.of(), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one selected usage point OR a Customer/PII"); + } + + @Test + void piiScopeWithoutCustomerUri_isRejected() { + UUID upId = stubUsagePoint(customer); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_54", CUSTOMER_ID, List.of(upId), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("customer_resource_uri is required"); + } + + @Test + void customerUriWithoutPiiScope_isRejected() { + UUID upId = stubUsagePoint(customer); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), PII_CUSTOMER_URI))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("customer_resource_uri must be absent"); + } + + @Test + void unknownClientId_isRejected() { + when(applicationInformationRepository.findByClientId("ghost")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, "ghost", "FB=4_5_15", CUSTOMER_ID, List.of(UUID.randomUUID()), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown client_id"); + } + + @Test + void unknownRetailCustomer_isRejected() { + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + when(retailCustomerRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15", 999L, List.of(UUID.randomUUID()), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown retail_customer_id"); + } + + @Test + void usagePointBelongingToDifferentCustomer_isRejected() { + UUID upId = UUID.randomUUID(); + RetailCustomerEntity other = new RetailCustomerEntity(); + other.setId(999L); + UsagePointEntity foreign = new UsagePointEntity(); + foreign.setId(upId); + foreign.setRetailCustomer(other); + + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(usagePointRepository.findById(upId)).thenReturn(Optional.of(foreign)); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not belong to retail_customer_id"); + } + + @Test + void unknownUsagePoint_isRejected() { + UUID upId = UUID.randomUUID(); + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + when(usagePointRepository.findById(upId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15", CUSTOMER_ID, List.of(upId), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown usage_point_id"); + } + + @Test + void unparseableScope_isRejected() { + when(applicationInformationRepository.findByClientId(CLIENT_ID)).thenReturn(Optional.of(application)); + when(retailCustomerRepository.findById(CUSTOMER_ID)).thenReturn(Optional.of(customer)); + + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=not_a_number", CUSTOMER_ID, List.of(), null))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void blankRequiredFields_areRejected() { + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, "", "FB=4_5_15", CUSTOMER_ID, List.of(), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("client_id"); + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, " ", CUSTOMER_ID, List.of(), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("granted_scope"); + assertThatThrownBy(() -> service.provisionFromGrant(new SubscriptionProvisionCommand( + CORRELATION_ID, CLIENT_ID, "FB=4_5_15", null, List.of(), null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("retail_customer_id"); + } + + private UUID stubUsagePoint(RetailCustomerEntity owner) { + UUID upId = UUID.randomUUID(); + UsagePointEntity up = new UsagePointEntity(); + up.setId(upId); + up.setRetailCustomer(owner); + when(usagePointRepository.findById(upId)).thenReturn(Optional.of(up)); + return upId; + } + + private AuthorizationEntity captureSavedAuthorization() { + ArgumentCaptor captor = ArgumentCaptor.forClass(AuthorizationEntity.class); + org.mockito.Mockito.verify(authorizationRepository).save(captor.capture()); + return captor.getValue(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/BackchannelSecurityConfiguration.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/BackchannelSecurityConfiguration.java new file mode 100644 index 00000000..295b73f5 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/config/BackchannelSecurityConfiguration.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security filter chain for the AS↔DC back-channel under {@code /internal/**}. + * + *

Separate from the public ESPI resource-server chain in {@link SecurityConfiguration}. This + * chain authenticates the GBA Authorization Server (the only legitimate caller) with HTTP Basic + * against a dedicated, single-purpose credential — not the OAuth2 token + * introspection used for the public API. Network-level isolation (binding {@code /internal/**} to + * an internal interface or gating it at ingress) is expected on top of this in production; mTLS is + * a future enhancement tracked separately.

+ * + *

Ordered at {@link Ordered#HIGHEST_PRECEDENCE} so the {@code securityMatcher("/internal/**")} + * is evaluated first — back-channel requests never fall through to the public chain. The + * back-channel {@link UserDetailsService} and {@link PasswordEncoder} are wired into this chain + * via a private {@link DaoAuthenticationProvider} and are NOT exposed as top-level beans, so the + * public OAuth2 resource-server chain remains unaffected.

+ * + * @see SecurityConfiguration + */ +@Configuration +public class BackchannelSecurityConfiguration { + + private static final String BACKCHANNEL_ROLE = "BACKCHANNEL"; + + @Value("${espi.backchannel.client-id:as-backchannel}") + private String backchannelClientId; + + @Value("${espi.backchannel.client-secret:change-me-in-production}") + private String backchannelClientSecret; + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain backchannelSecurityFilterChain(HttpSecurity http) throws Exception { + PasswordEncoder encoder = new BCryptPasswordEncoder(); + UserDetailsService uds = new InMemoryUserDetailsManager( + User.withUsername(backchannelClientId) + .password(encoder.encode(backchannelClientSecret)) + .roles(BACKCHANNEL_ROLE) + .build()); + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(uds); + provider.setPasswordEncoder(encoder); + + return http + .securityMatcher("/internal/**") + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .anyRequest().hasRole(BACKCHANNEL_ROLE)) + .authenticationProvider(provider) + .httpBasic(httpBasic -> {}) + .build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningController.java new file mode 100644 index 00000000..afcf8578 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningController.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionCommand; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionResult; +import org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto.SubscriptionProvisionRequest; +import org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto.SubscriptionProvisionResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * AS→DC back-channel: provisions an Authorization aggregate (Authorization + 1–2 + * Subscriptions) at OAuth2 token-mint time. + * + *

Not part of the ESPI standard. Implementation contract between the GBA + * Authorization Server and a Data Custodian sandbox. Mounted under {@code /internal/backchannel/} + * with a dedicated security filter chain (HTTP Basic with a shared back-channel credential, no + * OAuth2 introspection) so it is never reachable through the public ESPI resource-server chain.

+ */ +@Slf4j +@RestController +@RequestMapping("/internal/backchannel/v1/subscriptions") +@RequiredArgsConstructor +public class SubscriptionProvisioningController { + + private final SubscriptionProvisioningService provisioningService; + + @PostMapping + public ResponseEntity provision( + @Valid @RequestBody SubscriptionProvisionRequest request) { + + SubscriptionProvisionResult result = provisioningService.provisionFromGrant( + new SubscriptionProvisionCommand( + request.correlationId(), + request.clientId(), + request.grantedScope(), + request.retailCustomerId(), + request.selectedUsagePointIds(), + request.customerResourceUri())); + + SubscriptionProvisionResponse body = new SubscriptionProvisionResponse( + result.authorizationId(), + result.resourceSubscriptionId(), + result.customerSubscriptionId(), + result.resourceUri(), + result.authorizationUri(), + result.customerResourceUri()); + + return ResponseEntity.status(HttpStatus.CREATED).body(body); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleValidation(IllegalArgumentException e) { + log.warn("Back-channel provisioning rejected: {}", e.getMessage()); + return ResponseEntity.badRequest().body(Map.of( + "error", "invalid_request", + "error_description", e.getMessage())); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/dto/SubscriptionProvisionRequest.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/dto/SubscriptionProvisionRequest.java new file mode 100644 index 00000000..6b7afd77 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/dto/SubscriptionProvisionRequest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.List; +import java.util.UUID; + +/** + * JSON request body for {@code POST /internal/backchannel/v1/subscriptions}. + * + *

Sent by the GBA Authorization Server to a Data Custodian sandbox at token-mint time, after the + * customer has authenticated against the DC and made selections on the DC-hosted Authorization + * Screen. Implementation contract — not part of the ESPI standard.

+ * + * @param correlationId opaque AS-supplied trace id; echoed in DC logs for cross-system audit + * @param clientId OAuth2 {@code client_id} of the granted TP (must match a + * registered {@code ApplicationInformation} on DC) + * @param grantedScope ESPI scope string the customer approved + * (e.g. {@code "FB=4_5_15;IntervalDuration=3600"}) + * @param retailCustomerId DC primary key of the authenticated customer + * @param selectedUsagePointIds UUIDs of the usage points the customer chose to share with this TP; + * may be empty for grants that include only customer/PII scope + * @param customerResourceUri absolute URI for the customer/PII resource; must be present iff the + * scope includes a Customer/PII FB (54–62) + */ +public record SubscriptionProvisionRequest( + @JsonProperty("correlation_id") + @NotBlank String correlationId, + + @JsonProperty("client_id") + @NotBlank String clientId, + + @JsonProperty("granted_scope") + @NotBlank String grantedScope, + + @JsonProperty("retail_customer_id") + @NotNull Long retailCustomerId, + + @JsonProperty("selected_usage_point_ids") + List selectedUsagePointIds, + + @JsonProperty("customer_resource_uri") + String customerResourceUri +) {} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/dto/SubscriptionProvisionResponse.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/dto/SubscriptionProvisionResponse.java new file mode 100644 index 00000000..501a0992 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/dto/SubscriptionProvisionResponse.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +/** + * JSON response body for {@code POST /internal/backchannel/v1/subscriptions}. + * + *

Carries the canonical URIs the AS includes in its token response so the TP can locate the + * subscription, authorization, and (if granted) customer/PII resources. Null fields are omitted + * from serialization.

+ * + * @param authorizationId UUID of the persisted Authorization aggregate + * @param resourceSubscriptionId UUID of the energy Subscription, or {@code null} for a PII-only + * grant + * @param customerSubscriptionId UUID of the customer/PII Subscription, or {@code null} if no + * Customer/PII scope was granted + * @param resourceUri absolute URI the TP polls for energy data, or {@code null} for a + * PII-only grant + * @param authorizationUri absolute URI of the Authorization resource + * @param customerResourceUri absolute URI the TP polls for customer/PII data, or {@code null} + * if no Customer/PII scope was granted + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SubscriptionProvisionResponse( + @JsonProperty("authorization_id") + UUID authorizationId, + + @JsonProperty("resource_subscription_id") + UUID resourceSubscriptionId, + + @JsonProperty("customer_subscription_id") + UUID customerSubscriptionId, + + @JsonProperty("resource_uri") + String resourceUri, + + @JsonProperty("authorization_uri") + String authorizationUri, + + @JsonProperty("customer_resource_uri") + String customerResourceUri +) {} diff --git a/openespi-datacustodian/src/main/resources/application.yml b/openespi-datacustodian/src/main/resources/application.yml index 2b5fcbdd..ab5d5995 100644 --- a/openespi-datacustodian/src/main/resources/application.yml +++ b/openespi-datacustodian/src/main/resources/application.yml @@ -134,6 +134,13 @@ espi: introspection-endpoint: ${AUTHORIZATION_SERVER_INTROSPECTION_ENDPOINT:http://localhost:8080/oauth2/introspect} client-id: ${AUTHORIZATION_SERVER_CLIENT_ID:datacustodian} client-secret: ${AUTHORIZATION_SERVER_CLIENT_SECRET:datacustodian-secret} + + # AS↔DC back-channel (POST /internal/backchannel/v1/subscriptions). + # Implementation contract, NOT ESPI standard. Mounted on a dedicated security filter chain + # with HTTP Basic; network-level isolation is expected on top of this in production. + backchannel: + client-id: ${BACKCHANNEL_CLIENT_ID:as-backchannel} + client-secret: ${BACKCHANNEL_CLIENT_SECRET:change-me-in-production} # ESPI Resource Configuration resources: diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningControllerTest.java new file mode 100644 index 00000000..02e6fb27 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/internal/backchannel/SubscriptionProvisioningControllerTest.java @@ -0,0 +1,205 @@ +/* + * Copyright 2025 Green Button Alliance, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel; + +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionCommand; +import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService.SubscriptionProvisionResult; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * MockMvc tests for the AS↔DC back-channel endpoint and its dedicated security chain. + * Verifies auth gating (HTTP Basic with the dedicated back-channel credential), happy-path + * serialization, validation errors, and the service-level {@code IllegalArgumentException} + * exception handler. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SubscriptionProvisioningControllerTest { + + private static final String CLIENT_ID = "as-backchannel"; + private static final String CLIENT_SECRET = "change-me-in-production"; + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private SubscriptionProvisioningService provisioningService; + + @Test + void provision_withValidCredentialsAndBody_returns201AndCanonicalUris() throws Exception { + UUID authId = UUID.randomUUID(); + UUID subId = UUID.randomUUID(); + UUID upId = UUID.randomUUID(); + String resourceUri = "https://dc.example.com/espi/1_1/resource/Subscription/" + subId; + String authUri = "https://dc.example.com/espi/1_1/resource/Authorization/" + authId; + + when(provisioningService.provisionFromGrant(any(SubscriptionProvisionCommand.class))) + .thenReturn(new SubscriptionProvisionResult( + authId, subId, null, resourceUri, authUri, null)); + + String body = """ + { + "correlation_id": "corr-1", + "client_id": "test-tp", + "granted_scope": "FB=4_5_15", + "retail_customer_id": 42, + "selected_usage_point_ids": ["%s"] + } + """.formatted(upId); + + mockMvc.perform(post("/internal/backchannel/v1/subscriptions") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.authorization_id").value(authId.toString())) + .andExpect(jsonPath("$.resource_subscription_id").value(subId.toString())) + .andExpect(jsonPath("$.resource_uri").value(resourceUri)) + .andExpect(jsonPath("$.authorization_uri").value(authUri)) + .andExpect(jsonPath("$.customer_subscription_id").doesNotExist()) + .andExpect(jsonPath("$.customer_resource_uri").doesNotExist()); + + verify(provisioningService).provisionFromGrant(any(SubscriptionProvisionCommand.class)); + } + + @Test + void provision_withoutCredentials_returns401AndDoesNotCallService() throws Exception { + mockMvc.perform(post("/internal/backchannel/v1/subscriptions") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + verifyNoInteractions(provisioningService); + } + + @Test + void provision_withWrongPassword_returns401() throws Exception { + mockMvc.perform(post("/internal/backchannel/v1/subscriptions") + .with(httpBasic(CLIENT_ID, "nope")) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + verifyNoInteractions(provisioningService); + } + + @Test + void provision_withBlankRequiredField_returns400() throws Exception { + String body = """ + { + "correlation_id": "", + "client_id": "test-tp", + "granted_scope": "FB=4_5_15", + "retail_customer_id": 42, + "selected_usage_point_ids": [] + } + """; + + mockMvc.perform(post("/internal/backchannel/v1/subscriptions") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + verifyNoInteractions(provisioningService); + } + + @Test + void provision_withMissingRequiredField_returns400() throws Exception { + String body = """ + { + "correlation_id": "corr-1", + "client_id": "test-tp", + "granted_scope": "FB=4_5_15", + "selected_usage_point_ids": [] + } + """; + + mockMvc.perform(post("/internal/backchannel/v1/subscriptions") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + verifyNoInteractions(provisioningService); + } + + @Test + void provision_whenServiceRejects_returns400WithErrorBody() throws Exception { + when(provisioningService.provisionFromGrant(any(SubscriptionProvisionCommand.class))) + .thenThrow(new IllegalArgumentException("Unknown client_id: test-tp")); + + String body = """ + { + "correlation_id": "corr-1", + "client_id": "test-tp", + "granted_scope": "FB=4_5_15", + "retail_customer_id": 42, + "selected_usage_point_ids": [] + } + """; + + mockMvc.perform(post("/internal/backchannel/v1/subscriptions") + .with(httpBasic(CLIENT_ID, CLIENT_SECRET)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("invalid_request")) + .andExpect(jsonPath("$.error_description").value("Unknown client_id: test-tp")); + } + + @Test + void requestDto_serializesUnderscoreCaseRoundTrip() throws Exception { + // Sanity: confirm DTO round-trip uses snake_case (the AS↔DC contract is snake_case). + String json = """ + { + "correlation_id": "corr-1", + "client_id": "test-tp", + "granted_scope": "FB=4", + "retail_customer_id": 42, + "selected_usage_point_ids": [], + "customer_resource_uri": "https://x/Customer/1" + } + """; + var parsed = objectMapper.readValue(json, + org.greenbuttonalliance.espi.datacustodian.web.internal.backchannel.dto.SubscriptionProvisionRequest.class); + assertThat(parsed.correlationId()).isEqualTo("corr-1"); + assertThat(parsed.clientId()).isEqualTo("test-tp"); + assertThat(parsed.grantedScope()).isEqualTo("FB=4"); + assertThat(parsed.retailCustomerId()).isEqualTo(42L); + assertThat(parsed.selectedUsagePointIds()).isEqualTo(List.of()); + assertThat(parsed.customerResourceUri()).isEqualTo("https://x/Customer/1"); + } +}