/* * Copyright 2017 - 2020 Acosix GmbH * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package de.acosix.alfresco.simplecontentstores.repo.integration; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.List; import javax.ws.rs.NotFoundException; import javax.ws.rs.client.ClientRequestFilter; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import org.alfresco.service.cmr.site.SiteService; import org.apache.commons.codec.binary.Base64; import org.bouncycastle.util.Arrays; import org.jboss.resteasy.client.jaxrs.ResteasyClient; import org.jboss.resteasy.client.jaxrs.ResteasyWebTarget; import org.jboss.resteasy.client.jaxrs.internal.LocalResteasyProviderFactory; import org.jboss.resteasy.client.jaxrs.internal.ResteasyClientBuilderImpl; import org.jboss.resteasy.core.providerfactory.ResteasyProviderFactoryImpl; import org.jboss.resteasy.plugins.providers.RegisterBuiltin; import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider; import org.junit.Rule; import org.junit.rules.ExpectedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import de.acosix.alfresco.rest.client.api.AuthenticationV1; import de.acosix.alfresco.rest.client.api.NodesV1; import de.acosix.alfresco.rest.client.api.SitesV1; import de.acosix.alfresco.rest.client.jackson.RestAPIBeanDeserializerModifier; import de.acosix.alfresco.rest.client.model.authentication.TicketEntity; import de.acosix.alfresco.rest.client.model.authentication.TicketRequest; import de.acosix.alfresco.rest.client.model.sites.SiteContainerResponseEntity; import de.acosix.alfresco.rest.client.model.sites.SiteCreationRequestEntity; import de.acosix.alfresco.rest.client.model.sites.SiteResponseEntity; import de.acosix.alfresco.rest.client.model.sites.SiteVisibility; import de.acosix.alfresco.rest.client.resteasy.MultiValuedParamConverterProvider; /** * Base class for all content store tests making use of the dockerised deployment of Alfresco and accessing that deployment via the Alfresco * Public Rest API. * * @author Axel Faust */ public abstract class AbstractStoresTest { private static final Logger LOGGER = LoggerFactory.getLogger(AbstractStoresTest.class); protected static final String baseUrl = "http://localhost:8082/alfresco"; @Rule public ExpectedException expectedException = ExpectedException.none(); /** * Configures and constructs a Resteasy client to use for calling the Alfresco Public ReST API in the dockerised deployment. * * @return the configured client */ protected static ResteasyClient setupResteasyClient() { final SimpleModule module = new SimpleModule(); module.setDeserializerModifier(new RestAPIBeanDeserializerModifier()); final ResteasyJackson2Provider resteasyJacksonProvider = new ResteasyJackson2Provider(); final ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(Include.NON_EMPTY); mapper.registerModule(module); resteasyJacksonProvider.setMapper(mapper); final LocalResteasyProviderFactory resteasyProviderFactory = new LocalResteasyProviderFactory(new ResteasyProviderFactoryImpl()); resteasyProviderFactory.register(resteasyJacksonProvider); // will cause a warning regarding Jackson provider which is already registered RegisterBuiltin.register(resteasyProviderFactory); resteasyProviderFactory.register(new MultiValuedParamConverterProvider()); final ResteasyClient client = new ResteasyClientBuilderImpl().providerFactory(resteasyProviderFactory).build(); return client; } /** * Obtains an authentication ticket from an Alfresco system via the Public ReST API. * * @param client * the client to use for making the ReST API call * @param baseUrl * the base URL of the Alfresco instance * @param user * the user for which to obtain the ticket * @param password * the password of the user * @return the issued authentication ticket */ protected static String obtainTicket(final ResteasyClient client, final String baseUrl, final String user, final String password) { final ResteasyWebTarget targetServer = client.target(UriBuilder.fromPath(baseUrl)); final AuthenticationV1 authentication = targetServer.proxy(AuthenticationV1.class); final TicketRequest rq = new TicketRequest(); rq.setUserId(user); rq.setPassword(password); final TicketEntity ticket = authentication.createTicket(rq); return ticket.getId(); } /** * Initialised a simple Java facade for calls to a particular Alfresco Public ReST API interface. * * @param client * the client to use for making ReST API calls * @param baseUrl * the base URL of the Alfresco instance * @param api * the API interface to facade * @param ticket * the authentication ticket to use for calls to the API * @return the Java facade of the API */ protected static <T> T createAPI(final ResteasyClient client, final String baseUrl, final Class<T> api, final String ticket) { final ResteasyWebTarget targetServer = client.target(UriBuilder.fromPath(baseUrl)); final ClientRequestFilter rqAuthFilter = (requestContext) -> { final String base64Token = Base64.encodeBase64String(ticket.getBytes(StandardCharsets.UTF_8)); requestContext.getHeaders().add("Authorization", "Basic " + base64Token); }; targetServer.register(rqAuthFilter); return targetServer.proxy(api); } /** * Retrieves the node ID of the document library of a particular site, creating the site, if it does not exist. * * @param client * the client to use for making ReST API calls * @param baseUrl * the base URL of the Alfresco instance * @param ticket * the authentication ticket to use for calls to the ResT APIs * @param siteId * the ID of the site to retrieve / create * @param siteTitle * the title of the site to use if this operation cannot find an existing site and creates one lazily * @return the node ID of the document library */ protected static String getOrCreateSiteAndDocumentLibrary(final ResteasyClient client, final String baseUrl, final String ticket, final String siteId, final String siteTitle) { final SitesV1 sites = createAPI(client, baseUrl, SitesV1.class, ticket); SiteResponseEntity site = null; try { site = sites.getSite(siteId); } catch (final NotFoundException ignore) { // getOrCreate implies that site might not exist (yet) } if (site == null) { final SiteCreationRequestEntity siteToCreate = new SiteCreationRequestEntity(); siteToCreate.setId(siteId); siteToCreate.setTitle(siteTitle); siteToCreate.setVisibility(SiteVisibility.PUBLIC); site = sites.createSite(siteToCreate, true, true, null); } final SiteContainerResponseEntity documentLibrary = sites.getSiteContainer(site.getId(), SiteService.DOCUMENT_LIBRARY); return documentLibrary.getId(); } /** * Looks up the most recently modified file in a particular path of the Docker-mounted {@code alf_data} folder. * * @param subPath * the relative path within {@code alf_data} to use as the context for the lookup * @param exclusions * the list of paths to exclude from consideration of most recently modified file * @return the path of the most recently modified file, according to file system attributes * @throws IOException * if an error occurs walking the file tree of the specified path */ protected static Path findLastModifiedFileInAlfData(final String subPath, final Collection<Path> exclusions) throws IOException { final LastModifiedFileFinder lastModifiedFileFinder = new LastModifiedFileFinder(exclusions); final Path alfData = Paths.get("target", "docker", "alf_data"); final Path startingPoint = subPath != null && !subPath.isEmpty() ? alfData.resolve(subPath) : alfData; final Path lastModifiedFile; if (Files.exists(startingPoint)) { Files.walkFileTree(startingPoint, lastModifiedFileFinder); lastModifiedFile = lastModifiedFileFinder.getLastModifiedFile(); LOGGER.debug("Last modified file in alf_data/{} is {}", subPath, lastModifiedFile); } else { lastModifiedFile = null; } return lastModifiedFile; } /** * Lists the files in a particular path of the Docker-mounted {@code alf_data} folder. * * @param subPath * the relative path within {@code alf_data} to use as the context for the lookup * @return the list of paths for existing files in the specified path * @throws IOException * if an error occurs walking the file tree of the specified path */ protected static Collection<Path> listFilesInAlfData(final String subPath) throws IOException { final FileCollectingFinder collectingFinder = new FileCollectingFinder(); final Path alfData = Paths.get("target", "docker", "alf_data"); final Path startingPoint = subPath != null && !subPath.isEmpty() ? alfData.resolve(subPath) : alfData; final List<Path> files; if (Files.exists(startingPoint)) { Files.walkFileTree(startingPoint, collectingFinder); files = collectingFinder.getCollectedFiles(); } else { files = new ArrayList<>(); } return files; } /** * Checks whether the content in a file matches the content as specified by a provided array of bytes. * * @param contentBytes * the expected content * @param file * the file to check * @return {@code true} if the file matches the expected content, {@code false} otherwise */ protected static boolean contentMatches(final byte[] contentBytes, final Path file) throws IOException { boolean matches; try (InputStream is = Files.newInputStream(file)) { matches = contentMatches(contentBytes, is); } return matches; } /** * Checks whether the content in two files matches. * * @param fileA * the first of the two files * @param fileB * the second of the two files * @return {@code true} if the files match, {@code false} otherwise */ protected static boolean contentMatches(final Path fileA, final Path fileB) throws IOException { boolean matches; try (InputStream isA = Files.newInputStream(fileA)) { try (InputStream isB = Files.newInputStream(fileB)) { final byte[] buffA = new byte[4096]; final byte[] buffB = new byte[4096]; int offset = 0; matches = true; while (matches) { final int bytesReadA = isA.read(buffA); final int bytesReadB = isB.read(buffB); if (bytesReadA != bytesReadB) { matches = false; if (!matches) { LOGGER.debug( "contentMatches failed due to difference in length - read {} expected vs {} bytes in slice starting at position {}", bytesReadA, bytesReadB, offset); } } else if (bytesReadA != -1) { // note: don't have to care about equals check including bytes between bytesRead and length // (any left over bytes from previous read would be identical in both buffers) matches = Arrays.areEqual(buffA, buffB); if (!matches) { LOGGER.debug( "contentMatches failed due to difference in content slice starting at position {} and with a length of {}", offset, bytesReadA); } offset += bytesReadA; } else { break; } } } } return matches; } /** * Checks whether the content in a response (e.g. from the {@link NodesV1#getContent(String) getContent ReST API operation}) matches the * content as specified by a provided array of bytes. * * @param contentBytes * the expected content * @param response * the response to check * @return {@code true} if the response matches the expected content, {@code false} otherwise */ protected static boolean contentMatches(final byte[] contentBytes, final Response response) throws IOException { boolean matches = false; final Object entity = response.getEntity(); if (entity instanceof InputStream) { try (InputStream is = (InputStream) entity) { matches = contentMatches(contentBytes, is); } } return matches; } /** * Checks whether the content in a stream matches the content as specified by a provided array of bytes. * * @param contentBytes * the expected content * @param is * the input stream for accessing the content to check * @return {@code true} if the stream matches the expected content, {@code false} otherwise */ protected static boolean contentMatches(final byte[] contentBytes, final InputStream is) throws IOException { boolean matches = true; int offset = 0; final byte[] buff = new byte[8192]; while (matches) { final int bytesRead = is.read(buff); if (bytesRead == -1) { matches = offset == contentBytes.length; if (!matches) { LOGGER.debug("contentMatches failed due to difference in length - read {} bytes and expected {}", offset, contentBytes.length); } break; } else { if (bytesRead > (contentBytes.length - offset)) { matches = false; LOGGER.debug("contentMatches failed due to difference in length - read {} bytes and expected {}", offset + bytesRead, contentBytes.length); } for (int i = 0; i < bytesRead && matches; i++) { matches = buff[i] == contentBytes[offset + i]; if (!matches) { LOGGER.debug("contentMatches failed due to difference in content at position {}: expected byte {} and found {}", offset + i, contentBytes[offset + i], buff[i]); } } offset += bytesRead; } } return matches; } }