From 4dc0ab87018596a0a15dfe23f42d6af9d817d7e8 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 6 Jun 2026 00:07:53 -0400 Subject: [PATCH] feat(#119 F1): resource-server scope foundation + dev XML capture/sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the canonical DC ESPI resource surface (#119): the security-critical scope core every resource endpoint will enforce, so subscription scoping / the data-leakage fix lives in one tested component rather than per controller. Scope core (web/api/scope): - ResourceScope: resolved per-request visibility — admin/unfiltered, or a granted energy UsagePoint-id set + PII RetailCustomer-id set; fail-closed denied(). UUID resources, Long RetailCustomer correlation key. - ScopeResolver: token -> ResourceScope. DataCustodian-admin authority or Batch/Bulk -> admin; Batch/Subscription resourceURI -> the subscription's granted UsagePoint ids (read from the DC's own store via the new lazy-safe SubscriptionRepository.findUsagePointIdsBySubscriptionId, never from the token body); Batch/RetailCustomer customerResourceURI -> the PII RetailCustomer id; otherwise denied. URIs parsed via the canonical EspiBatchUri. - EspiQueryOptions: REQ.21.6.2.8 feed query params — ALL optional. published/updated min/max are required-to-support timestamp filters; max-results/start-index/start-after/depth are optional best-effort DB knobs. No limit/offset pagination (ESPI CMD/DMD are machine-to-machine). Dev tooling (web/api/devtools): - EspiXmlCaptureFilter: dev-only (@Profile dev-*) servlet filter that tees ESPI XML responses under /espi/1_1/resource/** to per-run sub-dirs under repo-root dev-xml-capture/ (git-ignored) so a developer can view/diff the marshalled XML before committing. Zero impact on test/prod/CI. - DevXmlSampleGenerator: on-demand generator (not run by CI) that marshals a sample resource via the real UsageExportService into dev-xml-capture/sample/ — view ESPI XML without a full server. Reuses the existing tested Atom/DTO/IdentifiedObject marshalling — F1 adds none. The resource engine + entry-assembly (self/up/related FB-gated atom:links, per REQ.21.6.2) lands in E1 with the first real resource (UsagePoint/MeterReading/ReadingType), exercised end-to-end. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + .../usage/SubscriptionRepository.java | 13 ++ .../api/devtools/EspiXmlCaptureFilter.java | 131 ++++++++++++++++ .../web/api/scope/EspiQueryOptions.java | 70 +++++++++ .../web/api/scope/ResourceScope.java | 83 ++++++++++ .../web/api/scope/ScopeResolver.java | 146 ++++++++++++++++++ .../api/devtools/DevXmlSampleGenerator.java | 97 ++++++++++++ .../devtools/EspiXmlCaptureFilterTest.java | 116 ++++++++++++++ .../web/api/scope/EspiQueryOptionsTest.java | 64 ++++++++ .../web/api/scope/ResourceScopeTest.java | 86 +++++++++++ .../web/api/scope/ScopeResolverTest.java | 137 ++++++++++++++++ 11 files changed, 946 insertions(+) create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptions.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScope.java create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolver.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/DevXmlSampleGenerator.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilterTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptionsTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScopeTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolverTest.java diff --git a/.gitignore b/.gitignore index 1405d817..97b70f46 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ temp-services/ node_modules/ package.json package-lock.json + +# Dev-only ESPI XML capture output (EspiXmlCaptureFilter, #119) — never committed +dev-xml-capture/ diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java index 53b780b4..3d8d5ab0 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/SubscriptionRepository.java @@ -21,6 +21,8 @@ import org.greenbuttonalliance.espi.common.domain.usage.SubscriptionEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -73,4 +75,15 @@ public interface SubscriptionRepository extends JpaRepository findByApplicationInformation_Id(UUID id); + + /** + * Returns the ids of the UsagePoints granted to a subscription, projected directly so callers + * (e.g. the resource-server scope resolver, #119) can resolve a subscription's granted set + * without traversing the lazy {@code usagePoints} collection outside a transaction. + * + * @param subscriptionId the subscription UUID + * @return the granted UsagePoint ids (empty if none / unknown subscription) + */ + @Query("select up.id from SubscriptionEntity s join s.usagePoints up where s.id = :subscriptionId") + List findUsagePointIdsBySubscriptionId(@Param("subscriptionId") UUID subscriptionId); } diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java new file mode 100644 index 00000000..7e61b9df --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilter.java @@ -0,0 +1,131 @@ +/* + * + * 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.web.api.devtools; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Dev-only diagnostic: tees ESPI resource XML responses to files on disk so a developer can view + * (and diff) the actual marshalled Atom XML before committing changes (#119). + * + *

Active only under the development profiles ({@code dev-mysql}, {@code dev-postgresql}, + * {@code local}, {@code dev}) — never in {@code test}/{@code prod}/{@code docker}, so it has zero + * impact on CI or production. It is controller-agnostic: it captures every XML response under + * {@code /espi/1_1/resource/**}, so it works for the existing controllers and every resource added + * to the canonical surface.

+ * + *

Layout — a persistent, git-ignored, repo-root directory ({@code espi.dev.xml-capture.dir}, + * default {@code dev-xml-capture}) with one timestamped sub-directory per application run:

+ *
+ *   dev-xml-capture/
+ *     run-20260605-231007-413/
+ *       GET_UsagePoint_503888.xml
+ *       GET_Customer.xml
+ *     run-20260605-233512-088/
+ *       GET_UsagePoint_503888.xml
+ * 
+ *

Per-run sub-dirs (rather than a flat overwrite) let you {@code diff -r} the output of one run + * against another to see the effect of a change. Within a run, filenames are stable per + * method+path (a repeated call overwrites with the latest response). The response body is always + * copied through to the client unchanged.

+ */ +@Component +@Profile({"dev-mysql", "dev-postgresql", "local", "dev"}) +public class EspiXmlCaptureFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(EspiXmlCaptureFilter.class); + private static final String RESOURCE_PATH = "/espi/1_1/resource"; + private static final DateTimeFormatter RUN_ID = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS"); + + private final boolean enabled; + private final Path runDir; + + public EspiXmlCaptureFilter( + @Value("${espi.dev.xml-capture.enabled:true}") boolean enabled, + @Value("${espi.dev.xml-capture.dir:dev-xml-capture}") String captureDir) { + this.enabled = enabled; + // One sub-directory per application run, so successive runs can be compared rather than + // overwriting each other. + this.runDir = Path.of(captureDir).resolve("run-" + LocalDateTime.now().format(RUN_ID)); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !enabled || !request.getRequestURI().contains(RESOURCE_PATH); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + try { + filterChain.doFilter(request, wrapper); + } finally { + capture(request, wrapper); + wrapper.copyBodyToResponse(); + } + } + + private void capture(HttpServletRequest request, ContentCachingResponseWrapper wrapper) { + byte[] body = wrapper.getContentAsByteArray(); + String contentType = wrapper.getContentType(); + if (body.length == 0 || contentType == null + || !contentType.toLowerCase().contains("xml")) { + return; + } + try { + Files.createDirectories(runDir); + Path file = runDir.resolve(fileName(request)); + Files.write(file, body); + log.info("[dev] captured ESPI XML ({} bytes) -> {}", body.length, file.toAbsolutePath()); + } catch (IOException e) { + // dev aid only — never let a capture failure affect the response + log.warn("[dev] ESPI XML capture failed: {}", e.getMessage()); + } + } + + /** Stable per-run filename from the request, e.g. {@code GET_UsagePoint_503888.xml}. */ + private String fileName(HttpServletRequest request) { + String path = request.getRequestURI(); + int idx = path.indexOf(RESOURCE_PATH); + String tail = idx >= 0 ? path.substring(idx + RESOURCE_PATH.length()) : path; + String safe = tail.replaceAll("^/+", "").replaceAll("[^A-Za-z0-9._-]+", "_"); + if (safe.isBlank()) { + safe = "root"; + } + return request.getMethod() + "_" + safe + ".xml"; + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptions.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptions.java new file mode 100644 index 00000000..8b6f6507 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptions.java @@ -0,0 +1,70 @@ +/* + * + * 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.web.api.scope; + +import java.time.OffsetDateTime; + +/** + * The ESPI feed query parameters of REQ.21.6.2.8, as bound from a resource request (#119). + * + *

All parameters are optional on the request — a feed request need supply none of them. + * The standard states only {@code published-min/max} and {@code updated-min/max} are + * required to be supported by the Data Custodian (they filter by the Atom + * {@code published}/{@code updated} timestamps); {@code max-results}, {@code start-index}, + * {@code start-after} and {@code depth} are optional DB-query knobs that certification checks only + * for data-format acceptance, not filtering behavior — they are accepted (never a {@code 400}) and + * applied best-effort.

+ * + *

ESPI does not define {@code limit}/{@code offset} pagination; CMD/DMD are machine-to-machine.

+ * + * @param publishedMin filter: Atom {@code published} ≥ this instant (required-to-support) + * @param publishedMax filter: Atom {@code published} ≤ this instant (required-to-support) + * @param updatedMin filter: Atom {@code updated} ≥ this instant (required-to-support) + * @param updatedMax filter: Atom {@code updated} ≤ this instant (required-to-support) + * @param maxResults optional cap on results (best-effort) + * @param startIndex optional 0-based start offset (best-effort) + * @param startAfter optional id cursor (best-effort; accepted, no-op until needed) + * @param depth optional feed-expansion depth (best-effort; sandbox returns natural depth) + */ +public record EspiQueryOptions( + OffsetDateTime publishedMin, + OffsetDateTime publishedMax, + OffsetDateTime updatedMin, + OffsetDateTime updatedMax, + Integer maxResults, + Integer startIndex, + String startAfter, + Integer depth) { + + /** An options object with nothing set — i.e. return the full (scoped) feed. */ + public static EspiQueryOptions none() { + return new EspiQueryOptions(null, null, null, null, null, null, null, null); + } + + /** True when no parameter is set (the request supplied no query filters). */ + public boolean isEmpty() { + return publishedMin == null && publishedMax == null && updatedMin == null && updatedMax == null + && maxResults == null && startIndex == null && startAfter == null && depth == null; + } + + /** True when any of the required-to-support timestamp filters is present. */ + public boolean hasTimestampFilter() { + return publishedMin != null || publishedMax != null || updatedMin != null || updatedMax != null; + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScope.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScope.java new file mode 100644 index 00000000..43b58e24 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScope.java @@ -0,0 +1,83 @@ +/* + * + * 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.web.api.scope; + +import java.util.Set; +import java.util.UUID; + +/** + * The data-visibility scope resolved for a single resource-server request (#119). + * + *

Foundation for ESPI subscription scoping: a request is either {@code admin} (DataCustodian + * admin / Bulk — sees everything) or constrained to a concrete set of granted ids derived from the + * token's {@code resourceURI} / {@code customerResourceURI} (per #160):

+ *
    + *
  • energy resources (UsagePoint → MeterReading → IntervalBlock, EPQS, UsageSummary) are + * visible iff their owning UsagePoint id is in {@link #usagePointIds()};
  • + *
  • PII resources (Customer, CustomerAccount, …) are visible iff their owning + * RetailCustomer id is in {@link #retailCustomerIds()} (RetailCustomer is a {@code Long} + * local correlation key, not an ESPI resource);
  • + *
  • ReadingType (shared metadata) is visible by reachability — i.e. referenced by a + * MeterReading whose UsagePoint is in {@link #usagePointIds()} — resolved by callers.
  • + *
+ * + *

A non-admin request with no resolvable grant yields {@link #denied()} (empty sets): it sees + * nothing, never everything — the fail-closed default.

+ * + * @param admin true for DataCustodian-admin / Bulk tokens (unfiltered access) + * @param usagePointIds the granted energy UsagePoint ids (ignored when {@code admin}) + * @param retailCustomerIds the granted PII RetailCustomer ids (ignored when {@code admin}) + */ +public record ResourceScope(boolean admin, Set usagePointIds, Set retailCustomerIds) { + + public ResourceScope { + usagePointIds = usagePointIds == null ? Set.of() : Set.copyOf(usagePointIds); + retailCustomerIds = retailCustomerIds == null ? Set.of() : Set.copyOf(retailCustomerIds); + } + + /** Unfiltered access (DataCustodian admin / Bulk). */ + public static ResourceScope unfiltered() { + return new ResourceScope(true, Set.of(), Set.of()); + } + + /** Fail-closed: a non-admin caller with no resolvable grant — sees nothing. */ + public static ResourceScope denied() { + return new ResourceScope(false, Set.of(), Set.of()); + } + + /** A non-admin scope granting the given energy + PII id sets. */ + public static ResourceScope of(Set usagePointIds, Set retailCustomerIds) { + return new ResourceScope(false, usagePointIds, retailCustomerIds); + } + + /** Whether an energy resource owned by {@code usagePointId} is visible to this scope. */ + public boolean permitsUsagePoint(UUID usagePointId) { + return admin || (usagePointId != null && usagePointIds.contains(usagePointId)); + } + + /** Whether a PII resource owned by {@code retailCustomerId} is visible to this scope. */ + public boolean permitsRetailCustomer(Long retailCustomerId) { + return admin || (retailCustomerId != null && retailCustomerIds.contains(retailCustomerId)); + } + + /** True when this scope can see no resource at all (non-admin with empty grants). */ + public boolean isDenied() { + return !admin && usagePointIds.isEmpty() && retailCustomerIds.isEmpty(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolver.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolver.java new file mode 100644 index 00000000..a920f29d --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolver.java @@ -0,0 +1,146 @@ +/* + * + * 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.web.api.scope; + +import org.greenbuttonalliance.espi.common.repositories.usage.SubscriptionRepository; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Resolves the {@link ResourceScope} for a resource-server request from the introspected token + * (#119). This is the single place the Data Custodian decides "what may this caller see", so every + * resource endpoint enforces the same rule and the subscription-scoping / data-leakage fix lives + * in one tested component rather than per controller. + * + *

Resolution order (fail-closed):

+ *
    + *
  1. DataCustodian admin authority → {@link ResourceScope#unfiltered()} (unfiltered).
  2. + *
  3. Token {@code resourceURI} = {@code …/Batch/Bulk/{id}} → admin (a Bulk token is a + * back-end/admin grant over a defined set; treated as unfiltered for the sandbox).
  4. + *
  5. Token {@code resourceURI} = {@code …/Batch/Subscription/{id}} → the subscription's granted + * UsagePoint ids (energy scope), parsed via {@link EspiBatchUri} and loaded from the DC's own + * store ({@link SubscriptionRepository#findUsagePointIdsBySubscriptionId}).
  6. + *
  7. Token {@code customerResourceURI} = {@code …/Batch/RetailCustomer/{id}} → the granted + * RetailCustomer id (PII scope; {@code Long} correlation key).
  8. + *
  9. Otherwise → {@link ResourceScope#denied()} (sees nothing).
  10. + *
+ * + *

Ids come from the trusted introspection response (the AS echoes the URIs the DC itself + * minted), but the granted set is read from the DC's own database — never from the token + * body — so a tampered/expanded URI cannot widen access.

+ */ +@Component +public class ScopeResolver { + + /** Authority (from {@code EspiScopeOpaqueTokenIntrospector}) that grants unfiltered access. */ + static final String ADMIN_AUTHORITY = "SCOPE_DataCustodian_Admin_Access"; + + private final SubscriptionRepository subscriptionRepository; + + public ScopeResolver(SubscriptionRepository subscriptionRepository) { + this.subscriptionRepository = subscriptionRepository; + } + + /** + * Resolves the data-visibility scope for the current request. + * + * @param authentication the resource-server authentication (may be {@code null}) + * @return the resolved scope; {@link ResourceScope#denied()} when nothing is grantable + */ + public ResourceScope resolve(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) { + return ResourceScope.denied(); + } + if (hasAdminAuthority(authentication)) { + return ResourceScope.unfiltered(); + } + if (!(authentication.getPrincipal() instanceof OAuth2AuthenticatedPrincipal principal)) { + return ResourceScope.denied(); + } + + String resourceUri = stringAttribute(principal, "resourceURI"); + // A Bulk token is a back-end/admin grant; treat as unfiltered for the sandbox. + if (resourceUri != null && EspiBatchUri.bulkId(resourceUri).isPresent()) { + return ResourceScope.unfiltered(); + } + + Set usagePointIds = new LinkedHashSet<>(); + if (resourceUri != null) { + EspiBatchUri.subscriptionId(resourceUri) + .flatMap(ScopeResolver::parseUuid) + .ifPresent(subscriptionId -> + usagePointIds.addAll(subscriptionRepository + .findUsagePointIdsBySubscriptionId(subscriptionId))); + } + + Set retailCustomerIds = new LinkedHashSet<>(); + String customerResourceUri = stringAttribute(principal, "customerResourceURI"); + if (customerResourceUri != null) { + EspiBatchUri.retailCustomerId(customerResourceUri) + .flatMap(ScopeResolver::parseLong) + .ifPresent(retailCustomerIds::add); + } + + if (usagePointIds.isEmpty() && retailCustomerIds.isEmpty()) { + return ResourceScope.denied(); + } + return ResourceScope.of(usagePointIds, retailCustomerIds); + } + + private boolean hasAdminAuthority(Authentication authentication) { + for (GrantedAuthority authority : authentication.getAuthorities()) { + if (ADMIN_AUTHORITY.equals(authority.getAuthority())) { + return true; + } + } + return false; + } + + private static String stringAttribute(OAuth2AuthenticatedPrincipal principal, String name) { + Object value = principal.getAttribute(name); + if (value == null) { + return null; + } + String s = value.toString().trim(); + return s.isEmpty() ? null : s; + } + + private static java.util.Optional parseUuid(String s) { + try { + return java.util.Optional.of(UUID.fromString(s)); + } catch (IllegalArgumentException e) { + return java.util.Optional.empty(); + } + } + + private static java.util.Optional parseLong(String s) { + try { + return java.util.Optional.of(Long.valueOf(s)); + } catch (NumberFormatException e) { + return java.util.Optional.empty(); + } + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/DevXmlSampleGenerator.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/DevXmlSampleGenerator.java new file mode 100644 index 00000000..ac2ea1e7 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/DevXmlSampleGenerator.java @@ -0,0 +1,97 @@ +/* + * + * 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.web.api.devtools; + +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto; +import org.greenbuttonalliance.espi.common.service.impl.UsageExportService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Dev-only, on-demand generator that marshals a sample ESPI resource through the real (tested) + * {@link UsageExportService} and writes the Atom XML to the repo-root {@code dev-xml-capture/sample/} + * so a developer can view the actual ESPI XML without standing up the full OAuth-secured + * server + database (#119). + * + *

This class is intentionally not named {@code *Test}/{@code *Tests}, so the normal CI + * Surefire run does not execute it (no files written in CI). Run it on demand:

+ *
+ *   mvn test -pl openespi-datacustodian -Dtest=DevXmlSampleGenerator -Dsurefire.failIfNoSpecifiedTests=false
+ * 
+ *

Output (git-ignored): {@code dev-xml-capture/sample/UsagePoint-sample.xml}.

+ */ +@DisplayName("Dev ESPI XML sample generator (#119)") +class DevXmlSampleGenerator { + + @Test + @DisplayName("write a sample UsagePoint Atom XML to dev-xml-capture/sample") + void generateUsagePointSample() throws Exception { + UsageExportService usageExportService = new UsageExportService(); + usageExportService.init(); // not using Spring context here + + UsagePointDto usagePoint = new UsagePointDto( + new byte[]{0x01, 0x02}, // roleFlags + null, // serviceCategory + (short) 1, // status + null, null, null, null, null, + null, null, null, null, + null, null, null, null, null, + null, null, null, null, + null, null, null, null, null); + + UsageAtomEntryDto entry = new UsageAtomEntryDto( + "urn:uuid:550e8400-e29b-51d4-a716-446655440000", + "Residential Electric Service - Usage Domain (dev sample)", + usagePoint); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + usageExportService.exportDto(entry, out); + byte[] xml = out.toByteArray(); + + Path dir = repoRoot().resolve("dev-xml-capture").resolve("sample"); + Files.createDirectories(dir); + Path file = dir.resolve("UsagePoint-sample.xml"); + Files.write(file, xml); + + System.out.println("\n[dev] wrote sample ESPI XML -> " + file.toAbsolutePath() + "\n"); + System.out.println(new String(xml)); + + assertThat(Files.readString(file)).contains("urn:uuid:x"; + + /** All .xml files anywhere under {@code base} (captures live in a per-run sub-dir). */ + private static List xmlFiles(Path base) throws Exception { + if (!Files.exists(base)) { + return List.of(); + } + try (var paths = Files.walk(base)) { + return paths.filter(p -> p.toString().endsWith(".xml")).toList(); + } + } + + private static FilterChain chain(String contentType, String body) { + return (req, res) -> { + res.setContentType(contentType); + res.getOutputStream().write(body.getBytes()); + }; + } + + @Test + @DisplayName("captures an XML resource response under a per-run sub-dir and passes the body through") + void capturesXmlResponse(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/UsagePoint/503888"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new EspiXmlCaptureFilter(true, dir.toString()) + .doFilter(request, response, chain("application/atom+xml", XML)); + + List files = xmlFiles(dir); + assertThat(files).hasSize(1); + Path captured = files.get(0); + assertThat(captured.getFileName().toString()).isEqualTo("GET_UsagePoint_503888.xml"); + assertThat(captured.getParent().getFileName().toString()).startsWith("run-"); + assertThat(Files.readString(captured)).isEqualTo(XML); + // body is still delivered to the client + assertThat(response.getContentAsString()).isEqualTo(XML); + } + + @Test + @DisplayName("does not capture non-XML responses") + void ignoresNonXml(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/UsagePoint"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new EspiXmlCaptureFilter(true, dir.toString()) + .doFilter(request, response, chain("application/json", "{}")); + + assertThat(xmlFiles(dir)).isEmpty(); + assertThat(response.getContentAsString()).isEqualTo("{}"); + } + + @Test + @DisplayName("does not filter requests outside the resource path") + void skipsNonResourcePaths(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/actuator/health"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new EspiXmlCaptureFilter(true, dir.toString()) + .doFilter(request, response, chain("application/atom+xml", XML)); + + assertThat(xmlFiles(dir)).isEmpty(); + assertThat(response.getContentAsString()).isEqualTo(XML); + } + + @Test + @DisplayName("disabled flag suppresses capture entirely") + void disabledSuppressesCapture(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/UsagePoint"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new EspiXmlCaptureFilter(false, dir.toString()) + .doFilter(request, response, chain("application/atom+xml", XML)); + + assertThat(xmlFiles(dir)).isEmpty(); + assertThat(response.getContentAsString()).isEqualTo(XML); + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptionsTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptionsTest.java new file mode 100644 index 00000000..aa1511e9 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/EspiQueryOptionsTest.java @@ -0,0 +1,64 @@ +/* + * + * 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.web.api.scope; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link EspiQueryOptions} — REQ.21.6.2.8 feed query parameters (all optional) (#119). + */ +@DisplayName("EspiQueryOptions — REQ.21.6.2.8 (#119)") +class EspiQueryOptionsTest { + + @Test + @DisplayName("none() is empty and has no timestamp filter") + void noneIsEmpty() { + EspiQueryOptions opts = EspiQueryOptions.none(); + assertThat(opts.isEmpty()).isTrue(); + assertThat(opts.hasTimestampFilter()).isFalse(); + } + + @Test + @DisplayName("a timestamp filter is detected and not considered empty") + void timestampFilterDetected() { + EspiQueryOptions opts = new EspiQueryOptions( + OffsetDateTime.parse("2025-01-01T00:00:00Z"), null, null, null, + null, null, null, null); + assertThat(opts.isEmpty()).isFalse(); + assertThat(opts.hasTimestampFilter()).isTrue(); + } + + @Test + @DisplayName("optional DB knobs alone are not empty but are not timestamp filters") + void optionalKnobsNotTimestamp() { + EspiQueryOptions opts = new EspiQueryOptions( + null, null, null, null, 100, 0, "cursor-1", 2); + assertThat(opts.isEmpty()).isFalse(); + assertThat(opts.hasTimestampFilter()).isFalse(); + assertThat(opts.maxResults()).isEqualTo(100); + assertThat(opts.startIndex()).isZero(); + assertThat(opts.startAfter()).isEqualTo("cursor-1"); + assertThat(opts.depth()).isEqualTo(2); + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScopeTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScopeTest.java new file mode 100644 index 00000000..cc5ebddc --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ResourceScopeTest.java @@ -0,0 +1,86 @@ +/* + * + * 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.web.api.scope; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ResourceScope} — the resolved per-request visibility scope (#119). + */ +@DisplayName("ResourceScope (#119)") +class ResourceScopeTest { + + private static final UUID UP = UUID.fromString("00000000-0000-5000-8000-000000000001"); + + @Test + @DisplayName("admin permits everything") + void adminPermitsAll() { + ResourceScope admin = ResourceScope.unfiltered(); + assertThat(admin.admin()).isTrue(); + assertThat(admin.isDenied()).isFalse(); + assertThat(admin.permitsUsagePoint(UUID.randomUUID())).isTrue(); + assertThat(admin.permitsRetailCustomer(1L)).isTrue(); + } + + @Test + @DisplayName("of(...) permits only listed ids; denies others") + void scopedPermitsOnlyListed() { + ResourceScope scope = ResourceScope.of(Set.of(UP), Set.of(42L)); + assertThat(scope.admin()).isFalse(); + assertThat(scope.permitsUsagePoint(UP)).isTrue(); + assertThat(scope.permitsUsagePoint(UUID.randomUUID())).isFalse(); + assertThat(scope.permitsRetailCustomer(42L)).isTrue(); + assertThat(scope.permitsRetailCustomer(7L)).isFalse(); + assertThat(scope.isDenied()).isFalse(); + } + + @Test + @DisplayName("denied permits nothing and is fail-closed") + void deniedPermitsNothing() { + ResourceScope denied = ResourceScope.denied(); + assertThat(denied.admin()).isFalse(); + assertThat(denied.isDenied()).isTrue(); + assertThat(denied.permitsUsagePoint(UP)).isFalse(); + assertThat(denied.permitsRetailCustomer(42L)).isFalse(); + } + + @Test + @DisplayName("null id arguments are never permitted (except under admin)") + void nullIdsNotPermitted() { + ResourceScope scope = ResourceScope.of(Set.of(UP), Set.of(42L)); + assertThat(scope.permitsUsagePoint(null)).isFalse(); + assertThat(scope.permitsRetailCustomer(null)).isFalse(); + assertThat(ResourceScope.unfiltered().permitsUsagePoint(null)).isTrue(); + } + + @Test + @DisplayName("null constructor sets normalize to empty immutable sets") + void nullSetsNormalized() { + ResourceScope scope = new ResourceScope(false, null, null); + assertThat(scope.usagePointIds()).isEmpty(); + assertThat(scope.retailCustomerIds()).isEmpty(); + assertThat(scope.isDenied()).isTrue(); + } +} diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolverTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolverTest.java new file mode 100644 index 00000000..6c40bb74 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/scope/ScopeResolverTest.java @@ -0,0 +1,137 @@ +/* + * + * 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.web.api.scope; + +import org.greenbuttonalliance.espi.common.repositories.usage.SubscriptionRepository; +import org.greenbuttonalliance.espi.common.uri.EspiBatchUri; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link ScopeResolver} — the resource-server data-visibility scope core (#119). + */ +@DisplayName("ScopeResolver — token → ResourceScope (#119)") +class ScopeResolverTest { + + private static final String BASE = "https://utilityapi.com/DataCustodian/espi/1_1/resource"; + + private final SubscriptionRepository subscriptionRepository = mock(SubscriptionRepository.class); + private final ScopeResolver resolver = new ScopeResolver(subscriptionRepository); + + private static Authentication auth(Map attributes, String... authorities) { + DefaultOAuth2AuthenticatedPrincipal principal = + new DefaultOAuth2AuthenticatedPrincipal("tester", attributes, null); + return new TestingAuthenticationToken(principal, null, authorities); + } + + @Test + @DisplayName("null / unauthenticated → denied") + void nullAuthDenied() { + assertThat(resolver.resolve(null).isDenied()).isTrue(); + TestingAuthenticationToken unauth = new TestingAuthenticationToken("x", null); + unauth.setAuthenticated(false); + assertThat(resolver.resolve(unauth).isDenied()).isTrue(); + verifyNoInteractions(subscriptionRepository); + } + + @Test + @DisplayName("DataCustodian admin authority → admin (unfiltered)") + void adminAuthority() { + ResourceScope scope = resolver.resolve( + auth(Map.of("active", true), "SCOPE_DataCustodian_Admin_Access")); + + assertThat(scope.admin()).isTrue(); + assertThat(scope.permitsUsagePoint(UUID.randomUUID())).isTrue(); + assertThat(scope.permitsRetailCustomer(999L)).isTrue(); + verifyNoInteractions(subscriptionRepository); + } + + @Test + @DisplayName("Bulk resourceURI → admin (back-end/admin grant)") + void bulkResourceUriIsAdmin() { + ResourceScope scope = resolver.resolve( + auth(Map.of("resourceURI", EspiBatchUri.batchBulk(BASE, "BULK_1")), "FB_4")); + + assertThat(scope.admin()).isTrue(); + verifyNoInteractions(subscriptionRepository); + } + + @Test + @DisplayName("Subscription resourceURI → that subscription's granted UsagePoint ids (energy)") + void subscriptionResourceUriResolvesUsagePoints() { + UUID subscriptionId = UUID.fromString("11111111-2222-5333-8444-555555555555"); + UUID up1 = UUID.fromString("00000000-0000-5000-8000-000000000001"); + UUID up2 = UUID.fromString("00000000-0000-5000-8000-000000000002"); + when(subscriptionRepository.findUsagePointIdsBySubscriptionId(subscriptionId)) + .thenReturn(List.of(up1, up2)); + + ResourceScope scope = resolver.resolve(auth( + Map.of("resourceURI", EspiBatchUri.batchSubscription(BASE, subscriptionId)), "FB_4")); + + assertThat(scope.admin()).isFalse(); + assertThat(scope.usagePointIds()).containsExactlyInAnyOrder(up1, up2); + assertThat(scope.permitsUsagePoint(up1)).isTrue(); + assertThat(scope.permitsUsagePoint(UUID.randomUUID())).isFalse(); + } + + @Test + @DisplayName("customerResourceURI → that RetailCustomer id (PII, Long key)") + void customerResourceUriResolvesRetailCustomer() { + ResourceScope scope = resolver.resolve(auth( + Map.of("customerResourceURI", EspiBatchUri.batchRetailCustomer(BASE, "42")), "FB_53")); + + assertThat(scope.admin()).isFalse(); + assertThat(scope.retailCustomerIds()).containsExactly(42L); + assertThat(scope.permitsRetailCustomer(42L)).isTrue(); + assertThat(scope.permitsRetailCustomer(7L)).isFalse(); + } + + @Test + @DisplayName("non-admin token with no resolvable grant → denied (fail-closed)") + void noGrantDenied() { + ResourceScope scope = resolver.resolve(auth(Map.of("active", true), "FB_4")); + assertThat(scope.isDenied()).isTrue(); + assertThat(scope.permitsUsagePoint(UUID.randomUUID())).isFalse(); + } + + @Test + @DisplayName("a subscription with no granted usage points → denied (not unfiltered)") + void emptySubscriptionDenied() { + UUID subscriptionId = UUID.fromString("22222222-3333-5444-8555-666666666666"); + when(subscriptionRepository.findUsagePointIdsBySubscriptionId(subscriptionId)) + .thenReturn(List.of()); + + ResourceScope scope = resolver.resolve(auth( + Map.of("resourceURI", EspiBatchUri.batchSubscription(BASE, subscriptionId)), "FB_4")); + + assertThat(scope.isDenied()).isTrue(); + } +}