Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import org.hibernate.proxy.HibernateProxy;

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

/**
Expand Down Expand Up @@ -228,12 +230,18 @@ public class AuthorizationEntity extends IdentifiedObject {
private RetailCustomerEntity retailCustomer;

/**
* Subscription associated with this authorization.
* One-to-one relationship with optional subscription.
* Subscriptions produced by this authorization (inverse side; the authorization is the
* aggregate root). One authorization owns one or two subscriptions: an Energy subscription
* (via {@code resource_uri}) and, when the grant includes Customer/PII scope, a Customer
* subscription (via {@code customerResourceURI}).
*
* <p>A subscription has no independent lifecycle: {@code cascade = ALL} + {@code orphanRemoval}
* mean removing the authorization removes its subscriptions, and dropping a subscription from
* this collection (e.g. revoking Customer/PII access) deletes that subscription. The owning
* FK is {@code subscriptions.authorization_id}.</p>
*/
@OneToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
@JoinColumn(name = "subscription_id")
private SubscriptionEntity subscription;
@OneToMany(mappedBy = "authorization", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<SubscriptionEntity> subscriptions = new ArrayList<>();

/**
* Application information for the authorized application.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,19 @@ public class SubscriptionEntity implements Serializable {
private RetailCustomerEntity retailCustomer;

/**
* Authorization associated with this subscription.
* One-to-one relationship representing the OAuth2 authorization.
* Authorization that backs this subscription (owning side of the relationship).
* Many-to-one: a single OAuth2 authorization is the aggregate root for one or two
* subscriptions — an Energy subscription (via {@code authorizations.resource_uri}) and,
* when the grant includes Customer/PII scope, a Customer subscription (via
* {@code authorizations.customer_resource_uri}).
*
* <p>NOT NULL: a subscription has no independent lifecycle — it is created and removed only
* through its authorization. Cascade is DETACH only; the inverse delete cascade lives on
* {@link AuthorizationEntity#getSubscriptions()} (and the DB FK ON DELETE CASCADE).</p>
*/
@OneToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
@JoinColumn(name = "authorization_id")
@ManyToOne(cascade = CascadeType.DETACH, fetch = FetchType.LAZY)
@JoinColumn(name = "authorization_id", nullable = false)
@NotNull
private AuthorizationEntity authorization;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public interface AuthorizationMapper {
@Mapping(target = "thirdParty", source = "thirdParty")
@Mapping(target = "applicationInformation", ignore = true) // Complex mapping, handle separately
@Mapping(target = "retailCustomer", ignore = true) // Complex mapping, handle separately
@Mapping(target = "subscription", ignore = true)
@Mapping(target = "subscriptions", ignore = true) // Aggregate children, managed via the subscription lifecycle
AuthorizationEntity toEntity(AuthorizationDto dto);

}
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ public interface SubscriptionRepository extends JpaRepository<SubscriptionEntity
Optional<SubscriptionEntity> findByHashedId(String hashedId);

/**
* Finds a subscription by its associated authorization ID.
* Uses index: idx_subscription_authorization
* Finds the subscriptions backed by an authorization.
* An authorization is the aggregate root for one or two subscriptions
* (Energy and, when granted, Customer/PII). Uses index: idx_subscription_authorization
*
* @param id the authorization UUID
* @return the subscription if found
* @return the subscriptions for that authorization (0, 1, or 2)
*/
Optional<SubscriptionEntity> findByAuthorization_Id(UUID id);
List<SubscriptionEntity> findByAuthorization_Id(UUID id);

/**
* Finds all subscriptions for a retail customer.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public interface SubscriptionService {

List<UUID> findUsagePointIds(UUID subscriptionId);

SubscriptionEntity findByAuthorizationId(UUID id);
List<SubscriptionEntity> findByAuthorizationId(UUID id);

SubscriptionEntity addUsagePoint(SubscriptionEntity subscription,
UsagePointEntity usagePoint);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ public AuthorizationEntity createAuthorizationEntity(SubscriptionEntity subscrip
log.info("Creating authorization entity for subscription: " + subscription.getId());
AuthorizationEntity authorization = new AuthorizationEntity();
authorization.setAccessToken(accessToken);
authorization.setSubscription(subscription);
// Subscription is the owning side of the relationship; maintain both directions.
// cascade=ALL on AuthorizationEntity.subscriptions persists the subscription with the save.
subscription.setAuthorization(authorization);
authorization.getSubscriptions().add(subscription);
return authorizationRepository.save(authorization);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,6 @@ public void notify(RetailCustomerEntity retailCustomer,

if (retailCustomer != null) {

SubscriptionEntity subscription = null;

// find and iterate across all relevant authorizations
List<AuthorizationEntity> authorizationList = authorizationService
.findAllByRetailCustomerId(retailCustomer.getId());
Expand All @@ -95,17 +93,19 @@ public void notify(RetailCustomerEntity retailCustomer,
while (authorizationIterator.hasNext()) {
AuthorizationEntity authorization = authorizationIterator.next();

List<SubscriptionEntity> subscriptions;
try {
subscription = subscriptionService
// an authorization backs one or two subscriptions (energy + customer/PII)
subscriptions = subscriptionService
.findByAuthorizationId(authorization.getId());
} catch (Exception e) {
// an Authorization w/o an associated subscription breaks
// the propagation chain
// TODO: if we want to continue the propagation forward, we
// just need to hook in the subscription substructure

subscriptions = List.of();
}
if (subscription != null) {
for (SubscriptionEntity subscription : subscriptions) {
notify(subscription, startDate, endDate);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ public List<UUID> findUsagePointIds(UUID subscriptionId) {
}

@Override
public SubscriptionEntity findByAuthorizationId(UUID id) {
return subscriptionRepository.findByAuthorization_Id(id).orElse(null);
public List<SubscriptionEntity> findByAuthorizationId(UUID id) {
return subscriptionRepository.findByAuthorization_Id(id);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ CREATE TABLE authorizations
published_period_duration BIGINT,
application_information_id CHAR(36),
retail_customer_id BIGINT,
subscription_id CHAR(36),
access_token VARCHAR(1024),
refresh_token VARCHAR(1024),
code VARCHAR(1024),
Expand Down Expand Up @@ -282,10 +281,13 @@ CREATE TABLE subscriptions

-- Foreign key relationships
application_information_id CHAR(36) NOT NULL,
authorization_id CHAR(36),
-- A subscription has no independent lifecycle: it is always backed by an authorization
-- (the aggregate root). One authorization may back two subscriptions (energy + customer/PII).
authorization_id CHAR(36) NOT NULL,
retail_customer_id BIGINT NOT NULL,

FOREIGN KEY (application_information_id) REFERENCES application_information (id) ON DELETE CASCADE
FOREIGN KEY (application_information_id) REFERENCES application_information (id) ON DELETE CASCADE,
FOREIGN KEY (authorization_id) REFERENCES authorizations (id) ON DELETE CASCADE
-- FK constraint for retail_customer_id added in V2 after retail_customers table is created
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ CREATE TABLE subscription_usage_points
CREATE INDEX idx_subscription_usage_points_subscription ON subscription_usage_points (subscription_id);
CREATE INDEX idx_subscription_usage_points_usage_point ON subscription_usage_points (usage_point_id);

-- Add foreign key constraint from authorizations to subscriptions
ALTER TABLE authorizations ADD CONSTRAINT fk_authorization_subscription
FOREIGN KEY (subscription_id) REFERENCES subscriptions (id) ON DELETE SET NULL;
-- The subscription↔authorization link is the FK subscriptions.authorization_id → authorizations.id
-- (defined NOT NULL with ON DELETE CASCADE in V1). The authorization is the aggregate root: a
-- subscription has no independent lifecycle, so there is no back-reference column on authorizations.

-- Add foreign key constraint from usage_points to subscriptions
ALTER TABLE usage_points ADD CONSTRAINT fk_usage_point_subscription
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,6 @@ void shouldHandleAuthorizationWithoutRelationships() {
AuthorizationEntity auth = createValidAuthorization();
auth.setRetailCustomer(null);
auth.setApplicationInformation(null);
auth.setSubscription(null);
auth.setDescription("Authorization without Relationships");

// Act
Expand All @@ -622,7 +621,7 @@ void shouldHandleAuthorizationWithoutRelationships() {
AuthorizationEntity entity = retrieved.get();
assertThat(entity.getRetailCustomer()).isNull();
assertThat(entity.getApplicationInformation()).isNull();
assertThat(entity.getSubscription()).isNull();
assertThat(entity.getSubscriptions()).isEmpty();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,23 @@ class SubscriptionRepositoryTest extends BaseRepositoryTest {
private SubscriptionEntity createValidSubscription() {
SubscriptionEntity subscription = new SubscriptionEntity(UUID.randomUUID());
subscription.setHashedId("hashed-" + faker.internet().uuid());
// authorization_id is NOT NULL: a subscription is always backed by an authorization.
// Tests needing a specific authorization override this via setAuthorization(...).
subscription.setAuthorization(createAndSaveAuthorization());
return subscription;
}

/**
* Creates and persists a minimal valid AuthorizationEntity, the aggregate root a
* subscription must reference.
*/
private AuthorizationEntity createAndSaveAuthorization() {
AuthorizationEntity auth = new AuthorizationEntity();
auth.setAccessToken("token-" + faker.internet().uuid());
auth.setStatus(AuthorizationEntity.STATUS_ACTIVE);
return authorizationRepository.save(auth);
}

/**
* Creates a valid ApplicationInformationEntity for testing.
*/
Expand Down Expand Up @@ -336,11 +350,51 @@ void shouldFindSubscriptionByAuthorizationId() {
flushAndClear();

// Act
Optional<SubscriptionEntity> result = subscriptionRepository.findByAuthorization_Id(savedAuth.getId());
List<SubscriptionEntity> result = subscriptionRepository.findByAuthorization_Id(savedAuth.getId());

// Assert
assertThat(result).isPresent();
assertThat(result.get().getAuthorization().getId()).isEqualTo(savedAuth.getId());
assertThat(result).hasSize(1)
.first()
.extracting(s -> s.getAuthorization().getId())
.isEqualTo(savedAuth.getId());
}

@Test
@DisplayName("Should find both subscriptions backed by one authorization (energy + customer/PII)")
void shouldFindBothSubscriptionsForOneAuthorization() {
// Arrange
RetailCustomerEntity customer = TestDataBuilders.createValidRetailCustomer();
customer.setUsername("n1auth" + faker.number().digits(6));
RetailCustomerEntity savedCustomer = retailCustomerRepository.save(customer);

ApplicationInformationEntity app = createValidApplicationInformation();
ApplicationInformationEntity savedApp = applicationInformationRepository.save(app);

// One authorization is the aggregate root for both subscriptions
AuthorizationEntity savedAuth = createAndSaveAuthorization();

SubscriptionEntity energy = createValidSubscription();
energy.setRetailCustomer(savedCustomer);
energy.setApplicationInformation(savedApp);
energy.setAuthorization(savedAuth);

SubscriptionEntity customerPii = createValidSubscription();
customerPii.setRetailCustomer(savedCustomer);
customerPii.setApplicationInformation(savedApp);
customerPii.setAuthorization(savedAuth);

subscriptionRepository.saveAll(List.of(energy, customerPii));
flushAndClear();

// Act
List<SubscriptionEntity> result = subscriptionRepository.findByAuthorization_Id(savedAuth.getId());

// Assert
assertThat(result)
.hasSize(2)
.allSatisfy(s -> assertThat(s.getAuthorization().getId()).isEqualTo(savedAuth.getId()))
.extracting(SubscriptionEntity::getId)
.containsExactlyInAnyOrder(energy.getId(), customerPii.getId());
}

@Test
Expand Down Expand Up @@ -478,7 +532,7 @@ void shouldHandleSubscriptionWithoutOptionalRelationships() {
SubscriptionEntity subscription = createValidSubscription();
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
// Leave authorization and usagePoints as null/empty
// usagePoints left empty (optional); authorization is required and set by the helper

// Act
SubscriptionEntity saved = subscriptionRepository.save(subscription);
Expand All @@ -490,7 +544,7 @@ void shouldHandleSubscriptionWithoutOptionalRelationships() {
SubscriptionEntity entity = retrieved.get();
assertThat(entity.getRetailCustomer().getId()).isEqualTo(savedCustomer.getId());
assertThat(entity.getApplicationInformation().getId()).isEqualTo(savedApp.getId());
assertThat(entity.getAuthorization()).isNull();
assertThat(entity.getAuthorization()).isNotNull();
assertThat(entity.getUsagePoints()).isEmpty();
}
}
Expand Down Expand Up @@ -577,6 +631,7 @@ void shouldPersistWithPreSetUuid() {
SubscriptionEntity subscription = new SubscriptionEntity(presetId);
subscription.setRetailCustomer(savedCustomer);
subscription.setApplicationInformation(savedApp);
subscription.setAuthorization(createAndSaveAuthorization());

// Act
SubscriptionEntity saved = persistAndFlush(subscription);
Expand All @@ -602,8 +657,9 @@ void shouldHandleEqualsAndHashCodeCorrectly() {
@Test
@DisplayName("Should check active status based on authorization")
void shouldCheckActiveStatusBasedOnAuthorization() {
// Arrange
SubscriptionEntity subscription = createValidSubscription();
// Arrange — bare, unpersisted entity so the null-authorization branch can be exercised
// (in-memory only; the NOT NULL authorization constraint applies at persist time).
SubscriptionEntity subscription = new SubscriptionEntity(UUID.randomUUID());

// Without authorization - should not be active
assertThat(subscription.isActive()).isFalse();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
package org.greenbuttonalliance.espi.datacustodian.web.filter;

import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity;
import org.greenbuttonalliance.espi.common.service.AuthorizationService;
import org.greenbuttonalliance.espi.common.service.SubscriptionService;
import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
Expand Down Expand Up @@ -67,7 +66,6 @@ public void doFilter(ServletRequest req, ServletResponse res,
Boolean resourceRequest = false;

AuthorizationEntity authorizationFromToken = null;
SubscriptionEntity subscription = null;
String resourceUri = null;
String authorizationUri = null;
Set<String> roles = null;
Expand Down Expand Up @@ -126,7 +124,6 @@ public void doFilter(ServletRequest req, ServletResponse res,
resourceUri = authorizationFromToken.getResourceURI();
authorizationUri = authorizationFromToken
.getAuthorizationURI();
subscription = authorizationFromToken.getSubscription();

} catch (Exception e) {
System.out
Expand Down Expand Up @@ -263,8 +260,11 @@ else if (hasValidOAuthAccessToken == true) {

// or /resource/Batch/Subscription/{subscriptionId}/**
if (invalid && uri.contains("/resource/Subscription")) {
if (authorizationFromToken.getSubscription().getId()
.toString().equals(tokens[3])) {
// An authorization backs one or two subscriptions (energy + customer/PII);
// the requested {subscriptionId} must match one of them.
boolean matchesSubscription = authorizationFromToken.getSubscriptions().stream()
.anyMatch(s -> s.getId().toString().equals(tokens[3]));
if (matchesSubscription) {
invalid = false;
} else {
// not authorized for this resource
Expand Down
Loading