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/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-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/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 8bb73d6e..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,8 +35,10 @@ 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; @@ -75,11 +77,16 @@ 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(entities, out); + applicationInformationService.export(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString(), + entities, out); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_XML) .body(out.toByteArray()); @@ -105,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 c5444b08..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 @@ -26,52 +26,59 @@ 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}. + *

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.

*/ - -//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 +87,32 @@ 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(); - return null; - // return ResponseEntity.ok(authorizations); + // 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(), + "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 +127,29 @@ 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) { - return null; -// return authorizationRepository.findById(authorizationId) -// .map(authorizationMapper::toDto) -// .map(authorization -> ResponseEntity.ok(authorization)) -// .orElse(ResponseEntity.notFound().build()); + // 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)); + + 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(); + + 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..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 @@ -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,61 @@ 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()); + // 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) { // 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 +192,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/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()); + } + } +} 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 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"); + + // 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); }