diff options
author | Thomas <> | 2022-10-17 12:52:00 +0200 |
---|---|---|
committer | Thomas <> | 2022-10-17 12:52:00 +0200 |
commit | 2f69fe3154251d4c4e36eca874039b3227d88fcd (patch) | |
tree | c9507e44386f8d513b06e3405afc9efd4e517a6b /modules | |
parent | 6dbb60973826e173bf44988b822c56d84b677c1a (diff) | |
download | National_eIDAS_Gateway-2f69fe3154251d4c4e36eca874039b3227d88fcd.tar.gz National_eIDAS_Gateway-2f69fe3154251d4c4e36eca874039b3227d88fcd.tar.bz2 National_eIDAS_Gateway-2f69fe3154251d4c4e36eca874039b3227d88fcd.zip |
freat(ernp): update ERnP client to distiguish between active ERnP entries and closed ERnP entries that kitt to ZMR entries
Diffstat (limited to 'modules')
6 files changed, 232 insertions, 40 deletions
diff --git a/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java b/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java index 4212aae8..5d3f43e6 100644 --- a/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java +++ b/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/clients/ernp/ErnpRestClient.java @@ -14,12 +14,14 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.PostConstruct; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; import org.apache.http.client.HttpClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; @@ -40,6 +42,7 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import at.asitplus.eidas.specific.modules.auth.eidas.v2.Constants; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ErnpPersonRegisterResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.RegisterResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData.SimpleEidasDataBuilder; @@ -78,7 +81,6 @@ import at.gv.egiz.eaaf.core.impl.http.HttpClientConfiguration; import at.gv.egiz.eaaf.core.impl.http.HttpClientConfiguration.ClientAuthMode; import at.gv.egiz.eaaf.core.impl.http.IHttpClientFactory; import at.gv.egiz.eaaf.core.impl.utils.TransactionIdUtils; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -120,6 +122,9 @@ public class ErnpRestClient implements IErnpClient { // HTTP header-names from ERnP response private static final String ERNP_RESPONSE_HEADER_SERVER_ID = "Server-Request-Id"; + // ERnP person type that indicates mark a person as ZMR entry + private static final String ERNP_RESPONSE_OPERATION_ZMR_FORWARD = "PersonUebernehmen"; + @Autowired IConfiguration basicConfig; @@ -265,7 +270,7 @@ public class ErnpRestClient implements IErnpClient { if (eidasDocumentToAdd.isEmpty() && mdsToUpdate == null) { log.info("Find no eIDAS document or MDS for update during: {}. Nothing todo on ERnP side", PROCESS_KITT_GENERAL); - return new ErnpRegisterResult(Arrays.asList(registerResult)); + return new ErnpRegisterResult(Arrays.asList(new ErnpPersonRegisterResult(registerResult, false))); } else { log.info("Find #{} eIDAS documents for update during: {}", eidasDocumentToAdd.size(), PROCESS_KITT_GENERAL); @@ -386,30 +391,32 @@ public class ErnpRestClient implements IErnpClient { resp.getPerson().size(), processStepFiendlyname); if (forceSinglePersonMatch) { - return new ErnpRegisterResult(processSearchPersonResponseSingleResult( - resp.getPerson(), citizenCountryCode, processStepFiendlyname)); + return processSearchPersonResponseSingleResult( + resp.getPerson(), citizenCountryCode, processStepFiendlyname); } else { - return new ErnpRegisterResult(processSearchPersonResponse( - resp.getPerson(), citizenCountryCode)); + return processSearchPersonResponse( + resp.getPerson(), citizenCountryCode); } } } @Nonnull - private List<RegisterResult> processSearchPersonResponse( + private ErnpRegisterResult processSearchPersonResponse( @Nonnull List<Person> list, @Nonnull String citizenCountryCode) throws EaafAuthenticationException { - return list.stream() + List<ErnpPersonRegisterResult> ernpResult = list.stream() .map(el -> mapErnpResponseToRegisterResult(el, citizenCountryCode)) .filter(Objects::nonNull) .collect(Collectors.toList()); + log.info("Get #{} ERnP results after post-processing", ernpResult.size()); + return new ErnpRegisterResult(ernpResult); } @NonNull - private List<RegisterResult> processSearchPersonResponseSingleResult( + private ErnpRegisterResult processSearchPersonResponseSingleResult( @Nonnull List<Person> persons, @Nonnull String citizenCountryCode, String processStepFiendlyname) throws EaafAuthenticationException { if (persons.size() > 1) { @@ -418,7 +425,8 @@ public class ErnpRestClient implements IErnpClient { "Find more-than-one ERnP entry with search criteria that has to be unique", true); } else { - RegisterResult activeResult = mapErnpResponseToRegisterResult(persons.get(0), citizenCountryCode); + ErnpPersonRegisterResult activeResult = + mapErnpResponseToRegisterResult(persons.get(0), citizenCountryCode); if (activeResult == null) { log.error("ERnP entry, which was selected by matching, looks already closed. " + "Automated operations on closed entries not supported my matching"); @@ -426,40 +434,41 @@ public class ErnpRestClient implements IErnpClient { "ERnP entry, which was selected by matching, is not active any more.", true); } - - return Arrays.asList(activeResult); - + + return new ErnpRegisterResult(Arrays.asList(activeResult)); + } } - - /** * Process a single Person data-set from ERnP. * * @param personEl Person data-set from ERnP * @param citizenCountryCode Country-Code of the citizen - * @return Simplified register result, or <code>null</code> if the person data-set is not active anymore + * @return {@link Pair} of Simplified register result and 'isZMREntry' flag, + * or <code>null</code> if the person data-set is not active anymore * @throws EaafAuthenticationException In case of a validation error */ @Nullable - private RegisterResult mapErnpResponseToRegisterResult(@Nonnull Person person, + private ErnpPersonRegisterResult mapErnpResponseToRegisterResult(@Nonnull Person person, @Nonnull String citizenCountryCode) { if (checkIfPersonIsActive(person)) { // build result - return RegisterResult.builder() - .pseudonym(selectAllEidasDocument(person, citizenCountryCode, - EidasConstants.eIDAS_ATTRURN_PERSONALIDENTIFIER)) - .familyName(person.getPersonendaten().getFamilienname()) - .givenName(person.getPersonendaten().getVorname()) - .dateOfBirth(getTextualBirthday(person.getPersonendaten().getGeburtsdatum())) - .bpk(person.getPersonendaten().getBpkZp()) - .placeOfBirth(selectSingleEidasDocument(person, citizenCountryCode, - EidasConstants.eIDAS_ATTRURN_PLACEOFBIRTH)) - .birthName(selectSingleEidasDocument(person, citizenCountryCode, - EidasConstants.eIDAS_ATTRURN_BIRTHNAME)) - .build(); + return new ErnpPersonRegisterResult( + RegisterResult.builder() + .pseudonym(selectAllEidasDocument(person, citizenCountryCode, + EidasConstants.eIDAS_ATTRURN_PERSONALIDENTIFIER)) + .familyName(person.getPersonendaten().getFamilienname()) + .givenName(person.getPersonendaten().getVorname()) + .dateOfBirth(getTextualBirthday(person.getPersonendaten().getGeburtsdatum())) + .bpk(person.getPersonendaten().getBpkZp()) + .placeOfBirth(selectSingleEidasDocument(person, citizenCountryCode, + EidasConstants.eIDAS_ATTRURN_PLACEOFBIRTH)) + .birthName(selectSingleEidasDocument(person, citizenCountryCode, + EidasConstants.eIDAS_ATTRURN_BIRTHNAME)) + .build(), + isPersonMovedToZmr(person)); } else { log.debug("Entity is not valid anymore. Skip it ... "); @@ -473,7 +482,12 @@ public class ErnpRestClient implements IErnpClient { if (person.getGueltigBis() != null) { LocalDateTime validTo = person.getGueltigBis().toLocalDateTime(); LocalDateTime now = LocalDateTime.now(); - if (validTo.isBefore(now)) { + + if (isPersonMovedToZmr(person)) { + log.debug("Entity has a 'validTo' element, but it's marked as {}. Use it as a ZMR entry", + ERNP_RESPONSE_OPERATION_ZMR_FORWARD); + + } else if (validTo.isBefore(now)) { log.warn("Enity was valid to: {}, but now its: {}. Ignore that entry", validTo, now); return false; @@ -491,6 +505,20 @@ public class ErnpRestClient implements IErnpClient { } + /** + * Check if ERnP person is marked as KITT to ZMR entry. + * + * <p>If person is marked as ZMR person then it has the same quality as a ZMR match.</p> + * + * @param person ERnP person result + * @return <code>true</code> if the person should be in ERnP, otherwise <code>false</code> + */ + private boolean isPersonMovedToZmr(Person person) { + return person.getLetzteOperation() != null + && ERNP_RESPONSE_OPERATION_ZMR_FORWARD.equals(person.getLetzteOperation().getVorgang()); + + } + private Suchdaten mapCountrySpecificSearchData(PersonSuchenRequest personSearchDao) { final Suchdaten searchInfos = new Suchdaten(); searchInfos.setFamilienname(personSearchDao.getNatuerlichePerson().getPersonenName().getFamilienname()); @@ -541,8 +569,8 @@ public class ErnpRestClient implements IErnpClient { generic.getClientRequestTime(), generic.getClientRequestId(), ernpReq); log.trace("Receive response from ERnP for '{}' operation", PROCESS_KITT_IDENITIES_UPDATE); - return new ErnpRegisterResult(Arrays.asList( - mapErnpResponseToRegisterResult(ernpResp.getPerson(), citizenCountryCode))); + return new ErnpRegisterResult(Collections.singletonList( + mapErnpResponseToRegisterResult(ernpResp.getPerson(), citizenCountryCode))); } @@ -896,11 +924,64 @@ public class ErnpRestClient implements IErnpClient { return config; } - @AllArgsConstructor @Getter public static class ErnpRegisterResult { - private final List<RegisterResult> personResult; + private List<ErnpPersonRegisterResult> fullErnpResults; + + /** + * Build reduced ERnP register result. + * @param list {@link List} of ERnP entities + */ + public ErnpRegisterResult(List<ErnpPersonRegisterResult> list) { + fullErnpResults = list; + + } + + /** + * Get all active ERnP results. + * + * @return ERnP entities + */ + public Stream<ErnpPersonRegisterResult> getPersonResultStream() { + return fullErnpResults.stream() + .filter(el -> !el.isZmrEntryNow()); + + } + + /** + * Get all active ERnP results. + * + * @return ERnP entities + */ + public List<RegisterResult> getPersonResult() { + return getPersonResultStream() + .collect(Collectors.toList()); + + } + + /** + * Get all ERnP results that are kitted to ZMR entries. + * + * @return entities that are in ZMR now + */ + public Stream<ErnpPersonRegisterResult> getZmrPersonResultStream() { + return fullErnpResults.stream() + .filter(el -> el.isZmrEntryNow()); + + } + + /** + * Get all ERnP results that are kitted to ZMR entries. + * + * @return entities that are in ZMR now + */ + public List<RegisterResult> getZmrPersonResult() { + return getZmrPersonResultStream() + .collect(Collectors.toList()); + + } + } private GenericRequestParams buildGenericRequestParameters() { diff --git a/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/dao/ErnpPersonRegisterResult.java b/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/dao/ErnpPersonRegisterResult.java new file mode 100644 index 00000000..1b9454db --- /dev/null +++ b/modules/authmodule-eIDAS-v2/src/main/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/dao/ErnpPersonRegisterResult.java @@ -0,0 +1,30 @@ +package at.asitplus.eidas.specific.modules.auth.eidas.v2.dao; + +import lombok.Getter; + +/** + * ERnP specific extension of a register result. + * + * @author tlenz + * + */ +@Getter +public class ErnpPersonRegisterResult extends RegisterResult { + + private static final long serialVersionUID = -1250543763613825226L; + + /** + * <code>true</code> in case of person is still found in ERnP, but is in ZMR now. + */ + private final boolean zmrEntryNow; + + public ErnpPersonRegisterResult(RegisterResult result, boolean isZmrEntryNow) { + super(result.getPseudonym(), result.getGivenName(), result.getFamilyName(), result.getDateOfBirth(), + result.getPlaceOfBirth(), result.getBirthName(), result.getTaxNumber(), result.getAddress(), + result.getBpk()); + + zmrEntryNow = isZmrEntryNow; + + } + +} diff --git a/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/clients/ErnpRestClientTest.java b/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/clients/ErnpRestClientTest.java index 93d8ab74..82d89e3e 100644 --- a/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/clients/ErnpRestClientTest.java +++ b/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/clients/ErnpRestClientTest.java @@ -163,6 +163,7 @@ public class ErnpRestClientTest { ErnpRegisterResult resp = client.searchWithResidenceData(null, null, null, null, null, null); assertNotNull("no ERnP response", resp); assertEquals("wrong resp size", 0, resp.getPersonResult().size()); + assertEquals("wrong resp size", 0, resp.getZmrPersonResult().size()); } @@ -290,6 +291,7 @@ public class ErnpRestClientTest { mockWebServer.takeRequest(); assertNotNull("no ERnP response", resp); assertEquals("wrong resp size", 2, resp.getPersonResult().size()); + assertEquals("wrong resp size", 0, resp.getZmrPersonResult().size()); } @@ -316,9 +318,7 @@ public class ErnpRestClientTest { assertEquals("wrong resp size", 2, resp.getPersonResult().size()); } - - - + @Test @SneakyThrows public void searchWithPersonalIdNoResponse() { @@ -432,7 +432,33 @@ public class ErnpRestClientTest { mockWebServer.takeRequest(); assertNotNull("no ERnP response", resp); assertEquals("wrong resp size", 1, resp.getPersonResult().size()); + assertEquals("wrong resp size", 0, resp.getZmrPersonResult().size()); + + } + + @Test + @SneakyThrows + public void searchWithPersonalIdZmrKitt() { + final String cc = "DE"; + final SimpleEidasData eidasDataFirst = generateRandomEidasData(cc); + // set ERnP response + mockWebServer.enqueue(new MockResponse().setResponseCode(200) + .setBody(IOUtils.toString( + ErnpRestClientTest.class.getResourceAsStream( + "/data/ernp/1_search_with_personalId_zmr_kitt_resp.json"), + "UTF-8")) + .setHeader("Content-Type", "application/json;charset=utf-8")); + + // execute operation + ErnpRegisterResult resp = client.searchWithPersonIdentifier(eidasDataFirst.getPseudonym(), cc); + + // validate state + mockWebServer.takeRequest(); + assertNotNull("no ERnP response", resp); + assertEquals("wrong resp size", 0, resp.getPersonResult().size()); + assertEquals("wrong resp size", 1, resp.getZmrPersonResult().size()); + } @Test @@ -455,6 +481,7 @@ public class ErnpRestClientTest { mockWebServer.takeRequest(); assertNotNull("no ERnP response", resp); assertEquals("wrong resp size", 1, resp.getPersonResult().size()); + assertEquals("wrong resp size", 0, resp.getZmrPersonResult().size()); RegisterResult persInfo = resp.getPersonResult().get(0); assertEquals("wrong familyname", "CtKKrtUe", persInfo.getFamilyName()); assertEquals("wrong givenName", "dUeYzUFg", persInfo.getGivenName()); @@ -619,6 +646,7 @@ public class ErnpRestClientTest { mockWebServer.takeRequest(); assertNotNull("no ERnP response", resp); assertEquals("wrong resp size", 1, resp.getPersonResult().size()); + assertEquals("wrong resp size", 0, resp.getZmrPersonResult().size()); RegisterResult persInfo = resp.getPersonResult().get(0); assertEquals("wrong familyname", "CtKKrtUe", persInfo.getFamilyName()); assertEquals("wrong givenName", "dUeYzUFg", persInfo.getGivenName()); diff --git a/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/CreateNewErnpEntryTaskTest.java b/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/CreateNewErnpEntryTaskTest.java index 985a5e14..6298e250 100644 --- a/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/CreateNewErnpEntryTaskTest.java +++ b/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/CreateNewErnpEntryTaskTest.java @@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.any; import java.net.URISyntaxException; import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import org.apache.commons.lang3.RandomStringUtils; import org.jetbrains.annotations.NotNull; @@ -28,6 +29,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.ErnpRestClient; import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.ErnpRestClient.ErnpRegisterResult; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ErnpPersonRegisterResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.MatchedPersonResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.RegisterResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData; @@ -150,7 +152,9 @@ public class CreateNewErnpEntryTaskTest { @NotNull private ErnpRegisterResult ernpRegisterResult(List<RegisterResult> registerResult) { - return new ErnpRegisterResult(registerResult); + return new ErnpRegisterResult(registerResult.stream() + .map(el -> new ErnpPersonRegisterResult(el, false)) + .collect(Collectors.toList())); } diff --git a/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/InitialSearchTaskTest.java b/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/InitialSearchTaskTest.java index c9b7b1ac..4cfba521 100644 --- a/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/InitialSearchTaskTest.java +++ b/modules/authmodule-eIDAS-v2/src/test/java/at/asitplus/eidas/specific/modules/auth/eidas/v2/test/tasks/InitialSearchTaskTest.java @@ -41,6 +41,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.stream.Collectors; import javax.xml.namespace.QName; @@ -68,6 +69,7 @@ import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.ernp.IErnpClient import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.zmr.IZmrClient; import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.zmr.ZmrSoapClient; import at.asitplus.eidas.specific.modules.auth.eidas.v2.clients.zmr.ZmrSoapClient.ZmrRegisterResult; +import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.ErnpPersonRegisterResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.MatchedPersonResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.RegisterResult; import at.asitplus.eidas.specific.modules.auth.eidas.v2.dao.SimpleEidasData; @@ -809,12 +811,15 @@ public class InitialSearchTaskTest { @NotNull private ErnpRegisterResult ernpRegisterResult(RegisterResult registerResult) { - return new ErnpRegisterResult(Collections.singletonList(registerResult)); + return new ErnpRegisterResult(Collections.singletonList( + new ErnpPersonRegisterResult(registerResult, false))); } @NotNull private ErnpRegisterResult ernpRegisterResult(List<RegisterResult> registerResult) { - return new ErnpRegisterResult(registerResult); + return new ErnpRegisterResult(registerResult.stream() + .map(el -> new ErnpPersonRegisterResult(el, false)) + .collect(Collectors.toList())); } @NotNull diff --git a/modules/authmodule-eIDAS-v2/src/test/resources/data/ernp/1_search_with_personalId_zmr_kitt_resp.json b/modules/authmodule-eIDAS-v2/src/test/resources/data/ernp/1_search_with_personalId_zmr_kitt_resp.json new file mode 100644 index 00000000..5612e3d0 --- /dev/null +++ b/modules/authmodule-eIDAS-v2/src/test/resources/data/ernp/1_search_with_personalId_zmr_kitt_resp.json @@ -0,0 +1,44 @@ +{ + "person": [ + { + "type": "Person", + "eidas": [ + { + "ablaufDatum": "9999-12-31T00:00:00.000+01:00", + "art": "http://eidas.europa.eu/attributes/naturalperson/PersonIdentifier", + "ausstellDatum": "9999-12-31T00:00:00.000+01:00", + "entityId": "47769100000077607", + "gueltigAb": "2022-10-06T08:01:18.117+02:00", + "gueltigBis": "2022-10-06T08:01:18.117+02:00", + "staatscode2": "XZ", + "wert": "eidasmatcherclosed19740404_01" + } + ], + "entityId": "47769100000077596", + "gueltigAb": "2022-10-06T08:01:18.117+02:00", + "gueltigBis": "2045-10-06T08:01:18.117+02:00", + "letzteOperation": { + "begruendung": "EIDAS Integrationstest", + "grund": "Person amtlich beenden", + "vorgang": "PersonUebernehmen", + "zeitpunkt": "2022-10-06T08:01:18.117+02:00" + }, + "personendaten": { + "basiszahl": "000862899079", + "bpkZp": "mhnWeYYC8KfRY/MaYKdUDkzwD2w=", + "entityId": "47769100000077596", + "familienname": "EidasMatcher", + "geburtsdatum": { + "jahr": 1974, + "monat": 4, + "tag": 4 + }, + "geprueft": false, + "gueltigAb": "2022-10-06T08:01:18.117+02:00", + "gueltigBis": "2022-10-06T08:01:18.117+02:00", + "vorname": "Closed" + }, + "version": "2022-10-06T08:01:18.117+02:00" + } + ] +}
\ No newline at end of file |