From d23943978b9daabe32b5216f44b2cc2361a457a5 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Thu, 4 Jun 2026 16:40:44 -0400 Subject: [PATCH] =?UTF-8?q?fix(#160):=20correct=20AS=E2=86=94DC=20token/in?= =?UTF-8?q?trospection=20wire=20format=20to=20the=20ESPI=20standard=20+=20?= =?UTF-8?q?pin=20it=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C4 token-response/introspection augmentation diverged from the ESPI 4.0 standard (NAESB REQ.21, whose own example shows `resourceURI=.../resource/Batch/Subscription/{id}`, `scope="FB=...;..."`, only the three *URI fields, `customerResourceURI` may be ""). The producer emitted `/resource/Subscription/{id}` (missing `/Batch/`) — which DC's own ResourceValidationFilter already rejected (it expects `/resource/Batch/Subscription`), so a real customer token failed DC's resource validation. The AS also emitted three non-standard `*_id` claims (one mislabeled). Surfaced while scoping the #150 contract. Root-cause fix (not a string patch): a single canonical builder/parser so producer and consumers can't drift again. - openespi-common EspiBatchUri: the one home for the ESPI Batch URL format — builds and parses `Batch/{Subscription|Bulk|RetailCustomer}/{id}` and `Authorization/{id}`. A test asserts the legacy `/Subscription/{id}` form does NOT parse as a Batch subscription. - SubscriptionProvisioningServiceImpl (producer) builds all three canonical URIs via EspiBatchUri: resourceURI -> Batch/Subscription, authorizationURI -> Authorization, and customerResourceURI -> Batch/RetailCustomer/{rc} (built from the retail-customer id DC holds, rather than trusting the round-tripped handoff value). - Dropped the three non-standard `*_id` claims from the AS token customizer + the token-response handler's claim set; consumers parse ids from the canonical URIs instead. (Verified openespi-thirdparty does not read the `*_id` fields.) Contract pin (#150), lightweight — no Spring Cloud (these are standalone EC2 services): - contracts/ holds the two ESPI-standard fixtures (Subscription + Bulk) as the shared single source of truth, with a README explaining the producer/consumer binding. - DC consumer test (IntrospectionWireContractTest) binds DC's parsing (EspiBatchUri + EspiScope) to the fixtures and asserts the `*_id` fields are absent and the scope is FB-grammar only. The producer test asserts all three emitted URIs are the canonical forms, so the contract is verified on both sides. Tests updated: GrantBackchannelTokenCustomizerTest, the #148 orchestration test (asserts the `*_id` fields now do-not-exist), SubscriptionProvisioningServiceImplTest. All green: common 18/18 + DC contract/controller + AS testcontainers-it 20/20. Follow-ups (noted): remove the now-vestigial `customer_resource_uri` from the back-channel request/handoff (structural cleanup); empirically verify `/oauth2/introspect` surfaces the URI claims (DC enforces from its own stored Authorization.resourceURI, now the Batch form). Refs #150. Closes #160. Co-Authored-By: Claude Opus 4.8 --- contracts/README.md | 42 ++++++ contracts/token-response-bulk.json | 9 ++ contracts/token-response-subscription.json | 10 ++ .../EspiTokenResponseSuccessHandler.java | 7 +- .../GrantBackchannelTokenCustomizer.java | 24 ++-- ...hCodeFlowOrchestrationIntegrationTest.java | 8 +- .../GrantBackchannelTokenCustomizerTest.java | 8 +- .../SubscriptionProvisioningServiceImpl.java | 15 +- .../espi/common/uri/EspiBatchUri.java | 133 ++++++++++++++++++ ...bscriptionProvisioningServiceImplTest.java | 8 +- .../espi/common/uri/EspiBatchUriTest.java | 99 +++++++++++++ .../IntrospectionWireContractTest.java | 106 ++++++++++++++ 12 files changed, 434 insertions(+), 35 deletions(-) create mode 100644 contracts/README.md create mode 100644 contracts/token-response-bulk.json create mode 100644 contracts/token-response-subscription.json create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUri.java create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUriTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/contract/IntrospectionWireContractTest.java diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..1763df80 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,42 @@ +# Cross-service wire contracts (AS ↔ DC) + +Canonical JSON examples of the HTTP wire formats exchanged between the Authorization +Server and the Data Custodian. These files are the **single source of truth** both +services bind to in their own tests — a lightweight, framework-free consumer-driven +contract suited to standalone Spring Boot services (deployed independently on EC2), +**not** a Spring Cloud microservice mesh. + +## How the contract is enforced (no Spring Cloud, no Pact broker) + +For each contract, two tests bind to the same file here: + +- **Producer test** asserts the service's *actual* output matches this file. +- **Consumer test** asserts the service correctly *parses* this file. + +If either side's wire format drifts from the file, that side's test fails. The fixture +is reviewed in PRs, so a deliberate wire change is a visible diff to this directory plus +a coordinated update on both sides. + +What these contracts cover: the **wire format** (field names, types, the ESPI scope +grammar). What they deliberately do NOT cover: TLS, ingress, CORS, timeouts — those are +EC2 deployment concerns, not wire-format concerns. + +## Contracts + +The token-response / introspection wire format is defined by the **ESPI 4.0 standard** itself +(NAESB REQ.21 — its own example shows `"resourceURI":".../resource/Batch/Subscription/{id}"`, +`"scope":"FB=...;..."`, no `*_id` fields, `customerResourceURI` may be `""`). These fixtures +reproduce that standard shape for the two grant types: + +| File | Grant type | `resourceURI` / `customerResourceURI` form | +|------|------------|--------------------------------------------| +| `token-response-subscription.json` | Authorization Code (Subscription) | `Batch/Subscription/{id}` / `Batch/RetailCustomer/{rc}` | +| `token-response-bulk.json` | Client Credentials (Bulk) | `Batch/Bulk/{bulkId}` / `Batch/Bulk/{bulkRcId}` | + +- **Producer** (AS token endpoint / introspection, fed by DC `SubscriptionProvisioningServiceImpl` + via `EspiBatchUri`) must emit these URI forms and only the three `*URI` fields. +- **Consumer** (DC `ResourceValidationFilter` / `EspiScopeOpaqueTokenIntrospector`) parses ids back + out of the URIs via the same `EspiBatchUri`, and the `FB=...` scope via `EspiScope`. + +UUIDs/ids/tokens in the fixtures are illustrative; the contract is the **shape** — field names, +the `Batch/{Subscription|Bulk|RetailCustomer}` URI forms, and the ESPI `FB=...` scope grammar. diff --git a/contracts/token-response-bulk.json b/contracts/token-response-bulk.json new file mode 100644 index 00000000..d417444c --- /dev/null +++ b/contracts/token-response-bulk.json @@ -0,0 +1,9 @@ +{ + "access_token": "OPAQUE_ACCESS_TOKEN", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "FB=1_3_4_5_10_11_35;BlockDuration=daily;BR=1", + "resourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Bulk/BULK_1", + "authorizationURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Authorization/CLIENTCREDS", + "customerResourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Bulk/BULK_RC_1" +} diff --git a/contracts/token-response-subscription.json b/contracts/token-response-subscription.json new file mode 100644 index 00000000..2bea5ed3 --- /dev/null +++ b/contracts/token-response-subscription.json @@ -0,0 +1,10 @@ +{ + "access_token": "OPAQUE_ACCESS_TOKEN", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "OPAQUE_REFRESH_TOKEN", + "scope": "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13", + "resourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/Subscription/503888", + "authorizationURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Authorization/503888", + "customerResourceURI": "https://utilityapi.com/DataCustodian/espi/1_1/resource/Batch/RetailCustomer/503888" +} diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenResponseSuccessHandler.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenResponseSuccessHandler.java index 078a31ad..8f2736cc 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenResponseSuccessHandler.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/EspiTokenResponseSuccessHandler.java @@ -75,13 +75,12 @@ public class EspiTokenResponseSuccessHandler implements AuthenticationSuccessHan private final OAuth2AuthorizationService authorizationService; private final OAuth2AccessTokenResponseHttpWriter responseWriter = new OAuth2AccessTokenResponseHttpWriter(); + // ESPI 4.0 (REQ.21) surfaces only the three canonical URIs in the token response; the bare *_id + // claims were non-standard and were removed in #160 (consumers parse ids from the URIs). private static final Set ESPI_URI_CLAIMS = Set.of( GrantBackchannelTokenCustomizer.CLAIM_RESOURCE_URI, GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_URI, - GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI, - GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_ID, - GrantBackchannelTokenCustomizer.CLAIM_RESOURCE_SUBSCRIPTION_ID, - GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_SUBSCRIPTION_ID + GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI ); @Override diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizer.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizer.java index 363e75ab..c7b22336 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizer.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizer.java @@ -60,10 +60,10 @@ * * *

Claim names

- * Match the ESPI 1.1/4.0 token-response wire format the third party expects: - * {@code resourceURI}, {@code authorizationURI}, {@code customerResourceURI}. The auxiliary ids - * ({@code authorization_id}, {@code resource_subscription_id}, {@code customer_subscription_id}) - * are surfaced as snake-case claims for AS-side audit and operator tooling. + * Exactly the ESPI 4.0 (REQ.21) token-response wire format the third party expects: + * {@code resourceURI}, {@code authorizationURI}, {@code customerResourceURI}. Consumers parse any + * needed ids out of those canonical URIs (see {@code EspiBatchUri}); the previously-emitted bare + * {@code *_id} claims were non-standard and were removed in #160. * *

Failure handling

* Any {@link DataCustodianBackchannelException} is rethrown as an {@link OAuth2AuthenticationException} @@ -79,9 +79,6 @@ public class GrantBackchannelTokenCustomizer implements OAuth2TokenCustomizer captor = ArgumentCaptor.forClass(BackchannelRequest.class); diff --git a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizerTest.java b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizerTest.java index 7840de44..caadf84a 100644 --- a/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizerTest.java +++ b/openespi-authserver/src/test/java/org/greenbuttonalliance/espi/authserver/service/GrantBackchannelTokenCustomizerTest.java @@ -106,9 +106,8 @@ void withGrantContextWritesClaims() { .containsEntry(GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_URI, "https://dc.example/Authorization/" + AUTH_ID) .containsEntry(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI, - "https://dc.example/RetailCustomer/42/Customer/" + CUST_SUB_ID) - .containsEntry(GrantBackchannelTokenCustomizer.CLAIM_AUTHORIZATION_ID, - AUTH_ID.toString()); + "https://dc.example/RetailCustomer/42/Customer/" + CUST_SUB_ID); + // *_id claims removed in #160 — consumers parse ids from the canonical URIs (EspiBatchUri). } @Test @@ -159,8 +158,7 @@ void piiLessGrantOmitsCustomerClaim() { assertThat(claims) .containsEntry(GrantBackchannelTokenCustomizer.CLAIM_RESOURCE_URI, "https://dc.example/Subscription/" + RES_SUB_ID) - .doesNotContainKey(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI) - .doesNotContainKey(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_SUBSCRIPTION_ID); + .doesNotContainKey(GrantBackchannelTokenCustomizer.CLAIM_CUSTOMER_RESOURCE_URI); } @Test 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 index 001ae019..1062a1e1 100644 --- 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 @@ -28,6 +28,7 @@ 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.uri.EspiBatchUri; import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; import org.greenbuttonalliance.espi.common.service.SubscriptionProvisioningService; import org.springframework.beans.factory.annotation.Value; @@ -171,7 +172,12 @@ private AuthorizationEntity newAuthorization(SubscriptionProvisionCommand comman authorization.setThirdParty(command.clientId()); authorization.setStatus(AuthorizationEntity.STATUS_ACTIVE); if (includesPii) { - authorization.setCustomerResourceURI(command.customerResourceUri()); + // Build the canonical ESPI Batch/RetailCustomer URI from the retail-customer id DC already + // holds, rather than trusting the round-tripped handoff value (#160). The handoff's + // customer_resource_uri is now vestigial — removing it from the back-channel request/handoff + // is a follow-up structural cleanup. + authorization.setCustomerResourceURI( + EspiBatchUri.batchRetailCustomer(resourceBaseUri, command.retailCustomerId())); } return authorization; } @@ -191,11 +197,14 @@ private SubscriptionEntity newSubscription(SubscriptionProvisionCommand command, return subscription; } + // Canonical ESPI 4.0 Batch resource URIs via the single builder/parser shared with the consumers + // (DC ResourceValidationFilter) — see EspiBatchUri / #160. The previous "/Subscription/{id}" form + // omitted the required "/Batch/" segment and so failed DC's own resource validation. private String subscriptionUri(UUID subscriptionId) { - return resourceBaseUri + "/Subscription/" + subscriptionId; + return EspiBatchUri.batchSubscription(resourceBaseUri, subscriptionId); } private String authorizationUri(UUID authorizationId) { - return resourceBaseUri + "/Authorization/" + authorizationId; + return EspiBatchUri.authorization(resourceBaseUri, authorizationId); } } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUri.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUri.java new file mode 100644 index 00000000..312409c5 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUri.java @@ -0,0 +1,133 @@ +/* + * + * Copyright (c) 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.uri; + +import java.util.Optional; + +/** + * The single canonical builder and parser for the ESPI 4.0 Batch resource URIs carried in the + * token-response / introspection {@code resourceURI}, {@code authorizationURI}, and + * {@code customerResourceURI} fields (NAESB ESPI 4.0, REQ.21.6.2.1 API Interface). + * + *

The canonical forms (relative to a resource base such as + * {@code https://{host}/DataCustodian/espi/1_1/resource}) are:

+ *
    + *
  • {@code .../Batch/Subscription/{subscriptionId}} — Subscription (authorization-code) flow
  • + *
  • {@code .../Batch/Bulk/{bulkId}} — Bulk (client-credentials) flow; the {@code bulkId} comes from + * the scope's {@code BR=} parameter
  • + *
  • {@code .../Batch/RetailCustomer/{retailCustomerId}} — customer/PII batch
  • + *
  • {@code .../Authorization/{authorizationId}}
  • + *
+ * + *

Why one class builds and parses: the producer (DC subscription provisioning) and + * the consumers (DC {@code ResourceValidationFilter}, the resource-server introspector) previously + * encoded these URL formats independently and drifted — the producer emitted {@code /Subscription/{id}} + * while the consumer expected {@code /Batch/Subscription/{id}} (issue #160). Routing both sides through + * this single class makes that drift structurally impossible.

+ */ +public final class EspiBatchUri { + + private EspiBatchUri() { + } + + // ------------------------------------------------------------------ builders + + /** {@code {resourceBase}/Batch/Subscription/{subscriptionId}}. */ + public static String batchSubscription(String resourceBase, Object subscriptionId) { + return base(resourceBase) + "/Batch/Subscription/" + require(subscriptionId, "subscriptionId"); + } + + /** {@code {resourceBase}/Batch/Bulk/{bulkId}}. */ + public static String batchBulk(String resourceBase, Object bulkId) { + return base(resourceBase) + "/Batch/Bulk/" + require(bulkId, "bulkId"); + } + + /** {@code {resourceBase}/Batch/RetailCustomer/{retailCustomerId}}. */ + public static String batchRetailCustomer(String resourceBase, Object retailCustomerId) { + return base(resourceBase) + "/Batch/RetailCustomer/" + require(retailCustomerId, "retailCustomerId"); + } + + /** {@code {resourceBase}/Authorization/{authorizationId}}. */ + public static String authorization(String resourceBase, Object authorizationId) { + return base(resourceBase) + "/Authorization/" + require(authorizationId, "authorizationId"); + } + + // ------------------------------------------------------------------ parsers + + /** Extract {@code subscriptionId} from a {@code .../Batch/Subscription/{id}...} URI. */ + public static Optional subscriptionId(String uri) { + return idAfter(uri, "/Batch/Subscription/"); + } + + /** Extract {@code bulkId} from a {@code .../Batch/Bulk/{id}...} URI. */ + public static Optional bulkId(String uri) { + return idAfter(uri, "/Batch/Bulk/"); + } + + /** Extract {@code retailCustomerId} from a {@code .../Batch/RetailCustomer/{id}...} URI. */ + public static Optional retailCustomerId(String uri) { + return idAfter(uri, "/Batch/RetailCustomer/"); + } + + /** Extract {@code authorizationId} from a {@code .../Authorization/{id}...} URI. */ + public static Optional authorizationId(String uri) { + return idAfter(uri, "/Authorization/"); + } + + /** + * Return the path segment immediately following {@code marker}, tolerant of deeper path segments and + * query/fragment suffixes — e.g. {@code .../Batch/Subscription/42/UsagePoint/7?x=1} yields {@code 42}. + * Returns empty when the marker is absent or the id segment is empty. + */ + static Optional idAfter(String uri, String marker) { + if (uri == null) { + return Optional.empty(); + } + int at = uri.indexOf(marker); + if (at < 0) { + return Optional.empty(); + } + int start = at + marker.length(); + int end = start; + while (end < uri.length()) { + char c = uri.charAt(end); + if (c == '/' || c == '?' || c == '#') { + break; + } + end++; + } + String id = uri.substring(start, end); + return id.isEmpty() ? Optional.empty() : Optional.of(id); + } + + private static String base(String resourceBase) { + if (resourceBase == null || resourceBase.isBlank()) { + throw new IllegalArgumentException("resourceBase is required"); + } + String trimmed = resourceBase.strip(); + return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; + } + + private static String require(Object id, String name) { + if (id == null || id.toString().isBlank()) { + throw new IllegalArgumentException(name + " is required"); + } + return id.toString(); + } +} 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 index 844a7d04..a5730721 100644 --- 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 @@ -102,7 +102,7 @@ void energyOnlyGrant_createsResourceSubscriptionAndNoPiiSubscription() { assertThat(result.resourceSubscriptionId()).isNotNull(); assertThat(result.customerSubscriptionId()).isNull(); - assertThat(result.resourceUri()).startsWith(BASE_URI + "/Subscription/"); + assertThat(result.resourceUri()).startsWith(BASE_URI + "/Batch/Subscription/"); // ESPI standard Batch form (#160) assertThat(result.authorizationUri()).startsWith(BASE_URI + "/Authorization/"); assertThat(result.customerResourceUri()).isNull(); @@ -134,10 +134,10 @@ void grantWithPiiScope_createsBothSubscriptions() { assertThat(result.resourceSubscriptionId()).isNotNull(); assertThat(result.customerSubscriptionId()).isNotNull(); - assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI); + assertThat(result.customerResourceUri()).isEqualTo(BASE_URI + "/Batch/RetailCustomer/" + CUSTOMER_ID); AuthorizationEntity saved = captureSavedAuthorization(); - assertThat(saved.getCustomerResourceURI()).isEqualTo(PII_CUSTOMER_URI); + assertThat(saved.getCustomerResourceURI()).isEqualTo(BASE_URI + "/Batch/RetailCustomer/" + CUSTOMER_ID); assertThat(saved.getSubscriptions()).hasSize(2); } @@ -152,7 +152,7 @@ void piiOnlyGrant_createsOnlyCustomerSubscription_noResourceUri() { assertThat(result.resourceSubscriptionId()).isNull(); assertThat(result.resourceUri()).isNull(); assertThat(result.customerSubscriptionId()).isNotNull(); - assertThat(result.customerResourceUri()).isEqualTo(PII_CUSTOMER_URI); + assertThat(result.customerResourceUri()).isEqualTo(BASE_URI + "/Batch/RetailCustomer/" + CUSTOMER_ID); AuthorizationEntity saved = captureSavedAuthorization(); assertThat(saved.getResourceURI()).isNull(); diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUriTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUriTest.java new file mode 100644 index 00000000..60961a4a --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/uri/EspiBatchUriTest.java @@ -0,0 +1,99 @@ +/* + * + * Copyright (c) 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.uri; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link EspiBatchUri} — the single canonical ESPI Batch-URI builder/parser (#160). + */ +@DisplayName("EspiBatchUri (canonical ESPI Batch URI builder/parser) #160") +class EspiBatchUriTest { + + private static final String BASE = "https://utilityapi.com/DataCustodian/espi/1_1/resource"; + + @Test + @DisplayName("builders produce the ESPI 4.0 standard Batch forms") + void buildersProduceStandardForms() { + assertThat(EspiBatchUri.batchSubscription(BASE, "503888")) + .isEqualTo(BASE + "/Batch/Subscription/503888"); + assertThat(EspiBatchUri.batchBulk(BASE, "BULK_1")) + .isEqualTo(BASE + "/Batch/Bulk/BULK_1"); + assertThat(EspiBatchUri.batchRetailCustomer(BASE, "503888")) + .isEqualTo(BASE + "/Batch/RetailCustomer/503888"); + assertThat(EspiBatchUri.authorization(BASE, "503888")) + .isEqualTo(BASE + "/Authorization/503888"); + } + + @Test + @DisplayName("a trailing slash on the base is normalized away") + void normalizesTrailingSlash() { + assertThat(EspiBatchUri.batchSubscription(BASE + "/", "1")) + .isEqualTo(BASE + "/Batch/Subscription/1"); + } + + @Test + @DisplayName("build then parse round-trips the id (UUID and alphanumeric)") + void roundTrips() { + UUID id = UUID.randomUUID(); + assertThat(EspiBatchUri.subscriptionId(EspiBatchUri.batchSubscription(BASE, id))) + .contains(id.toString()); + assertThat(EspiBatchUri.bulkId(EspiBatchUri.batchBulk(BASE, "1dfa07c5740a_118328"))) + .contains("1dfa07c5740a_118328"); + assertThat(EspiBatchUri.retailCustomerId(EspiBatchUri.batchRetailCustomer(BASE, "503888"))) + .contains("503888"); + assertThat(EspiBatchUri.authorizationId(EspiBatchUri.authorization(BASE, "CLIENTCREDS"))) + .contains("CLIENTCREDS"); + } + + @Test + @DisplayName("parser extracts the id from deeper paths and query strings") + void parsesDeepPathsAndQueryStrings() { + assertThat(EspiBatchUri.subscriptionId(BASE + "/Batch/Subscription/503888/UsagePoint/7")) + .contains("503888"); + assertThat(EspiBatchUri.bulkId(BASE + "/Batch/Bulk/1?published-min=2012-04-01T04:00:00Z")) + .contains("1"); + } + + @Test + @DisplayName("parser returns empty for a non-matching or null URI") + void parserEmptyWhenAbsent() { + // the legacy (wrong) non-Batch form must NOT parse as a Batch subscription + assertThat(EspiBatchUri.subscriptionId(BASE + "/Subscription/503888")).isEmpty(); + assertThat(EspiBatchUri.subscriptionId(null)).isEmpty(); + assertThat(EspiBatchUri.bulkId(BASE + "/Batch/Subscription/1")).isEmpty(); + } + + @Test + @DisplayName("builders reject a blank base or id") + void buildersRejectBlankInputs() { + assertThatThrownBy(() -> EspiBatchUri.batchSubscription(" ", "1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EspiBatchUri.batchSubscription(BASE, "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EspiBatchUri.authorization(BASE, null)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/contract/IntrospectionWireContractTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/contract/IntrospectionWireContractTest.java new file mode 100644 index 00000000..10cb3c65 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/contract/IntrospectionWireContractTest.java @@ -0,0 +1,106 @@ +/* + * + * Copyright (c) 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.contract; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.greenbuttonalliance.espi.common.scope.EspiScope; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * DC consumer side of the AS↔DC token/introspection wire contract (#150 / #160). + * + *

Binds the Data Custodian's consumption logic — {@link EspiBatchUri} (id parsing, as the + * resource-server / {@code ResourceValidationFilter} does) and {@link EspiScope} (FB-scope parsing, + * as the introspector does) — to the shared, ESPI-standard fixtures in the repo-root + * {@code contracts/} directory. If the wire format drifts from the standard on either side, the + * producer test ({@code SubscriptionProvisioningServiceImplTest}) or this consumer test fails.

+ * + *

Pure unit test (no Spring context, no Docker) — runs in CI as part of the DC module.

+ */ +@DisplayName("AS↔DC token/introspection wire contract — DC consumer (#150/#160)") +class IntrospectionWireContractTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Path CONTRACTS = locateContractsDir(); + + @Test + @DisplayName("Subscription (auth-code) token response conforms to the ESPI standard") + void subscriptionConformsToStandard() throws Exception { + JsonNode c = readFixture("token-response-subscription.json"); + + assertNoLegacyIdFields(c); + assertFbGrammarOnlyScope(c.get("scope").asText(), 4, 5, 15); + + // Consumer parses ids out of the canonical Batch URIs (the contract). + assertThat(EspiBatchUri.subscriptionId(c.get("resourceURI").asText())).contains("503888"); + assertThat(EspiBatchUri.authorizationId(c.get("authorizationURI").asText())).contains("503888"); + assertThat(EspiBatchUri.retailCustomerId(c.get("customerResourceURI").asText())).contains("503888"); + } + + @Test + @DisplayName("Bulk (client-credentials) token response conforms to the ESPI standard") + void bulkConformsToStandard() throws Exception { + JsonNode c = readFixture("token-response-bulk.json"); + + assertNoLegacyIdFields(c); + assertFbGrammarOnlyScope(c.get("scope").asText(), 1, 3, 4, 5, 10, 11, 35); + + assertThat(EspiBatchUri.bulkId(c.get("resourceURI").asText())).contains("BULK_1"); + assertThat(EspiBatchUri.authorizationId(c.get("authorizationURI").asText())).contains("CLIENTCREDS"); + assertThat(EspiBatchUri.bulkId(c.get("customerResourceURI").asText())).contains("BULK_RC_1"); + } + + private static void assertNoLegacyIdFields(JsonNode c) { + // ESPI 4.0 carries only the three *URI fields; the bare *_id claims were removed (#160). + assertThat(c.has("authorization_id")).as("authorization_id must be absent").isFalse(); + assertThat(c.has("resource_subscription_id")).as("resource_subscription_id must be absent").isFalse(); + assertThat(c.has("customer_subscription_id")).as("customer_subscription_id must be absent").isFalse(); + } + + private static void assertFbGrammarOnlyScope(String scope, int... expectedFbs) { + assertThat(scope).startsWith("FB=").doesNotContain("openid").doesNotContain("profile"); + EspiScope parsed = EspiScope.parse(scope); + for (int fb : expectedFbs) { + assertThat(parsed.containsFunctionBlock(fb)).as("scope must grant FB_" + fb).isTrue(); + } + } + + private static JsonNode readFixture(String name) throws Exception { + return MAPPER.readTree(Files.readString(CONTRACTS.resolve(name))); + } + + /** Resolve repo-root {@code contracts/} whether tests run from the module dir or the repo root. */ + private static Path locateContractsDir() { + for (Path candidate : new Path[] { Path.of("..", "contracts"), Path.of("contracts") }) { + if (Files.isDirectory(candidate)) { + return candidate; + } + } + throw new IllegalStateException( + "contracts/ directory not found from " + Path.of("").toAbsolutePath()); + } +}