From dcc36be10952010b4739db7987456eed4cf2863c Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 14:59:45 -0500 Subject: [PATCH 1/3] fix(#247): export DC collection endpoints as Atom feeds instead of a bare List Every DC feed endpoint returned 500: the controllers passed a List to BaseExportService.exportDto, and JAXB cannot marshal a collection root ("ImmutableCollections$ListN ... not known to this context"). Even an empty List.of() failed. Feed assembly (openespi-common) - BaseExportService.buildFeed/exportFeed: Atom feed with a UUID v5 id (from the feed URL), title, published/updated, self link (espi-feed/), and one entry per resource: urn:uuid:, title (description, else the resource name), the entity's timestamps, self (espi-entry/) and up (espi-feed/) links, and the mapped DTO as content. Stored links are used only when they are absolute http(s) URLs; otherwise they are derived from the feed URL, so they carry the real host and /DataCustodian context. - newEntry(): usage exporters build UsageAtomEntryDto; the customer exporters override it to build CustomerAtomEntryDto (the type in their JAXBContext). - exportDto rejects a bare Collection with a message naming exportFeed. - AtomFeedDto: published/updated get OffsetDateTimeAdapter (they were written as empty elements); entries use @XmlElementRef so entries carry no xsi:type="atom:UsageAtomEntry". - LinkDto: comment pinning the rel, href, type field order, which JAXB uses as the attribute order the CMD certification test requires. - IdentifiedObject.ensureSelfLink/ensureUpLink passed (rel, href) to the LinkType(href, rel) constructor, storing href="self" and href="up" on every entity given an id. Data Custodian - All 13 list call sites in 10 controllers use exportFeed with the request URL; ApplicationInformationService.export(List) takes the feed URL. - AuthorizationController: both stubs (which returned null, i.e. 200 with an empty body) are implemented: an Atom feed, and an Atom entry or 404. - EspiXmlCaptureFilter (local/dev profiles): a 4xx/5xx response or an exception writes _.error.txt (status, request, exception chain, body) beside the captured XML; the exception is still rethrown. A relative capture dir resolves to the repository root. Tests - AtomFeedExportTest (real exporters): usage and customer feeds, empty feed, link attribute order rel/href/type, collection rejection. - FeedEndpointsIntegrationTest (DC, real exporters, H2 data): UsagePoint feed with an entry, empty Authorization feed, Authorization 404. - EspiXmlCaptureFilterTest: exception and 4xx capture, repo-root resolution. Closes #247 Co-Authored-By: Claude Opus 5.5 --- .../domain/common/IdentifiedObject.java | 5 +- .../espi/common/dto/atom/AtomFeedDto.java | 12 +- .../espi/common/dto/atom/LinkDto.java | 5 + .../ApplicationInformationService.java | 7 +- .../common/service/BaseExportService.java | 123 ++++++++++++++ .../ApplicationInformationServiceImpl.java | 8 +- .../impl/CustomerAccountExportService.java | 12 ++ .../service/impl/CustomerExportService.java | 12 ++ .../common/service/AtomFeedExportTest.java | 159 ++++++++++++++++++ .../ApplicationInformationRESTController.java | 4 +- .../web/api/AuthorizationController.java | 99 ++++++----- .../api/CustomerAccountRESTController.java | 9 +- .../web/api/CustomerRESTController.java | 9 +- ...tricPowerQualitySummaryRESTController.java | 19 +-- .../web/api/IntervalBlockRESTController.java | 17 +- .../web/api/MeterReadingController.java | 9 +- .../web/api/ReadingTypeRESTController.java | 9 +- .../web/api/UsagePointController.java | 17 +- .../web/api/UsageSummaryRESTController.java | 17 +- .../api/devtools/EspiXmlCaptureFilter.java | 75 ++++++++- ...licationInformationRESTControllerTest.java | 4 +- .../web/api/FeedEndpointsIntegrationTest.java | 105 ++++++++++++ .../devtools/EspiXmlCaptureFilterTest.java | 62 +++++++ 23 files changed, 676 insertions(+), 122 deletions(-) create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java index e46b98d0..6ea48c43 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/common/IdentifiedObject.java @@ -189,7 +189,8 @@ public void generateEspiCompliantId(EspiIdGeneratorService idGeneratorService) { */ public void ensureSelfLink() { if (selfLink == null) { - selfLink = new LinkType("self", generateDefaultSelfHref()); + // LinkType(href, rel): these arguments were swapped, storing href="self" (#247) + selfLink = new LinkType(generateDefaultSelfHref(), "self"); } } @@ -199,7 +200,7 @@ public void ensureSelfLink() { */ public void ensureUpLink() { if (upLink == null) { - upLink = new LinkType("up", generateDefaultUpHref()); + upLink = new LinkType(generateDefaultUpHref(), "up"); } } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java index 8ec8cbe1..0be283cf 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/AtomFeedDto.java @@ -20,7 +20,9 @@ package org.greenbuttonalliance.espi.common.dto.atom; import jakarta.xml.bind.annotation.*; +import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import lombok.AllArgsConstructor; +import org.greenbuttonalliance.espi.common.utils.OffsetDateTimeAdapter; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -55,16 +57,24 @@ public class AtomFeedDto { @XmlElement(name = "title") private String title; + // Without the adapter JAXB cannot map OffsetDateTime and writes an empty element (#247). @XmlElement(name = "published") + @XmlJavaTypeAdapter(OffsetDateTimeAdapter.class) private OffsetDateTime published; @XmlElement(name = "updated") + @XmlJavaTypeAdapter(OffsetDateTimeAdapter.class) private OffsetDateTime updated; @XmlElement(name = "link") private List links; - @XmlElement(name = "entry") + /** + * Entries are written through their own root element ({@code UsageAtomEntryDto} or + * {@code CustomerAtomEntryDto}, both {@code atom:entry}). A plain {@code @XmlElement} on the abstract + * {@link AtomEntryDto} made JAXB add {@code xsi:type="atom:UsageAtomEntry"} to every entry (#247). + */ + @XmlElementRef private List entries; /** diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java index 1b0d0437..73ef37d2 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/LinkDto.java @@ -40,6 +40,11 @@ @AllArgsConstructor public class LinkDto { + // DO NOT REORDER these three fields. The GBA CMD certification test requires every atom:link to be + // written as rel=, href=, type= in that sequence, and JAXB writes attributes in field declaration + // order (@XmlType propOrder does not apply to attributes). Guarded by + // AtomFeedExportTest#linkAttributesAreRelHrefType. + @XmlAttribute(name = "rel") private String rel; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java index b454ea47..8754ada2 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java @@ -64,7 +64,12 @@ public ApplicationInformationEntity findByDataCustodianClientId( void deleteById(UUID id); - void export(List entities, OutputStream outputStream); + /** + * Exports the entities as an Atom feed (#247). + * + * @param feedHref absolute URL of the feed (its self link, and the source of its UUID v5 id) + */ + void export(String feedHref, List entities, OutputStream outputStream); void export(ApplicationInformationEntity entity, OutputStream outputStream); diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java index f955de73..41af22ca 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/BaseExportService.java @@ -23,11 +23,27 @@ import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; +import jakarta.xml.bind.annotation.XmlRootElement; import lombok.extern.slf4j.Slf4j; +import org.greenbuttonalliance.espi.common.domain.common.IdentifiedObject; +import org.greenbuttonalliance.espi.common.domain.common.LinkType; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; /** * Abstract base class for domain-specific XML export services. @@ -161,6 +177,12 @@ protected Marshaller createMarshaller() throws JAXBException { * @throws RuntimeException if marshalling fails */ public void exportDto(Object dto, OutputStream stream) { + if (dto instanceof Collection) { + // A bare collection has no XML root element; JAXB fails with an obscure + // "ImmutableCollections$ListN ... not known to this context" (#247). + throw new IllegalArgumentException("exportDto cannot marshal a " + dto.getClass().getSimpleName() + + "; wrap collections in an Atom feed with exportFeed(...)"); + } try { Marshaller marshaller = createMarshaller(); marshaller.marshal(dto, stream); @@ -196,4 +218,105 @@ public void exportDtoWithHeader(Object dto, OutputStream stream) { throw new RuntimeException("Failed to export DTO with header", e); } } + + /** + * Exports a collection of ESPI resources as an Atom {@code } (#247). + * + * @param feedHref absolute URL of the feed (its self link, and the source of its UUID v5 id) + * @param title feed title + * @param entities the resources, in feed order; may be empty (an empty feed is still valid) + * @param toContent maps an entity to the ESPI DTO carried in its entry's {@code } + * @param stream the output stream to write XML to + * @param entity type + * @see #buildFeed(String, String, List, Function) + */ + public void exportFeed(String feedHref, String title, List entities, + Function toContent, OutputStream stream) { + exportDto(buildFeed(feedHref, title, entities, toContent), stream); + } + + /** + * Builds an Atom feed whose entries carry the ESPI resources, per REQ.21: + *
    + *
  • feed: {@code urn:uuid} id (UUID v5 of {@code feedHref}), title, published/updated (now, UTC), + * {@code self} link of type {@code espi-feed/};
  • + *
  • entry: {@code urn:uuid:}, title (the entity description, else the resource name), + * the entity's published/updated, {@code self} ({@code espi-entry/}) and {@code up} + * ({@code espi-feed/}) links, and the mapped DTO as {@code }.
  • + *
+ * Stored self/up links are used when they are absolute URLs; otherwise they are derived from + * {@code feedHref} ({@code /} and {@code feedHref}). + */ + public AtomFeedDto buildFeed(String feedHref, String title, List entities, + Function toContent) { + Objects.requireNonNull(feedHref, "feedHref"); + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS); + + List entries = new ArrayList<>(entities.size()); + String resource = null; + for (E entity : entities) { + Object content = toContent.apply(entity); + String entryResource = resourceName(content); + resource = resource == null ? entryResource : resource; + + String selfHref = href(entity.getSelfLink(), feedHref + "/" + entity.getId()); + String upHref = href(entity.getUpLink(), feedHref); + List links = List.of( + new LinkDto("self", selfHref, "espi-entry/" + entryResource), + new LinkDto("up", upHref, "espi-feed/" + entryResource)); + String entryTitle = entity.getDescription() != null && !entity.getDescription().isBlank() + ? entity.getDescription() : entryResource; + entries.add(newEntry("urn:uuid:" + entity.getId(), entryTitle, + utc(entity.getPublished(), entity.getCreated(), now), + utc(entity.getUpdated(), entity.getCreated(), now), + links, content)); + } + + AtomFeedDto feed = new AtomFeedDto(); + feed.setId("urn:uuid:" + ID_GENERATOR.generateEspiId(feedHref)); + feed.setTitle(title); + feed.setPublished(now); + feed.setUpdated(now); + feed.setLinks(List.of(new LinkDto("self", feedHref, + resource != null ? "espi-feed/" + resource : null))); + feed.setEntries(entries); + return feed; + } + + /** + * Creates the Atom entry for one resource. Usage-domain exporters use {@link UsageAtomEntryDto}; + * customer-domain exporters override this to return {@code CustomerAtomEntryDto}, so the entry type + * matches the one registered in their JAXBContext. + */ + protected AtomEntryDto newEntry(String id, String title, OffsetDateTime published, OffsetDateTime updated, + List links, Object content) { + return new UsageAtomEntryDto(id, title, published, updated, links, content); + } + + private static final EspiIdGeneratorService ID_GENERATOR = new EspiIdGeneratorService(); + + /** The ESPI resource name: the DTO's {@code @XmlRootElement} name, else its class name without "Dto". */ + static String resourceName(Object content) { + XmlRootElement root = content.getClass().getAnnotation(XmlRootElement.class); + if (root != null && !root.name().isBlank() && !"##default".equals(root.name())) { + return root.name(); + } + String simple = content.getClass().getSimpleName(); + return simple.endsWith("Dto") ? simple.substring(0, simple.length() - 3) : simple; + } + + /** + * A stored link is used only when it is an absolute http(s) URL. Default links generated by + * {@link IdentifiedObject#ensureSelfLink()} are host- and context-less paths, and older rows may hold + * {@code href="self"} (swapped arguments, fixed in #247), so those fall back to the derived href. + */ + private static String href(LinkType link, String fallback) { + String href = link != null ? link.getHref() : null; + return href != null && (href.startsWith("https://") || href.startsWith("http://")) ? href : fallback; + } + + private static OffsetDateTime utc(LocalDateTime value, LocalDateTime fallback, OffsetDateTime now) { + LocalDateTime chosen = value != null ? value : fallback; + return chosen != null ? chosen.atOffset(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS) : now; + } } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java index bd7c5aca..1b954f9e 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java @@ -117,11 +117,9 @@ public void deleteById(UUID id) { } @Override - public void export(List entities, OutputStream outputStream) { - List dtos = entities.stream() - .map(applicationInformationMapper::toDto) - .toList(); - applicationInformationExportService.exportDto(dtos, outputStream); + public void export(String feedHref, List entities, OutputStream outputStream) { + applicationInformationExportService.exportFeed(feedHref, "ApplicationInformation Feed", entities, + applicationInformationMapper::toDto, outputStream); } @Override diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java index e99a7bba..fe41680a 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java @@ -21,9 +21,14 @@ import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; import org.greenbuttonalliance.espi.common.service.BaseExportService; import org.springframework.stereotype.Service; +import java.time.OffsetDateTime; +import java.util.List; import java.util.Set; /** @@ -69,4 +74,11 @@ protected JAXBContext createJAXBContext() throws JAXBException { protected Set getDomainNamespaces() { return Set.of(ATOM_NAMESPACE, CUSTOMER_NAMESPACE); } + + /** Customer-domain feeds use {@link CustomerAtomEntryDto}, the entry type registered above. */ + @Override + protected AtomEntryDto newEntry(String id, String title, OffsetDateTime published, OffsetDateTime updated, + List links, Object content) { + return new CustomerAtomEntryDto(id, title, published, updated, links, content); + } } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java index 6bac02ee..2c5705ee 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java @@ -21,9 +21,14 @@ import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.dto.atom.AtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; import org.greenbuttonalliance.espi.common.service.BaseExportService; import org.springframework.stereotype.Service; +import java.time.OffsetDateTime; +import java.util.List; import java.util.Set; /** @@ -104,4 +109,11 @@ protected JAXBContext createJAXBContext() throws JAXBException { protected Set getDomainNamespaces() { return Set.of(ATOM_NAMESPACE, CUSTOMER_NAMESPACE); } + + /** Customer-domain feeds use {@link CustomerAtomEntryDto}, the entry type registered above. */ + @Override + protected AtomEntryDto newEntry(String id, String title, OffsetDateTime published, OffsetDateTime updated, + List links, Object content) { + return new CustomerAtomEntryDto(id, title, published, updated, links, content); + } } \ No newline at end of file diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java new file mode 100644 index 00000000..f044f791 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/AtomFeedExportTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.greenbuttonalliance.espi.common.service; + +import org.greenbuttonalliance.espi.common.domain.common.LinkType; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity; +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto; +import org.greenbuttonalliance.espi.common.service.impl.CustomerExportService; +import org.greenbuttonalliance.espi.common.service.impl.UsageExportService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * #247: collections of ESPI resources are exported as an Atom feed, using the real exporters + * (the controller tests mock them, which is how the feed endpoints' 500s went unnoticed). + */ +@DisplayName("Atom feed export (#247)") +class AtomFeedExportTest { + + private static final String FEED = "https://dc.example/DataCustodian/espi/1_1/resource/UsagePoint"; + + private static UsageExportService usage; + private static CustomerExportService customer; + + @BeforeAll + static void initExporters() { + usage = new UsageExportService(); + usage.init(); + customer = new CustomerExportService(); + customer.init(); + } + + @Test + @DisplayName("a list of usage points becomes a feed with one entry per resource") + void usagePointsBecomeAFeed() { + UUID first = UUID.fromString("48c2a019-5598-5e16-b0f9-49e4ff27f5fb"); + UUID second = UUID.fromString("5d6f1b42-0a6e-5f0e-9c3e-2a1d7b8c9e01"); + UsagePointEntity stored = usagePoint(first, "Front of house meter"); + stored.setSelfLink(new LinkType(FEED + "/" + first, "self")); + stored.setPublished(LocalDateTime.of(2026, 1, 2, 3, 4, 5)); + UsagePointEntity derived = usagePoint(second, null); + + String xml = export(usage, List.of(stored, derived)); + System.out.println(xml); + + String feedId = usage.buildFeed(FEED, "t", List.of(), u -> u).getId(); + assertThat(xml) + .contains("" + feedId + "", "UsagePoint Feed") + .contains("rel=\"self\" href=\"" + FEED + "\" type=\"espi-feed/UsagePoint\"") + .contains("urn:uuid:" + first + "", "Front of house meter") + .contains("href=\"" + FEED + "/" + first + "\" type=\"espi-entry/UsagePoint\"") + .contains("urn:uuid:" + second + "", "UsagePoint") + .contains("href=\"" + FEED + "/" + second + "\"") + .contains("rel=\"up\" href=\"" + FEED + "\" type=\"espi-feed/UsagePoint\"") + .contains("2026-01-02T03:04:05Z") + .contains("", ""); + assertThat(feedId).matches("urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}"); + assertThat(count(xml, "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z") + .doesNotContain(" new CustomerDto(), out); + String xml = out.toString(StandardCharsets.UTF_8); + System.out.println(xml); + + assertThat(xml) + .contains(" links = java.util.regex.Pattern.compile("]*/>").matcher(xml) + .results().map(java.util.regex.MatchResult::group).toList(); + + // 2 feeds' self links + 1 entry's self and up links + assertThat(links).hasSize(4) + .allSatisfy(link -> assertThat(link) + .matches("")); + assertThat(links).filteredOn(link -> link.contains("type=")).hasSize(3); + } + + @Test + @DisplayName("exportDto rejects a bare collection with a pointer to exportFeed") + void exportDtoRejectsCollections() { + assertThatThrownBy(() -> usage.exportDto(List.of(new UsagePointDto()), new ByteArrayOutputStream())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exportFeed"); + } + + private static UsagePointEntity usagePoint(UUID id, String description) { + UsagePointEntity entity = new UsagePointEntity(); + entity.setId(id); + entity.setDescription(description); + return entity; + } + + private static String export(UsageExportService exporter, List entities) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + exporter.exportFeed(FEED, "UsagePoint Feed", entities, e -> new UsagePointDto(), out); + return out.toString(StandardCharsets.UTF_8); + } + + private static int count(String haystack, String needle) { + return haystack.split(needle, -1).length - 1; + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java index 8bb73d6e..7a964ca6 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java @@ -37,6 +37,7 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -79,7 +80,8 @@ public ResponseEntity getAllApplicationInformation() { List entities = applicationInformationService.findAll(); ByteArrayOutputStream out = new ByteArrayOutputStream(); - applicationInformationService.export(entities, out); + applicationInformationService.export(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + entities, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java index c5444b08..98a97094 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java @@ -26,52 +26,56 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; import org.greenbuttonalliance.espi.common.dto.usage.AuthorizationDto; +import org.greenbuttonalliance.espi.common.mapper.usage.AuthorizationMapper; +import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; +import org.greenbuttonalliance.espi.common.service.impl.UsageExportService; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import java.io.ByteArrayOutputStream; import java.util.List; import java.util.UUID; /** - * Modern REST Controller for ESPI Authorization resources. - *

- * This controller implements the NAESB ESPI 1.0 REST API for OAuth2 Authorizations, - * using modern Spring Boot 4.x patterns with DTOs and MapStruct mappers. - *

- * Supported endpoints: - * - GET /espi/1_1/resource/Authorization - List all authorizations - * - GET /espi/1_1/resource/Authorization/{authorizationId} - Get specific authorization + * ESPI Authorization resources (admin access only). + *

    + *
  • {@code GET /espi/1_1/resource/Authorization}: Atom feed of Authorizations; an empty feed when + * there are none.
  • + *
  • {@code GET /espi/1_1/resource/Authorization/{authorizationId}}: one Authorization as an Atom + * entry, or 404.
  • + *
+ * Both previously returned {@code null}, i.e. HTTP 200 with an empty body (#247). Token values, + * authorization codes and state never appear in the XML: those {@link AuthorizationDto} fields are + * {@code @XmlTransient}. */ - -//todo - complete implementation @RestController @RequestMapping("/espi/1_1/resource") @Tag(name = "Authorizations", description = "OAuth2 Authorization Management API") @SecurityRequirement(name = "oauth2") +@RequiredArgsConstructor public class AuthorizationController { -// private final AuthorizationRepository authorizationRepository; -// private final AuthorizationMapper authorizationMapper; -// -// public AuthorizationController(AuthorizationRepository authorizationRepository, AuthorizationMapper authorizationMapper) { -// this.authorizationRepository = authorizationRepository; -// this.authorizationMapper = authorizationMapper; -// } + private final AuthorizationRepository authorizationRepository; + private final AuthorizationMapper authorizationMapper; + private final UsageExportService usageExportService; /** * Get all Authorizations (admin access only). - * This endpoint is restricted to DataCustodian administrators. */ @GetMapping(value = "/Authorization", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) @Operation( summary = "Get all Authorizations", - description = "Retrieve all OAuth2 Authorizations (admin access only)", + description = "Retrieve all OAuth2 Authorizations as an Atom feed (admin access only)", responses = { @ApiResponse(responseCode = "200", description = "Authorizations retrieved successfully", content = @Content(schema = @Schema(implementation = AuthorizationDto.class))), @@ -80,26 +84,25 @@ public class AuthorizationController { } ) @PreAuthorize("hasAnyAuthority('SCOPE_DataCustodian_Admin_Access', 'SCOPE_ThirdParty_Admin_Access')") - public ResponseEntity> getAllAuthorizations( + public ResponseEntity getAllAuthorizations( @Parameter(description = "Maximum number of results to return", example = "50") @RequestParam(defaultValue = "50") int limit, @Parameter(description = "Offset for pagination", example = "0") - @RequestParam(defaultValue = "0") int offset, - Authentication authentication) { - - Pageable pageable = PageRequest.of(offset / limit, limit); -// List authorizationEntities = authorizationRepository.findAll(pageable).getContent(); -// List authorizations = authorizationEntities.stream() -// .map(authorizationMapper::toDto) -// .toList(); + @RequestParam(defaultValue = "0") int offset) { + + List authorizations = + authorizationRepository.findAll(PageRequest.of(offset / limit, limit)).getContent(); - return null; - // return ResponseEntity.ok(authorizations); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "Authorization Feed", authorizations, authorizationMapper::toDto, out); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_XML) + .body(out.toByteArray()); } /** - * Get specific Authorization by ID. - * Access restricted based on ownership and scope. + * Get specific Authorization by ID (admin access only). */ @GetMapping(value = "/Authorization/{authorizationId}", produces = {MediaType.APPLICATION_XML_VALUE, MediaType.APPLICATION_JSON_VALUE}) @Operation( @@ -114,15 +117,25 @@ public ResponseEntity> getAllAuthorizations( } ) @PreAuthorize("hasAnyAuthority('SCOPE_DataCustodian_Admin_Access', 'SCOPE_ThirdParty_Admin_Access')") - public ResponseEntity getAuthorization( + public ResponseEntity getAuthorization( @Parameter(description = "Unique identifier of the Authorization", required = true) - @PathVariable UUID authorizationId, - Authentication authentication) { + @PathVariable UUID authorizationId) { + + AuthorizationEntity authorization = authorizationRepository.findById(authorizationId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Authorization not found for id: " + authorizationId)); + + String self = ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(); + String up = self.substring(0, self.lastIndexOf('/')); + // One entry, built like a feed entry so it carries the same id, links and timestamps. + UsageAtomEntryDto entry = (UsageAtomEntryDto) usageExportService + .buildFeed(up, "Authorization", List.of(authorization), authorizationMapper::toDto) + .getEntries().getFirst(); - return null; -// return authorizationRepository.findById(authorizationId) -// .map(authorizationMapper::toDto) -// .map(authorization -> ResponseEntity.ok(authorization)) -// .orElse(ResponseEntity.notFound().build()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + usageExportService.exportDto(entry, out); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_XML) + .body(out.toByteArray()); } -} \ No newline at end of file +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java index 231aa7a4..7873706b 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerAccountRESTController.java @@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -81,12 +82,10 @@ public ResponseEntity getCustomerAccountCollection( @Parameter(description = "Offset for pagination", example = "0") @RequestParam(defaultValue = "0") int offset) { - List dtos = customerAccountRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(customerAccountMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - customerAccountExportService.exportDto(dtos, out); + customerAccountExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "CustomerAccount Feed", customerAccountRepository.findAll(PageRequest.of(offset, limit)).getContent(), + customerAccountMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java index 132b3092..82256b4d 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/CustomerRESTController.java @@ -37,6 +37,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -81,12 +82,10 @@ public ResponseEntity getCustomerCollection( @Parameter(description = "Offset for pagination", example = "0") @RequestParam(defaultValue = "0") int offset) { - List dtos = customerRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(customerMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - customerExportService.exportDto(dtos, out); + customerExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "Customer Feed", customerRepository.findAll(PageRequest.of(offset, limit)).getContent(), + customerMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java index 102e2828..1c65a176 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ElectricPowerQualitySummaryRESTController.java @@ -38,6 +38,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -87,12 +88,11 @@ public ResponseEntity getElectricPowerQualitySummaryCollection( @Parameter(description = "Offset for pagination", example = "0") @RequestParam(defaultValue = "0") int offset) { - List summaries = electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(electricPowerQualitySummaryMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - electricPowerQualitySummaryExportService.exportDto(summaries, out); + electricPowerQualitySummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "ElectricPowerQualitySummary Feed", + electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(), + electricPowerQualitySummaryMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); @@ -156,12 +156,11 @@ public ResponseEntity getSubscriptionElectricPowerQualitySummaries( // TODO: Implement subscription-based filtering when subscription relationship is available // For now, return all summaries with pagination as a temporary solution - List summaries = electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(electricPowerQualitySummaryMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - electricPowerQualitySummaryExportService.exportDto(summaries, out); + electricPowerQualitySummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "ElectricPowerQualitySummary Feed", + electricPowerQualitySummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(), + electricPowerQualitySummaryMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java index 4b6ec409..4b0b1d74 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/IntervalBlockRESTController.java @@ -38,6 +38,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -81,12 +82,10 @@ public ResponseEntity getIntervalBlockCollection( @RequestParam(defaultValue = "0") int offset, Authentication authentication) { - List dtos = intervalBlockRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(intervalBlockMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - intervalBlockExportService.exportDto(dtos, out); + intervalBlockExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "IntervalBlock Feed", intervalBlockRepository.findAll(PageRequest.of(offset, limit)).getContent(), + intervalBlockMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); @@ -148,12 +147,10 @@ public ResponseEntity getSubscriptionIntervalBlocks( Authentication authentication) { // Use the specialized repository method for hierarchical access - List dtos = intervalBlockRepository.findAllByMeterReadingId(meterReadingId).stream() - .map(intervalBlockMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - intervalBlockExportService.exportDto(dtos, out); + intervalBlockExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "IntervalBlock Feed", intervalBlockRepository.findAllByMeterReadingId(meterReadingId), + intervalBlockMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java index ff8e1e58..0d2e6a4a 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/MeterReadingController.java @@ -38,6 +38,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -86,12 +87,10 @@ public ResponseEntity getAllMeterReadings( @RequestParam(defaultValue = "0") int offset, Authentication authentication) { - List meterReadings = meterReadingRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(meterReadingMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - meterReadingExportService.exportDto(meterReadings, out); + meterReadingExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "MeterReading Feed", meterReadingRepository.findAll(PageRequest.of(offset, limit)).getContent(), + meterReadingMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java index a47f3200..539f56ec 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ReadingTypeRESTController.java @@ -38,6 +38,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -84,12 +85,10 @@ public ResponseEntity getReadingTypeCollection( @RequestParam(defaultValue = "0") int offset, Authentication authentication) { - List readingTypes = readingTypeRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(readingTypeMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - readingTypeExportService.exportDto(readingTypes, out); + readingTypeExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "ReadingType Feed", readingTypeRepository.findAll(PageRequest.of(offset, limit)).getContent(), + readingTypeMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java index b35b5924..e6e2049b 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsagePointController.java @@ -37,6 +37,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -88,12 +89,10 @@ public UsagePointController(UsagePointRepository usagePointRepository, UsagePoin public ResponseEntity getAllUsagePoints(@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "0") int offset) { - List usagePoints = usagePointRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(usagePointMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - usageExportService.exportDto(usagePoints, out); + usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "UsagePoint Feed", usagePointRepository.findAll(PageRequest.of(offset, limit)).getContent(), + usagePointMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); @@ -168,12 +167,10 @@ public ResponseEntity getSubscriptionUsagePoints( // TODO: Implement subscription-based filtering when subscription relationship is available // For now, return all usage points with pagination as a temporary solution - List usagePoints = usagePointRepository.findAll( PageRequest.of(offset, limit)).getContent().stream() - .map(usagePointMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - usageExportService.exportDto(usagePoints, out); + usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "UsagePoint Feed", usagePointRepository.findAll(PageRequest.of(offset, limit)).getContent(), + usagePointMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java index a686556e..6b8068bc 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/UsageSummaryRESTController.java @@ -38,6 +38,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.io.ByteArrayOutputStream; import java.util.List; @@ -85,12 +86,10 @@ public ResponseEntity getUsageSummaryCollection( @RequestParam(defaultValue = "0") int offset, Authentication authentication) { - List usageSummaries = usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(usageSummaryMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - usageSummaryExportService.exportDto(usageSummaries, out); + usageSummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "UsageSummary Feed", usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(), + usageSummaryMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); @@ -151,12 +150,10 @@ public ResponseEntity getSubscriptionUsageSummaries( Authentication authentication) { // TODO: Implement subscription and usage point based filtering when relationships are available - List usageSummaries = usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent().stream() - .map(usageSummaryMapper::toDto) - .toList(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - usageSummaryExportService.exportDto(usageSummaries, out); + usageSummaryExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + "UsageSummary Feed", usageSummaryRepository.findAll(PageRequest.of(offset, limit)).getContent(), + usageSummaryMapper::toDto, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); 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 index b5170503..13cca9ee 100644 --- 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 @@ -60,6 +60,15 @@ * 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.

+ * + *

Failures are captured too (#247): a response with status 400 or above, or a request that ends + * in an exception, writes {@code _.error.txt} next to the XML, with the status, the + * request, the full exception chain (for example a JAXB marshalling error) and the response body if + * any. The exception is still rethrown, so error handling is unchanged.

+ * + *

A relative {@code espi.dev.xml-capture.dir} is resolved against the repository root (the + * nearest directory containing {@code .git}), so captures land in the same place whether the + * application is started from the repository root or from the module directory.

*/ @Component @Profile({"dev-mysql", "dev-postgresql", "local", "dev"}) @@ -79,7 +88,21 @@ public EspiXmlCaptureFilter( 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)); + this.runDir = resolveCaptureDir(captureDir).resolve("run-" + LocalDateTime.now().format(RUN_ID)); + } + + /** A relative capture dir is anchored at the repository root, else at the working directory. */ + static Path resolveCaptureDir(String captureDir) { + Path dir = Path.of(captureDir); + if (dir.isAbsolute()) { + return dir; + } + for (Path p = Path.of("").toAbsolutePath(); p != null; p = p.getParent()) { + if (Files.exists(p.resolve(".git"))) { + return p.resolve(dir); + } + } + return dir.toAbsolutePath(); } @Override @@ -91,10 +114,15 @@ protected boolean shouldNotFilter(HttpServletRequest request) { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + Throwable failure = null; try { filterChain.doFilter(request, wrapper); + } catch (ServletException | IOException | RuntimeException | Error e) { + failure = e; + throw e; } finally { capture(request, wrapper); + captureFailure(request, wrapper, failure); wrapper.copyBodyToResponse(); } } @@ -102,23 +130,54 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse private void capture(HttpServletRequest request, ContentCachingResponseWrapper wrapper) { byte[] body = wrapper.getContentAsByteArray(); String contentType = wrapper.getContentType(); - if (body.length == 0 || contentType == null + if (wrapper.getStatus() >= 400 || body.length == 0 || contentType == null || !contentType.toLowerCase().contains("xml")) { return; } + write(fileName(request, ".xml"), body, "captured ESPI XML"); + } + + /** Writes {@code _.error.txt} for a 4xx/5xx response or an exception (#247). */ + private void captureFailure(HttpServletRequest request, ContentCachingResponseWrapper wrapper, + Throwable failure) { + if (failure == null && wrapper.getStatus() < 400) { + return; + } + StringBuilder report = new StringBuilder() + .append("Time: ").append(LocalDateTime.now()).append('\n') + .append("Request: ").append(request.getMethod()).append(' ').append(request.getRequestURI()) + .append(request.getQueryString() != null ? "?" + request.getQueryString() : "").append('\n') + .append("Status: ").append(failure != null ? "unhandled exception (the container answers 500)" + : String.valueOf(wrapper.getStatus())).append('\n'); + if (failure != null) { + report.append("\nException chain:\n"); + for (Throwable t = failure; t != null; t = t.getCause() == t ? null : t.getCause()) { + report.append(" ").append(t.getClass().getName()).append(": ").append(t.getMessage()).append('\n'); + } + } + byte[] body = wrapper.getContentAsByteArray(); + if (body.length > 0) { + report.append("\nResponse body (").append(wrapper.getContentType()).append("):\n") + .append(new String(body, java.nio.charset.StandardCharsets.UTF_8)).append('\n'); + } + write(fileName(request, ".error.txt"), + report.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8), "captured ESPI failure"); + } + + private void write(String fileName, byte[] content, String what) { 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()); + Path file = runDir.resolve(fileName); + Files.write(file, content); + log.info("[dev] {} ({} bytes) -> {}", what, content.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()); + log.warn("[dev] ESPI capture failed: {}", e.getMessage()); } } /** Stable per-run filename from the request, e.g. {@code GET_UsagePoint_503888.xml}. */ - private String fileName(HttpServletRequest request) { + private String fileName(HttpServletRequest request, String suffix) { String path = request.getRequestURI(); int idx = path.indexOf(RESOURCE_PATH); String tail = idx >= 0 ? path.substring(idx + RESOURCE_PATH.length()) : path; @@ -126,6 +185,6 @@ private String fileName(HttpServletRequest request) { if (safe.isBlank()) { safe = "root"; } - return request.getMethod() + "_" + safe + ".xml"; + return request.getMethod() + "_" + safe + suffix; } } diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java index 61490b9a..c9feae86 100644 --- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTControllerTest.java @@ -61,7 +61,9 @@ void shouldReturn200ForAdmin() throws Exception { .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_XML)); - verify(applicationInformationService).export(anyList(), any()); + // #247: the feed is built from the request URL (its self link and UUID v5 id) + verify(applicationInformationService).export( + eq("http://localhost/espi/1_1/resource/ApplicationInformation"), anyList(), any()); } @Test diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java new file mode 100644 index 00000000..4e77ec68 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/FeedEndpointsIntegrationTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 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 + * + * https://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; + +import org.greenbuttonalliance.espi.common.domain.common.ServiceCategory; +import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; +import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * #247: feed (collection) endpoints with real data and the REAL export services. The controller + * mock tests stub every exporter, which is how the 500s on every non-empty feed went unnoticed. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@DisplayName("ESPI feed endpoints with real exporters (#247)") +class FeedEndpointsIntegrationTest { + + private static final String BASE = "http://localhost/espi/1_1/resource"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UsagePointRepository usagePointRepository; + + @Test + @DisplayName("GET /UsagePoint with data returns an Atom feed with one entry per usage point") + void usagePointFeedWithData() throws Exception { + UUID id = UUID.fromString("48c2a019-5598-5e16-b0f9-49e4ff27f5fb"); + UsagePointEntity usagePoint = new UsagePointEntity(); + usagePoint.setId(id); + usagePoint.setDescription("Front of house meter"); + usagePoint.setServiceCategory(ServiceCategory.ELECTRICITY); + usagePointRepository.saveAndFlush(usagePoint); + + String xml = mockMvc.perform(get("/espi/1_1/resource/UsagePoint").with(admin())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(xml) + .contains("UsagePoint Feed") + .contains("rel=\"self\" href=\"" + BASE + "/UsagePoint\" type=\"espi-feed/UsagePoint\"") + .contains("urn:uuid:" + id + "", "Front of house meter") + .contains("href=\"" + BASE + "/UsagePoint/" + id + "\" type=\"espi-entry/UsagePoint\"") + .contains("", "href=\"self\""); + } + + @Test + @DisplayName("GET /Authorization with no authorizations returns an empty Atom feed, not an empty body") + void emptyAuthorizationFeed() throws Exception { + String xml = mockMvc.perform(get("/espi/1_1/resource/Authorization").with(admin())) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(xml) + .contains("Authorization Feed", + "rel=\"self\" href=\"" + BASE + "/Authorization\"") + .doesNotContain(" files(Path base, String suffix) throws Exception { + if (!Files.exists(base)) { + return List.of(); + } + try (var paths = Files.walk(base)) { + return paths.filter(p -> p.toString().endsWith(suffix)).toList(); + } + } + + @Test + @DisplayName("#247: an exception writes an .error.txt with the exception chain, then is rethrown") + void capturesExceptionChain(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/UsagePoint"); + request.setQueryString("limit=5"); + MockHttpServletResponse response = new MockHttpServletResponse(); + RuntimeException failure = new RuntimeException("Failed to export DTO", + new IllegalStateException("class java.util.ImmutableCollections$ListN nor any of its super class is known")); + + assertThatThrownBy(() -> new EspiXmlCaptureFilter(true, dir.toString()) + .doFilter(request, response, (req, res) -> { throw failure; })) + .isSameAs(failure); + + List errors = files(dir, ".error.txt"); + assertThat(errors).singleElement() + .satisfies(p -> assertThat(p.getFileName().toString()).isEqualTo("GET_UsagePoint.error.txt")) + .satisfies(p -> assertThat(Files.readString(p)) + .contains("Request: GET /espi/1_1/resource/UsagePoint?limit=5", "unhandled exception", + "java.lang.RuntimeException: Failed to export DTO", + "java.lang.IllegalStateException: class java.util.ImmutableCollections$ListN")); + assertThat(xmlFiles(dir)).isEmpty(); + } + + @Test + @DisplayName("#247: a 4xx/5xx response writes an .error.txt with the status and body, not an .xml") + void captures4xxResponse(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/espi/1_1/resource/Authorization/42"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new EspiXmlCaptureFilter(true, dir.toString()).doFilter(request, response, (req, res) -> { + ((jakarta.servlet.http.HttpServletResponse) res).setStatus(404); + res.setContentType("application/xml"); + res.getOutputStream().write("not found".getBytes()); + }); + + assertThat(xmlFiles(dir)).isEmpty(); + assertThat(files(dir, ".error.txt")).singleElement() + .satisfies(p -> assertThat(Files.readString(p)) + .contains("Status: 404", "not found")); + assertThat(response.getContentAsString()).isEqualTo("not found"); + } + + @Test + @DisplayName("a relative capture dir is anchored at the repository root") + void relativeDirResolvesToRepoRoot() { + Path resolved = EspiXmlCaptureFilter.resolveCaptureDir("dev-xml-capture"); + + assertThat(resolved).isAbsolute().endsWith(Path.of("dev-xml-capture")); + assertThat(resolved.getParent().resolve(".git")).exists(); + } } From 08bf9ae62fca149f54600cb4b6b37588a638b0dd Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 15:07:04 -0500 Subject: [PATCH 2/3] fix(#247): row-level scope for the ESPI admin resources ApplicationInformation and Authorization are readable with either DataCustodian_Admin_Access or ThirdParty_Admin_Access. Now that their feeds return data, the records a caller sees are scoped: - DataCustodian_Admin_Access: every record. - ThirdParty_Admin_Access: only records of its own application, matched on the token's client_id (Authorization via a new AuthorizationRepository.findAllByApplicationClientId query). - A ThirdParty admin token without client_id: nothing (fails closed). - A record of another application: 404, so its existence is not disclosed. The rule lives in one place (AdminCaller) for both controllers. AdminRowScopeTest covers feed and entry for each kind of caller, and fails if the check is bypassed. Co-Authored-By: Claude Opus 5.5 --- .../usage/AuthorizationRepository.java | 6 + .../datacustodian/web/api/AdminCaller.java | 67 ++++++ .../ApplicationInformationRESTController.java | 15 +- .../web/api/AuthorizationController.java | 22 +- .../web/api/AdminRowScopeTest.java | 209 ++++++++++++++++++ 5 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java create mode 100644 openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java index 502bf69d..b3848536 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AuthorizationRepository.java @@ -20,6 +20,8 @@ package org.greenbuttonalliance.espi.common.repositories.usage; import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -41,6 +43,10 @@ public interface AuthorizationRepository extends JpaRepository findAllIdsByApplicationInformationId(@Param("applicationInformationId") UUID applicationInformationId); + /** Authorizations granted to one Third Party application, by its OAuth client_id (row-level admin scope). */ + @Query("SELECT a FROM AuthorizationEntity a WHERE a.applicationInformation.clientId = :clientId") + Page findAllByApplicationClientId(@Param("clientId") String clientId, Pageable pageable); + Optional findByState(String state); /** Back-channel idempotency lookup (#202): the Authorization provisioned for an AS grant. */ diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java new file mode 100644 index 00000000..c0636fc6 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminCaller.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 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 + * + * https://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; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; + +import java.util.Optional; + +/** + * Row-level scope for the ESPI admin resources (ApplicationInformation, Authorization). + * + *

{@code SCOPE_DataCustodian_Admin_Access} is the Data Custodian's own administrator and sees every + * record. {@code SCOPE_ThirdParty_Admin_Access} is a Third Party's client_credentials token: it sees only + * the records of its own application, matched on the token's {@code client_id}. Without that check any + * Third Party admin could read every other Third Party's Authorizations and ApplicationInformation, + * including their {@code client_secret} and {@code registration_access_token}.

+ * + *

Fails closed: a caller that is not a DataCustodian admin and has no {@code client_id} sees nothing.

+ */ +final class AdminCaller { + + static final String DATA_CUSTODIAN_ADMIN = "SCOPE_DataCustodian_Admin_Access"; + + private AdminCaller() { + } + + static boolean isDataCustodianAdmin(Authentication authentication) { + return authentication != null && authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(DATA_CUSTODIAN_ADMIN::equals); + } + + /** The OAuth client the token was issued to, from the introspection response's {@code client_id}. */ + static Optional clientId(Authentication authentication) { + if (authentication != null && authentication.getPrincipal() instanceof OAuth2AuthenticatedPrincipal principal) { + Object clientId = principal.getAttribute("client_id"); + if (clientId != null && !clientId.toString().isBlank()) { + return Optional.of(clientId.toString()); + } + } + return Optional.empty(); + } + + /** Whether the caller may see a record owned by the application with {@code ownerClientId}. */ + static boolean canSee(Authentication authentication, String ownerClientId) { + if (isDataCustodianAdmin(authentication)) { + return true; + } + return ownerClientId != null && clientId(authentication).map(ownerClientId::equals).orElse(false); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java index 7a964ca6..79261951 100755 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/ApplicationInformationRESTController.java @@ -35,6 +35,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -76,8 +77,12 @@ public class ApplicationInformationRESTController { @ApiResponse(responseCode = "403", description = "Forbidden") }) @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_ThirdParty_Admin_Access')") - public ResponseEntity getAllApplicationInformation() { - List entities = applicationInformationService.findAll(); + public ResponseEntity getAllApplicationInformation(Authentication authentication) { + // Row-level scope (AdminCaller): ApplicationInformation carries client_secret and + // registration_access_token, so a Third Party admin must only ever see its own application. + List entities = applicationInformationService.findAll().stream() + .filter(entity -> AdminCaller.canSee(authentication, entity.getClientId())) + .toList(); ByteArrayOutputStream out = new ByteArrayOutputStream(); applicationInformationService.export(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), @@ -107,10 +112,12 @@ public ResponseEntity getAllApplicationInformation() { @PreAuthorize("hasAuthority('SCOPE_DataCustodian_Admin_Access') or hasAuthority('SCOPE_ThirdParty_Admin_Access')") public ResponseEntity getApplicationInformation( @Parameter(description = "Unique identifier of the ApplicationInformation", required = true) - @PathVariable UUID applicationInformationId) { + @PathVariable UUID applicationInformationId, + Authentication authentication) { ApplicationInformationEntity entity = applicationInformationService.findById(applicationInformationId); - if (entity == null) { + // Another application's record is reported as not found, so its existence is not disclosed. + if (entity == null || !AdminCaller.canSee(authentication, entity.getClientId())) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "ApplicationInformation not found"); } diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java index 98a97094..4339b62c 100644 --- a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/web/api/AuthorizationController.java @@ -38,6 +38,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -57,6 +58,8 @@ * Both previously returned {@code null}, i.e. HTTP 200 with an empty body (#247). Token values, * authorization codes and state never appear in the XML: those {@link AuthorizationDto} fields are * {@code @XmlTransient}. + *

Row-level scope ({@link AdminCaller}): a {@code DataCustodian_Admin_Access} caller sees every + * Authorization; a {@code ThirdParty_Admin_Access} caller only those granted to its own application.

*/ @RestController @RequestMapping("/espi/1_1/resource") @@ -88,10 +91,17 @@ public ResponseEntity getAllAuthorizations( @Parameter(description = "Maximum number of results to return", example = "50") @RequestParam(defaultValue = "50") int limit, @Parameter(description = "Offset for pagination", example = "0") - @RequestParam(defaultValue = "0") int offset) { + @RequestParam(defaultValue = "0") int offset, + Authentication authentication) { - List authorizations = - authorizationRepository.findAll(PageRequest.of(offset / limit, limit)).getContent(); + // Row-level scope: a DataCustodian admin sees all Authorizations; a Third Party admin only those + // of its own application (token client_id); anyone else nothing. See AdminCaller. + PageRequest page = PageRequest.of(offset / limit, limit); + List authorizations = AdminCaller.isDataCustodianAdmin(authentication) + ? authorizationRepository.findAll(page).getContent() + : AdminCaller.clientId(authentication) + .map(clientId -> authorizationRepository.findAllByApplicationClientId(clientId, page).getContent()) + .orElse(List.of()); ByteArrayOutputStream out = new ByteArrayOutputStream(); usageExportService.exportFeed(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), @@ -119,9 +129,13 @@ public ResponseEntity getAllAuthorizations( @PreAuthorize("hasAnyAuthority('SCOPE_DataCustodian_Admin_Access', 'SCOPE_ThirdParty_Admin_Access')") public ResponseEntity getAuthorization( @Parameter(description = "Unique identifier of the Authorization", required = true) - @PathVariable UUID authorizationId) { + @PathVariable UUID authorizationId, + Authentication authentication) { + // Another application's Authorization is reported as not found, so its existence is not disclosed. AuthorizationEntity authorization = authorizationRepository.findById(authorizationId) + .filter(a -> AdminCaller.canSee(authentication, + a.getApplicationInformation() != null ? a.getApplicationInformation().getClientId() : null)) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Authorization not found for id: " + authorizationId)); diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java new file mode 100644 index 00000000..7ff95234 --- /dev/null +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/AdminRowScopeTest.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026 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 + * + * https://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; + +import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity; +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.repositories.usage.AuthorizationRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.opaqueToken; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Row-level scope on the ESPI admin resources (AdminCaller): a DataCustodian admin sees every record; + * a ThirdParty admin sees only its own application's records (token client_id); a ThirdParty admin + * token without client_id sees nothing; another application's record is a 404. + */ +@DisplayName("Admin resources: row-level scope by client_id") +class AdminRowScopeTest extends AbstractControllerMockTest { + + @MockitoBean + private AuthorizationRepository authorizationRepository; + + private static final String TP_A = "third_party_a"; + private static final String TP_B = "third_party_b"; + + private static RequestPostProcessor dcAdmin() { + return opaqueToken().authorities(new SimpleGrantedAuthority("SCOPE_DataCustodian_Admin_Access")) + .attributes(a -> a.put("client_id", "data_custodian_admin")); + } + + private static RequestPostProcessor tpAdmin(String clientId) { + return opaqueToken().authorities(new SimpleGrantedAuthority("SCOPE_ThirdParty_Admin_Access")) + .attributes(a -> { + if (clientId != null) { + a.put("client_id", clientId); + } + }); + } + + private static ApplicationInformationEntity application(String clientId) { + ApplicationInformationEntity app = new ApplicationInformationEntity(); + app.setId(UUID.nameUUIDFromBytes(clientId.getBytes())); + app.setClientId(clientId); + return app; + } + + @Nested + @DisplayName("ApplicationInformation (carries client_secret and registration_access_token)") + class ApplicationInformationScope { + + private final ApplicationInformationEntity appA = application(TP_A); + private final ApplicationInformationEntity appB = application(TP_B); + + @SuppressWarnings("unchecked") + private List exported() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(applicationInformationService).export(anyString(), captor.capture(), any()); + return captor.getValue(); + } + + @Test + @DisplayName("feed: a ThirdParty admin gets only its own application") + void thirdPartyAdminFeedIsScoped() throws Exception { + when(applicationInformationService.findAll()).thenReturn(List.of(appA, appB)); + + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation").with(tpAdmin(TP_A))) + .andExpect(status().isOk()); + + assertThat(exported()).containsExactly(appA); + } + + @Test + @DisplayName("feed: a DataCustodian admin gets every application") + void dataCustodianAdminFeedIsUnscoped() throws Exception { + when(applicationInformationService.findAll()).thenReturn(List.of(appA, appB)); + + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation").with(dcAdmin())) + .andExpect(status().isOk()); + + assertThat(exported()).containsExactly(appA, appB); + } + + @Test + @DisplayName("feed: a ThirdParty admin token without client_id gets nothing") + void thirdPartyAdminWithoutClientIdGetsNothing() throws Exception { + when(applicationInformationService.findAll()).thenReturn(List.of(appA, appB)); + + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation").with(tpAdmin(null))) + .andExpect(status().isOk()); + + assertThat(exported()).isEmpty(); + } + + @Test + @DisplayName("entry: another application's record is 404; its own is 200") + void thirdPartyAdminEntryIsScoped() throws Exception { + when(applicationInformationService.findById(appB.getId())).thenReturn(appB); + when(applicationInformationService.findById(appA.getId())).thenReturn(appA); + + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation/" + appB.getId()).with(tpAdmin(TP_A))) + .andExpect(status().isNotFound()); + mockMvc.perform(get("/espi/1_1/resource/ApplicationInformation/" + appA.getId()).with(tpAdmin(TP_A))) + .andExpect(status().isOk()); + + verify(applicationInformationService, never()).export(eq(appB), any()); + } + } + + @Nested + @DisplayName("Authorization") + class AuthorizationScope { + + private AuthorizationEntity authorizationOf(String clientId) { + AuthorizationEntity authorization = new AuthorizationEntity(); + authorization.setId(UUID.randomUUID()); + authorization.setApplicationInformation(application(clientId)); + return authorization; + } + + @Test + @DisplayName("feed: a ThirdParty admin is queried by its own client_id, never findAll") + void thirdPartyAdminFeedQueriesByClientId() throws Exception { + when(authorizationRepository.findAllByApplicationClientId(eq(TP_A), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(authorizationOf(TP_A)))); + + mockMvc.perform(get("/espi/1_1/resource/Authorization").with(tpAdmin(TP_A))) + .andExpect(status().isOk()); + + verify(authorizationRepository).findAllByApplicationClientId(eq(TP_A), any(Pageable.class)); + verify(authorizationRepository, never()).findAll(any(Pageable.class)); + } + + @Test + @DisplayName("feed: a DataCustodian admin sees every Authorization") + void dataCustodianAdminFeedUsesFindAll() throws Exception { + when(authorizationRepository.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(authorizationOf(TP_A), authorizationOf(TP_B)))); + + mockMvc.perform(get("/espi/1_1/resource/Authorization").with(dcAdmin())) + .andExpect(status().isOk()); + + verify(authorizationRepository).findAll(any(Pageable.class)); + } + + @Test + @DisplayName("feed: a ThirdParty admin token without client_id queries nothing") + void thirdPartyAdminWithoutClientIdQueriesNothing() throws Exception { + mockMvc.perform(get("/espi/1_1/resource/Authorization").with(tpAdmin(null))) + .andExpect(status().isOk()); + + verify(authorizationRepository, never()).findAll(any(Pageable.class)); + verify(authorizationRepository, never()).findAllByApplicationClientId(anyString(), any(Pageable.class)); + } + + @Test + @DisplayName("entry: another application's Authorization is 404; its own is 200") + void thirdPartyAdminEntryIsScoped() throws Exception { + AuthorizationEntity foreign = authorizationOf(TP_B); + AuthorizationEntity own = authorizationOf(TP_A); + when(authorizationRepository.findById(foreign.getId())).thenReturn(Optional.of(foreign)); + when(authorizationRepository.findById(own.getId())).thenReturn(Optional.of(own)); + AtomFeedDto oneEntry = new AtomFeedDto(); + oneEntry.setEntries(List.of(new UsageAtomEntryDto())); + when(usageExportService.buildFeed(anyString(), anyString(), any(), any())).thenReturn(oneEntry); + + mockMvc.perform(get("/espi/1_1/resource/Authorization/" + foreign.getId()).with(tpAdmin(TP_A))) + .andExpect(status().isNotFound()); + mockMvc.perform(get("/espi/1_1/resource/Authorization/" + own.getId()).with(tpAdmin(TP_A))) + .andExpect(status().isOk()); + } + } +} From b40b9593e85b995cac2ad859de1296141d0860a9 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Sat, 14 Nov 2026 15:19:39 -0500 Subject: [PATCH 3/3] fix(#247): fix the Third Party JAXB context and CI-only test failures - Third Party: UsagePointRESTRepositoryImpl built a JAXBContext from AtomFeedDto alone. With AtomFeedDto.entries now an @XmlElementRef to the abstract AtomEntryDto, JAXB needs a concrete entry type in the same context ("Invalid @XmlElementRef ... not known to this context"), so the TP application context failed to start. Register UsageAtomEntryDto. That is also what the TP needs to read usage feeds at all (#197: abstract entry type). - EspiXmlCaptureFilterTest.relativeDirResolvesToRepoRoot used AssertJ Path.endsWith, which canonicalises the path and fails when the directory does not exist, as on a fresh CI checkout; use endsWithRaw. - EspiXmlCaptureFilter: explicit containment check before writing a capture file (normalised path must sit directly in the run directory), plus a traversal test. fileName() already stripped path separators; this makes the guarantee explicit for code scanning. Verified locally with no dev-xml-capture directory present: DC 196, TP 56 (2 skipped, pre-existing). Co-Authored-By: Claude Opus 5.5 --- .../api/devtools/EspiXmlCaptureFilter.java | 9 +++++++- .../devtools/EspiXmlCaptureFilterTest.java | 21 ++++++++++++++++++- .../impl/UsagePointRESTRepositoryImpl.java | 5 ++++- 3 files changed, 32 insertions(+), 3 deletions(-) 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 index 13cca9ee..9ebd4131 100644 --- 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 @@ -167,7 +167,14 @@ private void captureFailure(HttpServletRequest request, ContentCachingResponseWr private void write(String fileName, byte[] content, String what) { try { Files.createDirectories(runDir); - Path file = runDir.resolve(fileName); + // The name comes from the request path. fileName() already strips path separators; this + // containment check makes it explicit that nothing can be written outside the run directory. + Path base = runDir.toAbsolutePath().normalize(); + Path file = base.resolve(fileName).normalize(); + if (!file.startsWith(base) || !base.equals(file.getParent())) { + log.warn("[dev] ESPI capture skipped: '{}' resolves outside {}", fileName, base); + return; + } Files.write(file, content); log.info("[dev] {} ({} bytes) -> {}", what, content.length, file.toAbsolutePath()); } catch (IOException e) { diff --git a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilterTest.java b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilterTest.java index 5e4b671e..e0bd9c4a 100644 --- a/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilterTest.java +++ b/openespi-datacustodian/src/test/java/org/greenbuttonalliance/espi/datacustodian/web/api/devtools/EspiXmlCaptureFilterTest.java @@ -167,12 +167,31 @@ void captures4xxResponse(@TempDir Path dir) throws Exception { assertThat(response.getContentAsString()).isEqualTo("not found"); } + @Test + @DisplayName("a path-traversal request path cannot write outside the run directory") + void traversalStaysInsideRunDir(@TempDir Path dir) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", + "/espi/1_1/resource/../../../../outside/evil"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new EspiXmlCaptureFilter(true, dir.resolve("capture").toString()) + .doFilter(request, response, chain("application/atom+xml", XML)); + + try (var paths = Files.walk(dir)) { + assertThat(paths.filter(Files::isRegularFile).toList()) + .allSatisfy(p -> assertThat(p.getParent().getParent()).isEqualTo(dir.resolve("capture"))); + } + assertThat(dir.resolve("outside")).doesNotExist(); + } + @Test @DisplayName("a relative capture dir is anchored at the repository root") void relativeDirResolvesToRepoRoot() { Path resolved = EspiXmlCaptureFilter.resolveCaptureDir("dev-xml-capture"); - assertThat(resolved).isAbsolute().endsWith(Path.of("dev-xml-capture")); + // endsWithRaw: plain endsWith canonicalises the path and fails when the directory does not + // exist yet, as on a fresh CI checkout + assertThat(resolved).isAbsolute().endsWithRaw(Path.of("dev-xml-capture")); assertThat(resolved.getParent().resolve(".git")).exists(); } } diff --git a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java index 17f7294b..e1649351 100755 --- a/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java +++ b/openespi-thirdparty/src/main/java/org/greenbuttonalliance/espi/thirdparty/repository/impl/UsagePointRESTRepositoryImpl.java @@ -33,6 +33,7 @@ import org.springframework.stereotype.Repository; import org.springframework.web.reactive.function.client.WebClient; import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; import org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto; import org.greenbuttonalliance.espi.common.mapper.usage.UsagePointMapper; @@ -61,7 +62,9 @@ public class UsagePointRESTRepositoryImpl implements UsagePointRESTRepository { public UsagePointRESTRepositoryImpl() { try { - this.jaxbContext = JAXBContext.newInstance(AtomFeedDto.class); + // AtomFeedDto.entries is an @XmlElementRef to the abstract AtomEntryDto (#247), so the concrete + // entry type must be in the context; the Third Party reads usage-domain feeds. + this.jaxbContext = JAXBContext.newInstance(AtomFeedDto.class, UsageAtomEntryDto.class); } catch (JAXBException e) { throw new RuntimeException("Failed to initialize JAXB context", e); }