diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java index 1df743ed..a88b3810 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStore.java @@ -57,7 +57,7 @@ public CouchdbAuthStore( this.logger = logFactory.getLog(getClass()); this.timeService = timeService; - validator.checkCouchdbDatabaseIsValid(this.storeUri, this.httpClient, this.httpRequestFactory); + validator.checkCouchdbDatabaseIsValid(this.storeUri, this.httpClient, this.httpRequestFactory, timeService); } @Override @@ -83,6 +83,28 @@ public List getTokens() throws AuthStoreException { return tokens; } + public List getTokensByLoginId(String loginId) throws AuthStoreException { + logger.info("Retrieving tokens from CouchDB"); + List tokenDocuments = new ArrayList<>(); + List tokens = new ArrayList<>(); + + try { + // Get all of the documents in the tokens database + tokenDocuments = getAllDocsByLoginId(TOKENS_DATABASE_NAME, loginId); + + // Build up a list of all the tokens using the document IDs + for (ViewRow row : tokenDocuments) { + tokens.add(getAuthTokenFromDocument(row.id)); + } + + logger.info("Tokens retrieved from CouchDB OK"); + } catch (CouchdbException e) { + String errorMessage = ERROR_FAILED_TO_RETRIEVE_TOKENS.getMessage(e.getMessage()); + throw new AuthStoreException(errorMessage, e); + } + return tokens; + } + @Override public void shutdown() throws AuthStoreException { try { diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java index 2a2214d0..3c3e8d41 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/CouchdbAuthStoreValidator.java @@ -8,24 +8,205 @@ import java.net.URI; import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; +import org.apache.http.HttpEntity; +import org.apache.http.HttpStatus; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; + +import com.google.gson.JsonSyntaxException; + + +import dev.galasa.extensions.common.api.LogFactory; import dev.galasa.extensions.common.couchdb.CouchdbBaseValidator; +import dev.galasa.extensions.common.couchdb.CouchdbClashingUpdateException; import dev.galasa.extensions.common.couchdb.CouchdbException; +import dev.galasa.extensions.common.couchdb.RetryableCouchdbUpdateOperationProcessor; +import dev.galasa.auth.couchdb.internal.beans.*; import dev.galasa.extensions.common.api.HttpRequestFactory; +import dev.galasa.framework.spi.utils.GalasaGson; +import dev.galasa.framework.spi.utils.ITimeService; + +import static dev.galasa.auth.couchdb.internal.Errors.*; public class CouchdbAuthStoreValidator extends CouchdbBaseValidator { - private final Log logger = LogFactory.getLog(getClass()); + private final Log logger ; + private final GalasaGson gson = new GalasaGson(); + private final LogFactory logFactory; + + // A couchDB view, it gets all the access tokens of a the user based on the loginId provided. + public static final String DB_TABLE_TOKENS_DESIGN = "function (doc) { if (doc.owner && doc.owner.loginId) {emit(doc.owner.loginId, doc); } }"; + + public CouchdbAuthStoreValidator() { + this(new LogFactory(){ + @Override + public Log getLog(Class clazz) { + return org.apache.commons.logging.LogFactory.getLog(clazz); + } + }); + } + + public CouchdbAuthStoreValidator(LogFactory logFactory) { + this.logFactory = logFactory; + this.logger = logFactory.getLog(getClass()); + } @Override - public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory httpRequestFactory) throws CouchdbException { + public void checkCouchdbDatabaseIsValid( + URI couchdbUri, + CloseableHttpClient httpClient, + HttpRequestFactory httpRequestFactory, + ITimeService timeService + ) throws CouchdbException { + // Perform the base CouchDB checks - super.checkCouchdbDatabaseIsValid(couchdbUri, httpClient, httpRequestFactory); + super.checkCouchdbDatabaseIsValid(couchdbUri, httpClient, httpRequestFactory, timeService); + + RetryableCouchdbUpdateOperationProcessor retryProcessor = new RetryableCouchdbUpdateOperationProcessor(timeService, this.logFactory); + + retryProcessor.retryCouchDbUpdateOperation( + ()->{ tryToCheckAndUpdateCouchDBTokenView(couchdbUri, httpClient, httpRequestFactory); + }); + + logger.debug("Auth Store CouchDB at " + couchdbUri.toString() + " validated"); + } + private void tryToCheckAndUpdateCouchDBTokenView(URI couchdbUri, CloseableHttpClient httpClient, + HttpRequestFactory httpRequestFactory) throws CouchdbException { + validateDatabasePresent(couchdbUri, CouchdbAuthStore.TOKENS_DATABASE_NAME); + checkTokensDesignDocument(httpClient, couchdbUri, 1); + } - logger.debug("Auth Store CouchDB at " + couchdbUri.toString() + " validated"); + public void checkTokensDesignDocument(CloseableHttpClient httpClient, URI couchdbUri, int attempts) + throws CouchdbException { + + // Get the design document from couchdb + String docJson = getTokenDesignDocument(httpClient, couchdbUri, attempts); + + TokensDBNameViewDesign tableDesign = parseTokenDesignFromJson(docJson); + + boolean isDesignUpdated = updateDesignDocToDesiredDesignDoc(tableDesign); + + if (isDesignUpdated) { + updateTokenDesignDocument(httpClient, couchdbUri, attempts, tableDesign); + } + } + + private TokensDBNameViewDesign parseTokenDesignFromJson(String docJson) throws CouchdbException { + TokensDBNameViewDesign tableDesign; + try { + tableDesign = gson.fromJson(docJson, TokensDBNameViewDesign.class); + } catch (JsonSyntaxException ex) { + throw new CouchdbException(ERROR_FAILED_TO_PARSE_COUCHDB_DESIGN_DOC.getMessage(ex.getMessage()), ex); + } + + if (tableDesign == null) { + tableDesign = new TokensDBNameViewDesign(); + } + return tableDesign; + } + + private boolean updateDesignDocToDesiredDesignDoc(TokensDBNameViewDesign tableDesign) { + boolean isUpdated = false; + + if (tableDesign.views == null) { + isUpdated = true; + tableDesign.views = new TokenDBViews(); + } + + if (tableDesign.views.loginIdView == null) { + isUpdated = true; + tableDesign.views.loginIdView = new TokenDBLoginView(); + } + + if (tableDesign.views.loginIdView.map == null + || !DB_TABLE_TOKENS_DESIGN.equals(tableDesign.views.loginIdView.map)) { + isUpdated = true; + tableDesign.views.loginIdView.map = DB_TABLE_TOKENS_DESIGN; + } + + if (tableDesign.language == null || !tableDesign.language.equals("javascript")) { + isUpdated = true; + tableDesign.language = "javascript"; + } + + return isUpdated; + } + + private String getTokenDesignDocument(CloseableHttpClient httpClient, URI couchdbUri, int attempts) + throws CouchdbException { + HttpRequestFactory requestFactory = super.getRequestFactory(); + HttpGet httpGet = requestFactory.getHttpGetRequest(couchdbUri + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME +"/_design/docs"); + + String docJson = null; + try (CloseableHttpResponse response = httpClient.execute(httpGet)) { + + StatusLine statusLine = response.getStatusLine(); + + docJson = EntityUtils.toString(response.getEntity()); + if (statusLine.getStatusCode() != HttpStatus.SC_OK + && statusLine.getStatusCode() != HttpStatus.SC_NOT_FOUND) { + throw new CouchdbException( + "Validation failed of database galasa_tokens design document - " + statusLine.toString()); + } + if (statusLine.getStatusCode() == HttpStatus.SC_NOT_FOUND) { + docJson = "{}"; + } + + return docJson; + + } catch (CouchdbException e) { + throw e; + } catch (Exception e) { + throw new CouchdbException("Validation failed", e); + } + } + + private void updateTokenDesignDocument(CloseableHttpClient httpClient, URI couchdbUri, int attempts, + TokensDBNameViewDesign tokenViewDesign) throws CouchdbException { + HttpRequestFactory requestFactory = super.getRequestFactory(); + + logger.info("Updating the galasa_tokens design document"); + + HttpEntity entity = new StringEntity(gson.toJson(tokenViewDesign), ContentType.APPLICATION_JSON); + + HttpPut httpPut = requestFactory.getHttpPutRequest(couchdbUri + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME +"/_design/docs"); + httpPut.setEntity(entity); + + if (tokenViewDesign._rev != null) { + httpPut.addHeader("ETaq", "\"" + tokenViewDesign._rev + "\""); + } + + try (CloseableHttpResponse response = httpClient.execute(httpPut)) { + StatusLine statusLine = response.getStatusLine(); + int statusCode = statusLine.getStatusCode(); + + EntityUtils.consumeQuietly(response.getEntity()); + + if (statusCode == HttpStatus.SC_CONFLICT) { + // Someone possibly updated the document while we were thinking about it. + // It was probably another instance of this exact code. + throw new CouchdbClashingUpdateException(ERROR_FAILED_TO_UPDATE_COUCHDB_DESING_DOC_CONFLICT.toString()); + } + + if (statusCode != HttpStatus.SC_CREATED) { + + throw new CouchdbException( + "Update of galasa_tokens design document failed on CouchDB server - " + statusLine.toString()); + } + + } catch (CouchdbException e) { + throw e; + } catch (Exception e) { + throw new CouchdbException("Update of galasa_tokens design document failed", e); + } } } diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/Errors.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/Errors.java new file mode 100644 index 00000000..13634e94 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/Errors.java @@ -0,0 +1,89 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package dev.galasa.auth.couchdb.internal; + + +import java.text.MessageFormat; + +public enum Errors { + + ERROR_FAILED_TO_PARSE_COUCHDB_DESIGN_DOC (7500, + "GAL7500E: The Galasa auth extension could not check that couchdb has the correct definition for the dababase in which access tokens are stored."+ + "The design of the database could not be parsed. Please report this error to your Galasa system administrator. Detailed cause of this problem: {}"), + ERROR_FAILED_TO_UPDATE_COUCHDB_DESING_DOC_CONFLICT (7501, + "GAL7501E: The Galasa auth extension could not upgrade the definition of the couchdb database in which access tokens are stored."+ + "The design of the database could not be updated due to clashing updates. Please report this error to your Galasa system administrator."), + ; + + private String template; + private int expectedParameterCount; + private Errors(int ordinal, String template ) { + this.template = template ; + this.expectedParameterCount = this.template.split("[{]").length-1; + } + + public String getMessage() { + String msg ; + int actualParameterCount = 0; + + if (actualParameterCount!= this.expectedParameterCount) { + msg = getWrongNumberOfParametersErrorMessage(actualParameterCount,expectedParameterCount); + } else { + msg = this.template; + } + + return msg; + } + + public String getMessage(Object o1) { + + String msg ; + int actualParameterCount = 1; + + if (actualParameterCount!= this.expectedParameterCount) { + msg = getWrongNumberOfParametersErrorMessage(actualParameterCount,expectedParameterCount); + } else { + msg = MessageFormat.format(this.template,o1); + } + + return msg; + } + + public String getMessage(Object o1, Object o2) { + + String msg ; + int actualParameterCount = 2; + + if (actualParameterCount!= this.expectedParameterCount) { + msg = getWrongNumberOfParametersErrorMessage(actualParameterCount,expectedParameterCount); + } else { + msg = MessageFormat.format(this.template,o1,o2); + } + + return msg; + } + + public String getMessage(Object o1, Object o2, Object o3) { + + String msg ; + int actualParameterCount = 3; + + if (actualParameterCount!= this.expectedParameterCount) { + msg = getWrongNumberOfParametersErrorMessage(actualParameterCount,expectedParameterCount); + } else { + msg = MessageFormat.format(this.template,o1,o2,o3); + } + + return msg; + } + + private String getWrongNumberOfParametersErrorMessage(int actualParameterCount,int expectedParameterCount) { + String template = "Failed to render message template. Not the expected number of parameters. Got ''{0}''. Expected ''{1}''"; + String msg = MessageFormat.format(template,actualParameterCount, this.expectedParameterCount); + return msg ; + } +} diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokenDBLoginView.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokenDBLoginView.java new file mode 100644 index 00000000..95c92afe --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokenDBLoginView.java @@ -0,0 +1,10 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.auth.couchdb.internal.beans; + +public class TokenDBLoginView { + public String map; +} \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokenDBViews.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokenDBViews.java new file mode 100644 index 00000000..d7af7ffb --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokenDBViews.java @@ -0,0 +1,13 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.auth.couchdb.internal.beans; + +import com.google.gson.annotations.SerializedName; + +public class TokenDBViews { + @SerializedName("loginId-view") + public TokenDBLoginView loginIdView; +} diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokensDBNameViewDesign.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokensDBNameViewDesign.java new file mode 100644 index 00000000..8c7add64 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/main/java/dev/galasa/auth/couchdb/internal/beans/TokensDBNameViewDesign.java @@ -0,0 +1,24 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.auth.couchdb.internal.beans; + +//{ +// "_id": "_design/docs", +// "_rev": "3-xxxxxxxxxxx9c9072dyy", +// "views": { +// "loginId-view": { +// "map": "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}" +// } +// }, +// "language": "javascript" +// } +public class TokensDBNameViewDesign { + public String _rev; + public String _id; + public TokenDBViews views; + public String language; +} + \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java index 59d926d0..0ec9b21d 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStore.java @@ -105,7 +105,7 @@ public void testGetTokensReturnsTokensWithFailingRequestReturnsError() throws Ex MockLogFactory logFactory = new MockLogFactory(); List interactions = new ArrayList(); - interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs?include_docs=true&endkey=%22_%22", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); @@ -137,7 +137,7 @@ public void testGetTokensReturnsTokensFromCouchdbOK() throws Exception { CouchdbAuthToken mockToken = new CouchdbAuthToken("token1", "dex-client", "my test token", Instant.now(), new CouchdbUser("johndoe", "dex-user-id")); List interactions = new ArrayList(); - interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs", HttpStatus.SC_OK, mockAllDocsResponse)); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_all_docs?include_docs=true&endkey=%22_%22", HttpStatus.SC_OK, mockAllDocsResponse)); interactions.add(new GetTokenDocumentInteraction("https://my-auth-store/galasa_tokens/token1", HttpStatus.SC_OK, mockToken)); MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); @@ -157,6 +157,66 @@ public void testGetTokensReturnsTokensFromCouchdbOK() throws Exception { assertThat(actualToken).usingRecursiveComparison().isEqualTo(mockToken); } + @Test + public void testGetTokensReturnsTokensByLoginIdFromCouchdbOK() throws Exception { + // Given... + URI authStoreUri = URI.create("couchdb:https://my-auth-store"); + MockLogFactory logFactory = new MockLogFactory(); + + ViewRow tokenDoc = new ViewRow(); + tokenDoc.id = "token1"; + List mockDocs = List.of(tokenDoc); + + ViewResponse mockAllDocsResponse = new ViewResponse(); + mockAllDocsResponse.rows = mockDocs; + + CouchdbAuthToken mockToken = new CouchdbAuthToken("token1", "dex-client", "my test token", Instant.now(), new CouchdbUser("johndoe", "dex-user-id")); + CouchdbAuthToken mockToken2 = new CouchdbAuthToken("token2", "dex-client", "my test token", Instant.now(), new CouchdbUser("notJohnDoe", "dex-user-id")); + List interactions = new ArrayList(); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_design/docs/_view/loginId-view?key=%22johndoe%22", HttpStatus.SC_OK, mockAllDocsResponse)); + interactions.add(new GetTokenDocumentInteraction("https://my-auth-store/galasa_tokens/token1", HttpStatus.SC_OK, mockToken)); + interactions.add(new GetTokenDocumentInteraction("https://my-auth-store/galasa_tokens/token1", HttpStatus.SC_OK, mockToken2)); + + MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + + MockHttpClientFactory httpClientFactory = new MockHttpClientFactory(mockHttpClient); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + + CouchdbAuthStore authStore = new CouchdbAuthStore(authStoreUri, httpClientFactory, new HttpRequestFactoryImpl(), logFactory, new MockCouchdbValidator(), mockTimeService); + // When... + List tokens = authStore.getTokensByLoginId("johndoe"); + + // Then... + assertThat(tokens).hasSize(1); + + IInternalAuthToken actualToken = tokens.get(0); + assertThat(actualToken).usingRecursiveComparison().isEqualTo(mockToken); + } + + @Test + public void testGetTokensReturnsTokensByLoginIdWithFailingRequestReturnsError() throws Exception { + // Given... + URI authStoreUri = URI.create("couchdb:https://my-auth-store"); + MockLogFactory logFactory = new MockLogFactory(); + + List interactions = new ArrayList(); + interactions.add(new GetAllTokenDocumentsInteraction("https://my-auth-store/galasa_tokens/_design/docs/_view/loginId-view?key=%22johndoe%22", HttpStatus.SC_INTERNAL_SERVER_ERROR, null)); + + MockCloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + + MockHttpClientFactory httpClientFactory = new MockHttpClientFactory(mockHttpClient); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + + CouchdbAuthStore authStore = new CouchdbAuthStore(authStoreUri, httpClientFactory, new HttpRequestFactoryImpl(), logFactory, new MockCouchdbValidator(), mockTimeService); + + // When... + AuthStoreException thrown = catchThrowableOfType(() -> authStore.getTokensByLoginId("johndoe"), AuthStoreException.class); + + // Then... + assertThat(thrown).isNotNull(); + assertThat(thrown.getMessage()).contains("GAL6101E", "Failed to get auth tokens from the CouchDB auth store"); + } + @Test public void testStoreTokenSendsRequestToCreateTokenDocumentOK() throws Exception { // Given... diff --git a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStoreValidator.java b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStoreValidator.java index 78cae24f..f183a819 100644 --- a/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStoreValidator.java +++ b/galasa-extensions-parent/dev.galasa.auth.couchdb/src/test/java/dev/galasa/auth/couchdb/TestCouchdbAuthStoreValidator.java @@ -10,6 +10,7 @@ import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.time.Instant; import org.apache.http.HttpHost; import org.apache.http.HttpRequest; @@ -19,7 +20,7 @@ import dev.galasa.auth.couchdb.internal.CouchdbAuthStore; import dev.galasa.auth.couchdb.internal.CouchdbAuthStoreValidator; -import dev.galasa.extensions.common.couchdb.CouchdbBaseValidator; +import dev.galasa.auth.couchdb.internal.beans.*; import dev.galasa.extensions.common.couchdb.CouchdbException; import dev.galasa.extensions.common.couchdb.pojos.PutPostResponse; import dev.galasa.extensions.common.couchdb.pojos.Welcome; @@ -27,6 +28,8 @@ import dev.galasa.extensions.mocks.BaseHttpInteraction; import dev.galasa.extensions.mocks.HttpInteraction; import dev.galasa.extensions.mocks.MockCloseableHttpClient; +import dev.galasa.extensions.mocks.MockTimeService; +import dev.galasa.extensions.common.couchdb.CouchDbVersion; public class TestCouchdbAuthStoreValidator { @@ -76,6 +79,35 @@ public void validateRequest(HttpHost host, HttpRequest request) throws RuntimeEx } } + class GetTokensDatabaseDesignInteraction extends BaseHttpInteraction { + public GetTokensDatabaseDesignInteraction(String expectedUri, Object returnedDocument) { + this(expectedUri, returnedDocument, HttpStatus.SC_OK); + } + + public GetTokensDatabaseDesignInteraction(String expectedUri, Object returnedDocument, int expectedResponseCode) { + super(expectedUri, returnedDocument, expectedResponseCode); + } + + @Override + public void validateRequest(HttpHost host, HttpRequest request) throws RuntimeException { + super.validateRequest(host,request); + assertThat(request.getRequestLine().getMethod()).isEqualTo("GET"); + } + } + + class UpdateTokensDatabaseDesignInteraction extends BaseHttpInteraction { + public UpdateTokensDatabaseDesignInteraction(String expectedUri, String returnedDocument, int expectedResponseCode) { + super(expectedUri, returnedDocument, expectedResponseCode); + setResponsePayload(returnedDocument); + } + + @Override + public void validateRequest(HttpHost host, HttpRequest request) throws RuntimeException { + super.validateRequest(host,request); + assertThat(request.getRequestLine().getMethod()).isEqualTo("PUT"); + } + } + @Test public void testCheckCouchdbDatabaseIsValidWithValidDatabaseIsOK() throws Exception { // Given... @@ -85,21 +117,42 @@ public void testCheckCouchdbDatabaseIsValidWithValidDatabaseIsOK() throws Except Welcome welcomeMessage = new Welcome(); welcomeMessage.couchdb = "Welcome"; - welcomeMessage.version = CouchdbBaseValidator.COUCHDB_MIN_VERSION; + welcomeMessage.version = CouchDbVersion.COUCHDB_MIN_VERSION.toString(); List interactions = new ArrayList<>(); interactions.add(new GetCouchdbWelcomeInteraction(couchdbUriStr, welcomeMessage)); interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); + + + // We are expecting this to ne returned from our mock couchdb server to the code: + // "_id": "_design/docs", + // "_rev": "3-9e69612124f138c029ab40c9c9072deb", + // "views": { + // "loginId-view": { + // "map": "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}" + // } + // }, + // "language": "javascript" + // } + TokenDBLoginView view = new TokenDBLoginView(); + view.map = "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}"; + TokenDBViews views = new TokenDBViews(); + views.loginIdView = view; + TokensDBNameViewDesign designDocToPassBack = new TokensDBNameViewDesign(); + designDocToPassBack.language = "javascript"; + + + + String tokensDesignDocUrl = couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME + "/_design/docs"; + interactions.add(new GetTokensDatabaseDesignInteraction(tokensDesignDocUrl, designDocToPassBack)); + + + interactions.add(new UpdateTokensDatabaseDesignInteraction(tokensDesignDocUrl, "", HttpStatus.SC_CREATED)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); // When... - Throwable thrown = catchThrowable( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()) - ); - - // Then... - // The validation should have passed, so no errors should have been thrown - assertThat(thrown).isNull(); + validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService); } @Test @@ -111,7 +164,7 @@ public void testCheckCouchdbDatabaseIsValidWithFailingDatabaseCreationReturnsErr Welcome welcomeMessage = new Welcome(); welcomeMessage.couchdb = "Welcome"; - welcomeMessage.version = CouchdbBaseValidator.COUCHDB_MIN_VERSION; + welcomeMessage.version = CouchDbVersion.COUCHDB_MIN_VERSION.toString(); String tokensDatabaseName = CouchdbAuthStore.TOKENS_DATABASE_NAME; List interactions = new ArrayList<>(); @@ -119,10 +172,11 @@ public void testCheckCouchdbDatabaseIsValidWithFailingDatabaseCreationReturnsErr interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + tokensDatabaseName, HttpStatus.SC_NOT_FOUND)); interactions.add(new CreateDatabaseInteraction(couchdbUriStr + "/" + tokensDatabaseName, HttpStatus.SC_INTERNAL_SERVER_ERROR)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService), CouchdbException.class ); @@ -140,24 +194,35 @@ public void testCheckCouchdbDatabaseIsValidWithSuccessfulDatabaseCreationIsOK() Welcome welcomeMessage = new Welcome(); welcomeMessage.couchdb = "Welcome"; - welcomeMessage.version = CouchdbBaseValidator.COUCHDB_MIN_VERSION; + welcomeMessage.version = CouchDbVersion.COUCHDB_MIN_VERSION.toString(); String tokensDatabaseName = CouchdbAuthStore.TOKENS_DATABASE_NAME; List interactions = new ArrayList<>(); interactions.add(new GetCouchdbWelcomeInteraction(couchdbUriStr, welcomeMessage)); interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + tokensDatabaseName, HttpStatus.SC_NOT_FOUND)); interactions.add(new CreateDatabaseInteraction(couchdbUriStr + "/" + tokensDatabaseName, HttpStatus.SC_CREATED)); + + TokenDBLoginView view = new TokenDBLoginView(); + view.map = "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}"; + TokenDBViews views = new TokenDBViews(); + views.loginIdView = view; + TokensDBNameViewDesign designDocToPassBack = new TokensDBNameViewDesign(); + designDocToPassBack.language = "javascript"; + + + String tokensDesignDocUrl = couchdbUriStr + "/" + tokensDatabaseName + "/_design/docs"; + interactions.add(new GetTokensDatabaseDesignInteraction(tokensDesignDocUrl, designDocToPassBack)); + + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + + interactions.add(new UpdateTokensDatabaseDesignInteraction(tokensDesignDocUrl, "",HttpStatus.SC_CREATED)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); // When... - CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), - CouchdbException.class - ); + validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(),mockTimeService); // Then... // The validation should have passed, so no errors should have been thrown - assertThat(thrown).isNull(); } @Test @@ -167,7 +232,7 @@ public void testCheckCouchdbDatabaseIsValidWithNewerCouchdbVersionIsOK() throws URI couchdbUri = URI.create(couchdbUriStr); CouchdbAuthStoreValidator validator = new CouchdbAuthStoreValidator(); - String[] versionParts = CouchdbBaseValidator.COUCHDB_MIN_VERSION.split("\\."); + String[] versionParts = CouchDbVersion.COUCHDB_MIN_VERSION.toString().split("\\."); int majorVersion = Integer.parseInt(versionParts[0]); int minorVersion = Integer.parseInt(versionParts[1]); int patchVersion = Integer.parseInt(versionParts[2]); @@ -182,9 +247,11 @@ public void testCheckCouchdbDatabaseIsValidWithNewerCouchdbVersionIsOK() throws interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + tokensDatabaseName, HttpStatus.SC_INTERNAL_SERVER_ERROR)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService), CouchdbException.class ); @@ -202,16 +269,18 @@ public void testCheckCouchdbDatabaseIsValidWithInvalidWelcomeMessageThrowsError( Welcome welcomeMessage = new Welcome(); welcomeMessage.couchdb = "not welcome"; - welcomeMessage.version = CouchdbBaseValidator.COUCHDB_MIN_VERSION; + welcomeMessage.version = CouchDbVersion.COUCHDB_MIN_VERSION.toString(); List interactions = new ArrayList<>(); interactions.add(new GetCouchdbWelcomeInteraction(couchdbUriStr, welcomeMessage)); interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService), CouchdbException.class ); @@ -236,15 +305,17 @@ public void testCheckCouchdbDatabaseIsValidWithMajorVersionMismatchThrowsError() interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(),mockTimeService), CouchdbException.class ); // Then... assertThat(thrown).isNotNull(); - assertThat(thrown.getMessage()).contains("GAL6005E", "Expected version '" + CouchdbBaseValidator.COUCHDB_MIN_VERSION + "' or above"); + assertThat(thrown.getMessage()).contains("GAL6005E", "Expected version '" + CouchDbVersion.COUCHDB_MIN_VERSION.toString() + "' or above"); } @Test @@ -263,9 +334,11 @@ public void testCheckCouchdbDatabaseIsValidWithInvalidVersionThrowsError() throw interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(),mockTimeService), CouchdbException.class ); @@ -281,7 +354,7 @@ public void testCheckCouchdbDatabaseIsValidWithMinorVersionMismatchThrowsError() URI couchdbUri = URI.create(couchdbUriStr); CouchdbAuthStoreValidator validator = new CouchdbAuthStoreValidator(); - String majorVersion = CouchdbBaseValidator.COUCHDB_MIN_VERSION.split("\\.")[0]; + String majorVersion = CouchDbVersion.COUCHDB_MIN_VERSION.toString().split("\\.")[0]; Welcome welcomeMessage = new Welcome(); welcomeMessage.couchdb = "Welcome"; @@ -292,15 +365,18 @@ public void testCheckCouchdbDatabaseIsValidWithMinorVersionMismatchThrowsError() interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(),mockTimeService), CouchdbException.class ); // Then... assertThat(thrown).isNotNull(); - assertThat(thrown.getMessage()).contains("GAL6005E", "Expected version '" + CouchdbBaseValidator.COUCHDB_MIN_VERSION + "' or above"); + assertThat(thrown.getMessage()).contains("GAL6005E", "Expected version '" + CouchDbVersion.COUCHDB_MIN_VERSION.toString() + "' or above"); } @Test @@ -310,7 +386,7 @@ public void testCheckCouchdbDatabaseIsValidWithPatchVersionMismatchThrowsError() URI couchdbUri = URI.create(couchdbUriStr); CouchdbAuthStoreValidator validator = new CouchdbAuthStoreValidator(); - String[] minVersionParts = CouchdbBaseValidator.COUCHDB_MIN_VERSION.split("\\."); + String[] minVersionParts = CouchDbVersion.COUCHDB_MIN_VERSION.toString().split("\\."); Welcome welcomeMessage = new Welcome(); welcomeMessage.couchdb = "Welcome"; @@ -321,14 +397,120 @@ public void testCheckCouchdbDatabaseIsValidWithPatchVersionMismatchThrowsError() interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + + // When... + CouchdbException thrown = catchThrowableOfType( + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService), + CouchdbException.class + ); + + // Then... + assertThat(thrown).isNotNull(); + assertThat(thrown.getMessage()).contains("GAL6005E", "Expected version '" + CouchDbVersion.COUCHDB_MIN_VERSION.toString() + "' or above"); + } + + @Test + public void testCheckCouchdbDatabaseIsValidWithFailedDesignDocResponseThrowsError() throws Exception { + // Given... + String couchdbUriStr = "https://my-couchdb-server"; + URI couchdbUri = URI.create(couchdbUriStr); + CouchdbAuthStoreValidator validator = new CouchdbAuthStoreValidator(); + + Welcome welcomeMessage = new Welcome(); + welcomeMessage.couchdb = "Welcome"; + welcomeMessage.version = CouchDbVersion.COUCHDB_MIN_VERSION.toString(); + + List interactions = new ArrayList<>(); + interactions.add(new GetCouchdbWelcomeInteraction(couchdbUriStr, welcomeMessage)); + interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); + + + // We are expecting this to ne returned from our mock couchdb server to the code: + // "_id": "_design/docs", + // "_rev": "3-9e69612124f138c029ab40c9c9072deb", + // "views": { + // "loginId-view": { + // "map": "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}" + // } + // }, + // "language": "javascript" + // } + TokenDBLoginView view = new TokenDBLoginView(); + view.map = "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}"; + TokenDBViews views = new TokenDBViews(); + views.loginIdView = view; + TokensDBNameViewDesign designDocToPassBack = new TokensDBNameViewDesign(); + designDocToPassBack.language = "javascript"; + + + String tokensDesignDocUrl = couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME + "/_design/docs"; + interactions.add(new GetTokensDatabaseDesignInteraction(tokensDesignDocUrl, designDocToPassBack, HttpStatus.SC_INTERNAL_SERVER_ERROR)); + CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + + // When... + CouchdbException thrown = catchThrowableOfType( + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService), + CouchdbException.class + ); + + // Then... + assertThat(thrown).isNotNull(); + assertThat(thrown.getMessage()).contains("Validation failed of database galasa_tokens design document"); + } + + @Test + public void testCheckCouchdbDatabaseIsValidWithUpdateDesignDocResponseThrowsError() throws Exception { + // Given... + String couchdbUriStr = "https://my-couchdb-server"; + URI couchdbUri = URI.create(couchdbUriStr); + CouchdbAuthStoreValidator validator = new CouchdbAuthStoreValidator(); + + Welcome welcomeMessage = new Welcome(); + welcomeMessage.couchdb = "Welcome"; + welcomeMessage.version = CouchDbVersion.COUCHDB_MIN_VERSION.toString(); + + List interactions = new ArrayList<>(); + interactions.add(new GetCouchdbWelcomeInteraction(couchdbUriStr, welcomeMessage)); + interactions.add(new GetTokensDatabaseInteraction(couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME, HttpStatus.SC_OK)); + + + // We are expecting this to ne returned from our mock couchdb server to the code: + // "_id": "_design/docs", + // "_rev": "3-9e69612124f138c029ab40c9c9072deb", + // "views": { + // "loginId-view": { + // "map": "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}" + // } + // }, + // "language": "javascript" + // } + TokenDBLoginView view = new TokenDBLoginView(); + view.map = "function (doc) {\n if (doc.owner && doc.owner.loginId) {\n emit(doc.owner.loginId, doc);\n }\n}"; + TokenDBViews views = new TokenDBViews(); + views.loginIdView = view; + TokensDBNameViewDesign designDocToPassBack = new TokensDBNameViewDesign(); + designDocToPassBack.language = "javascript"; + + + String tokensDesignDocUrl = couchdbUriStr + "/" + CouchdbAuthStore.TOKENS_DATABASE_NAME + "/_design/docs"; + interactions.add(new GetTokensDatabaseDesignInteraction(tokensDesignDocUrl, designDocToPassBack, HttpStatus.SC_OK)); + interactions.add(new UpdateTokensDatabaseDesignInteraction(tokensDesignDocUrl, "", HttpStatus.SC_INTERNAL_SERVER_ERROR)); + CloseableHttpClient mockHttpClient = new MockCloseableHttpClient(interactions); + + MockTimeService mockTimeService = new MockTimeService(Instant.now()); + // When... CouchdbException thrown = catchThrowableOfType( - () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl()), + () -> validator.checkCouchdbDatabaseIsValid(couchdbUri, mockHttpClient, new HttpRequestFactoryImpl(), mockTimeService), CouchdbException.class ); // Then... assertThat(thrown).isNotNull(); - assertThat(thrown.getMessage()).contains("GAL6005E", "Expected version '" + CouchdbBaseValidator.COUCHDB_MIN_VERSION + "' or above"); + assertThat(thrown.getMessage()).contains("Update of galasa_tokens design"); } + } diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle b/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle index 64a33227..7c43f48d 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle +++ b/galasa-extensions-parent/dev.galasa.extensions.common/build.gradle @@ -11,6 +11,8 @@ dependencies { implementation ('org.apache.httpcomponents:httpclient-osgi:4.5.13') implementation ('org.apache.httpcomponents:httpcore-osgi:4.4.14') implementation ('com.google.code.gson:gson:2.10.1') + + testImplementation(project(':dev.galasa.extensions.mocks')) } // Note: These values are consumed by the parent build process diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java index 6470c571..7b0df5f4 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/Errors.java @@ -58,6 +58,7 @@ public enum Errors { ERROR_GALASA_REST_CALL_TO_GET_CPS_NAMESPACES_FAILED (7020,"GAL7020E: Could not get the CPS namespaces information from URL ''{0}''. Cause: {1}"), ERROR_GALASA_REST_CALL_TO_GET_CPS_NAMESPACES_BAD_JSON_RETURNED (7021,"GAL7021E: Could not get the CPS namespaces value from URL ''{0}''. Cause: Bad json returned from the server. {1}"), + ERROR_GALASA_COUCHDB_UPDATED_FAILED_AFTER_RETRIES (7022,"GAL7022E: Couchdb operation failed after {0} attempts, due to conflicts."), ; private String template; diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchDbVersion.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchDbVersion.java new file mode 100644 index 00000000..7c947d44 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchDbVersion.java @@ -0,0 +1,122 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.extensions.common.couchdb; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static dev.galasa.extensions.common.Errors.*; + + +public class CouchDbVersion implements Comparable { + + + private int version ; + private int release; + private int modification; + + public static final CouchDbVersion COUCHDB_MIN_VERSION = new CouchDbVersion(3,3,3); + + public CouchDbVersion(int version, int release, int modification) { + this.version = version ; + this.release = release ; + this.modification = modification; + } + + public CouchDbVersion(String dotSeparatedVersion ) throws CouchdbException { + Pattern vrm = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$"); + Matcher m = vrm.matcher(dotSeparatedVersion); + + if (!m.find()) { + String errorMessage = ERROR_INVALID_COUCHDB_VERSION_FORMAT.getMessage(dotSeparatedVersion, COUCHDB_MIN_VERSION); + throw new CouchdbException(errorMessage); + } + + try { + this.version = Integer.parseInt(m.group(1)); + this.release = Integer.parseInt(m.group(2)); + this.modification = Integer.parseInt(m.group(3)); + } catch (NumberFormatException e) { + throw new CouchdbException(ERROR_INVALID_COUCHDB_VERSION_FORMAT.getMessage(dotSeparatedVersion), e); // TODO: Common error. + } + } + + public int getVersion() { + return this.version ; + } + + public int getRelease() { + return this.release; + } + + public int getModification() { + return this.modification; + } + + @Override + public boolean equals(Object other) { + boolean isSame = false ; + + if( other != null ) { + if( other instanceof CouchDbVersion) { + CouchDbVersion otherVersion = (CouchDbVersion)other; + if(otherVersion.version == this.version){ + if (otherVersion.release == this.release) { + if( otherVersion.modification == this.modification) { + isSame = true; + } + } + } + } + } + + return isSame; + } + + @Override + public int hashCode() { + int hash = 7; + hash = 31 * hash + (int) this.version; + hash = 31 * hash + (int) this.release; + hash = 31 * hash + (int) this.modification; + return hash; + } + + @Override + public int compareTo(CouchDbVersion other) { + int result ; + if (this.version > other.version) { + result = +1; + } else if (this.version < other.version) { + result = -1; + } else if (this.release > other.release) { + result = +1; + } else if (this.release < other.release) { + result = -1; + } else if (this.modification > other.modification) { + result = +1; + } else if (this.modification < other.modification) { + result = -1; + } else { + result = 0; + } + return result; + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + + buffer.append(this.version); + buffer.append('.'); + buffer.append(this.release); + buffer.append('.'); + buffer.append(this.modification); + + return buffer.toString(); + } + +} \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbBaseValidator.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbBaseValidator.java index 46321299..be61815b 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbBaseValidator.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbBaseValidator.java @@ -20,9 +20,6 @@ import java.io.IOException; import java.net.URI; -import java.util.Arrays; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -30,11 +27,10 @@ import dev.galasa.extensions.common.couchdb.pojos.Welcome; import dev.galasa.extensions.common.api.HttpRequestFactory; import dev.galasa.framework.spi.utils.GalasaGson; +import dev.galasa.framework.spi.utils.ITimeService; public abstract class CouchdbBaseValidator implements CouchdbValidator { - public static final String COUCHDB_MIN_VERSION = "3.3.3"; - private final GalasaGson gson = new GalasaGson(); private final Log logger = LogFactory.getLog(getClass()); @@ -42,7 +38,7 @@ public abstract class CouchdbBaseValidator implements CouchdbValidator { private CloseableHttpClient httpClient; @Override - public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory httpRequestFactory) throws CouchdbException { + public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory httpRequestFactory, ITimeService timeService) throws CouchdbException { this.requestFactory = httpRequestFactory; this.httpClient = httpClient; @@ -73,6 +69,10 @@ public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient http } } + public HttpRequestFactory getRequestFactory() { + return requestFactory; + } + /** * Checks if a database with the given name exists in the CouchDB server. If not, the database is created. * @@ -152,36 +152,17 @@ private synchronized void createDatabase(URI couchdbUri, String dbName) throws C * @throws CouchdbException if the version of CouchDB is older than the minimum required version */ private void checkVersion(String actualVersion) throws CouchdbException { - Pattern versionPattern = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$"); - Matcher versionMatcher = versionPattern.matcher(actualVersion); - - // Make sure the given version follows semantic versioning rules (i.e. major.minor.patch) - if (!versionMatcher.find()) { - String errorMessage = ERROR_INVALID_COUCHDB_VERSION_FORMAT.getMessage(actualVersion, COUCHDB_MIN_VERSION); - throw new CouchdbException(errorMessage); - } - int[] actualVersionParts = getVersionStringAsArray(actualVersion); - int[] minVersionParts = getVersionStringAsArray(COUCHDB_MIN_VERSION); + CouchDbVersion actualCouchDbVersion = new CouchDbVersion(actualVersion); // Check if the actual version is older than the minimum version // If the actual version matches the minimum version, then this loop will continue until // all parts of the versions have been compared - for (int i = 0; i < minVersionParts.length; i++) { - if (actualVersionParts[i] < minVersionParts[i]) { + if ( actualCouchDbVersion.compareTo(CouchDbVersion.COUCHDB_MIN_VERSION) < 0) { - // The minimum CouchDB version is later than the actual version, so throw an error - String errorMessage = ERROR_OUTDATED_COUCHDB_VERSION.getMessage(actualVersion, COUCHDB_MIN_VERSION); - throw new CouchdbException(errorMessage); - - } else if (actualVersionParts[i] > minVersionParts[i]) { - // The minimum CouchDB version is older than the actual version, this is fine - break; - } + // The minimum CouchDB version is later than the actual version, so throw an error + String errorMessage = ERROR_OUTDATED_COUCHDB_VERSION.getMessage(actualVersion, CouchDbVersion.COUCHDB_MIN_VERSION); + throw new CouchdbException(errorMessage); } } - - private int[] getVersionStringAsArray(String versionStr) { - return Arrays.stream(versionStr.split("\\.")).mapToInt(Integer::parseInt).toArray(); - } } diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbClashingUpdateException.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbClashingUpdateException.java new file mode 100644 index 00000000..b1001a6c --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbClashingUpdateException.java @@ -0,0 +1,40 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.extensions.common.couchdb; + +public class CouchdbClashingUpdateException extends CouchdbException { + + private static final long serialVersionUID = 1L; + + /** + * {@inheritDoc} + */ + public CouchdbClashingUpdateException() { + super(); + } + + /** + * {@inheritDoc} + */ + public CouchdbClashingUpdateException(String message) { + super(message); + } + + /** + * {@inheritDoc} + */ + public CouchdbClashingUpdateException(Throwable cause) { + super(cause); + } + + /** + * {@inheritDoc} + */ + public CouchdbClashingUpdateException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java index 0c6062f4..a7d86b99 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbStore.java @@ -8,8 +8,10 @@ import static dev.galasa.extensions.common.Errors.*; import java.io.IOException; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.CopyOption; import java.nio.file.Files; @@ -18,6 +20,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import org.apache.http.HttpEntity; import org.apache.http.HttpStatus; import org.apache.http.ParseException; @@ -40,8 +44,10 @@ import dev.galasa.framework.spi.utils.GalasaGson; /** - * This is a base class for CouchDB implementations of Galasa stores that defines functions for common interactions - * with CouchDB, including creating documents in a database and getting all documents that are stored in a database. + * This is a base class for CouchDB implementations of Galasa stores that + * defines functions for common interactions + * with CouchDB, including creating documents in a database and getting all + * documents that are stored in a database. */ public abstract class CouchdbStore { @@ -49,11 +55,14 @@ public abstract class CouchdbStore { protected final URI storeUri; + private Log logger = LogFactory.getLog(this.getClass()); + protected HttpRequestFactory httpRequestFactory; protected CloseableHttpClient httpClient; protected GalasaGson gson = new GalasaGson(); - public CouchdbStore(URI storeUri, HttpRequestFactory httpRequestFactory, HttpClientFactory httpClientFactory) throws CouchdbException { + public CouchdbStore(URI storeUri, HttpRequestFactory httpRequestFactory, HttpClientFactory httpClientFactory) + throws CouchdbException { // Strip off the 'couchdb:' prefix from the auth store URI // e.g. couchdb:https://myhost:5984 becomes https://myhost:5984 String storeUriStr = storeUri.toString(); @@ -71,10 +80,12 @@ public CouchdbStore(URI storeUri, HttpRequestFactory httpRequestFactory, HttpCli /** * Creates a new document in the given database with the given JSON content. * - * @param dbName the database to create the new document within - * @param jsonContent the JSON content to send to CouchDB in order to populate the new document + * @param dbName the database to create the new document within + * @param jsonContent the JSON content to send to CouchDB in order to populate + * the new document * @return PutPostResponse the response from the CouchDB service - * @throws CouchdbException if there is a problem accessing the CouchDB server or creating the document + * @throws CouchdbException if there is a problem accessing the CouchDB server + * or creating the document */ protected PutPostResponse createDocument(String dbName, String jsonContent) throws CouchdbException { // Create a new document in the tokens database with the new token to store @@ -95,20 +106,59 @@ protected PutPostResponse createDocument(String dbName, String jsonContent) thro } /** - * Sends a GET request to CouchDB's /{db}/_all_docs endpoint and returns the "rows" list in the response, + * Sends a GET request to CouchDB's /{db}/_all_docs endpoint and returns the + * "rows" list in the response, * which corresponds to the list of documents within the given database. * * @param dbName the name of the database to retrieve the documents of * @return a list of rows corresponding to documents within the database - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ protected List getAllDocsFromDatabase(String dbName) throws CouchdbException { - HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/_all_docs"); + + //The end key is "_" because, design docs start with "_design", + // this will exclude any design documents from being fetched from couchdb. + HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/_all_docs?include_docs=true&endkey=%22_%22"); String responseEntity = sendHttpRequest(getTokensDocs, HttpStatus.SC_OK); ViewResponse allDocs = gson.fromJson(responseEntity, ViewResponse.class); List viewRows = allDocs.rows; + if (viewRows == null) { + String errorMessage = ERROR_FAILED_TO_GET_DOCUMENTS_FROM_DATABASE.getMessage(dbName); + throw new CouchdbException(errorMessage); + } + + return viewRows; + } + + /** + * Sends a GET request to CouchDB's + * /{db}/_design/docs/_view/loginId-view?key={loginId} endpoint and returns the + * "rows" list in the response, + * which corresponds to the list of documents within the given database. + * + * @param dbName the name of the database to retrieve the documents of + * @param loginId the loginId of the user to retrieve the doucemnts of + * @return a list of rows corresponding to documents within the database + * @throws CouchdbException if there was a problem accessing the + * CouchDB store or its response + * @throws UnsupportedEncodingException A failure occurred. + */ + protected List getAllDocsByLoginId(String dbName, String loginId) throws CouchdbException { + + String encodedLoginId = URLEncoder.encode("\"" + loginId + "\"", StandardCharsets.UTF_8); + String url = storeUri + "/" + dbName + "/_design/docs/_view/loginId-view?key=" + encodedLoginId; + + HttpGet getTokensDocs = httpRequestFactory.getHttpGetRequest(url); + getTokensDocs.addHeader("Content-Type", "application/json"); + + String responseEntity = sendHttpRequest(getTokensDocs, HttpStatus.SC_OK); + + ViewResponse docByLoginId = gson.fromJson(responseEntity, ViewResponse.class); + List viewRows = docByLoginId.rows; + if (viewRows == null) { String errorMessage = ERROR_FAILED_TO_GET_DOCUMENTS_FROM_DATABASE.getMessage(dbName); throw new CouchdbException(errorMessage); @@ -118,28 +168,32 @@ protected List getAllDocsFromDatabase(String dbName) throws CouchdbExce } /** - * Gets an object from a given database's document using its document ID by sending a + * Gets an object from a given database's document using its document ID by + * sending a * GET /{db}/{docid} request to the CouchDB server. * - * @param The object type to be returned - * @param dbName the name of the database to retrieve the document from - * @param documentId the CouchDB ID for the document to retrieve - * @param classOfObject the class of the JSON object to retrieve from the CouchDB Document + * @param The object type to be returned + * @param dbName the name of the database to retrieve the document from + * @param documentId the CouchDB ID for the document to retrieve + * @param classOfObject the class of the JSON object to retrieve from the + * CouchDB Document * @return an object of the class provided in classOfObject - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ - protected T getDocumentFromDatabase(String dbName, String documentId, Class classOfObject) throws CouchdbException { + protected T getDocumentFromDatabase(String dbName, String documentId, Class classOfObject) + throws CouchdbException { HttpGet getDocumentRequest = httpRequestFactory.getHttpGetRequest(storeUri + "/" + dbName + "/" + documentId); return gson.fromJson(sendHttpRequest(getDocumentRequest, HttpStatus.SC_OK), classOfObject); } - - protected void retrieveArtifactFromDatabase(String URI, Path cachePath, CopyOption copyOption) throws CouchdbException{ + protected void retrieveArtifactFromDatabase(String URI, Path cachePath, CopyOption copyOption) + throws CouchdbException { HttpGet httpGet = httpRequestFactory.getHttpGetRequest(URI); try (CloseableHttpResponse response = httpClient.execute(httpGet)) { StatusLine statusLine = response.getStatusLine(); if (statusLine.getStatusCode() != HttpStatus.SC_OK) { - String errorMessage = ERROR_URI_IS_INVALID .getMessage(URI); + String errorMessage = ERROR_URI_IS_INVALID.getMessage(URI); throw new CouchdbException(errorMessage); } HttpEntity entity = response.getEntity(); @@ -155,9 +209,10 @@ protected void retrieveArtifactFromDatabase(String URI, Path cachePath, CopyOpti * Deletes a document from a given database using its document ID by sending a * DELETE /{db}/{docid} request to the CouchDB server. * - * @param dbName the name of the database to delete the document from + * @param dbName the name of the database to delete the document from * @param documentId the CouchDB ID for the document to delete - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ protected void deleteDocumentFromDatabase(String dbName, String documentId) throws CouchdbException { IdRev documentIdRev = getDocumentFromDatabase(dbName, documentId, IdRev.class); @@ -173,14 +228,18 @@ protected void deleteDocumentFromDatabase(String dbName, String documentId) thro } /** - * Sends a given HTTP request to the CouchDB server and returns the response body as a string. + * Sends a given HTTP request to the CouchDB server and returns the response + * body as a string. * - * @param httpRequest the HTTP request to send to the CouchDB server - * @param expectedHttpStatusCodes the expected Status code to get from the CouchDb server upon the request being actioned + * @param httpRequest the HTTP request to send to the CouchDB server + * @param expectedHttpStatusCodes the expected Status code to get from the + * CouchDb server upon the request being actioned * @return a string representation of the response. - * @throws CouchdbException if there was a problem accessing the CouchDB store or its response + * @throws CouchdbException if there was a problem accessing the CouchDB store + * or its response */ - protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttpStatusCodes) throws CouchdbException { + protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttpStatusCodes) + throws CouchdbException { String responseEntity = ""; try (CloseableHttpResponse response = httpClient.execute(httpRequest)) { StatusLine statusLine = response.getStatusLine(); @@ -188,10 +247,11 @@ protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttp if (!isStatusCodeExpected(actualStatusCode, expectedHttpStatusCodes)) { String expectedStatusCodesStr = IntStream.of(expectedHttpStatusCodes) - .mapToObj(Integer::toString) - .collect(Collectors.joining(", ")); + .mapToObj(Integer::toString) + .collect(Collectors.joining(", ")); - String errorMessage = ERROR_UNEXPECTED_COUCHDB_HTTP_RESPONSE.getMessage(httpRequest.getURI().toString(), expectedStatusCodesStr, actualStatusCode); + String errorMessage = ERROR_UNEXPECTED_COUCHDB_HTTP_RESPONSE.getMessage(httpRequest.getURI().toString(), + expectedStatusCodesStr, actualStatusCode); throw new CouchdbException(errorMessage); } @@ -199,18 +259,22 @@ protected String sendHttpRequest(HttpUriRequest httpRequest, int... expectedHttp responseEntity = EntityUtils.toString(entity); } catch (ParseException | IOException e) { - String errorMessage = ERROR_FAILURE_OCCURRED_WHEN_CONTACTING_COUCHDB.getMessage(httpRequest.getURI().toString(), e.getMessage()); + String errorMessage = ERROR_FAILURE_OCCURRED_WHEN_CONTACTING_COUCHDB + .getMessage(httpRequest.getURI().toString(), e.getMessage()); throw new CouchdbException(errorMessage, e); } return responseEntity; } /** - * Checks if a given status code is an expected status code using a given array of expected status codes. + * Checks if a given status code is an expected status code using a given array + * of expected status codes. * - * @param actualStatusCode the status code to check - * @param expectedStatusCodes an array of expected status codes returned from CouchDB - * @return true if the actual status code is an expected status code, false otherwise + * @param actualStatusCode the status code to check + * @param expectedStatusCodes an array of expected status codes returned from + * CouchDB + * @return true if the actual status code is an expected status code, false + * otherwise */ private boolean isStatusCodeExpected(int actualStatusCode, int... expectedStatusCodes) { boolean isExpectedStatusCode = false; diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbValidator.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbValidator.java index db93223b..c42b707e 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbValidator.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/CouchdbValidator.java @@ -8,9 +8,10 @@ import org.apache.http.impl.client.CloseableHttpClient; import dev.galasa.extensions.common.api.HttpRequestFactory; +import dev.galasa.framework.spi.utils.ITimeService; import java.net.URI; public interface CouchdbValidator { - public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory requestFactory) throws CouchdbException; + public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory requestFactory, ITimeService timeService ) throws CouchdbException; } diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessor.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessor.java new file mode 100644 index 00000000..2d4b77e4 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessor.java @@ -0,0 +1,111 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.extensions.common.couchdb; + +import java.util.Random; + +import dev.galasa.extensions.common.Errors; +import dev.galasa.extensions.common.api.LogFactory; +import dev.galasa.framework.spi.utils.ITimeService; + +import org.apache.commons.logging.Log; + +/** + * Allows a lambda function to be used, and that function will be retried a number of times before giving up. + */ +public class RetryableCouchdbUpdateOperationProcessor { + + public static final int DEFAULT_MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP = 10; + + private ITimeService timeService ; + private Log logger; + + /** + * Lambda supplying the code which should be repeated during successive attempts + */ + public interface RetryableCouchdbUpdateOperation { + /** + * @throws CouchdbException Something went wrong and no retries should be attempted, failing by passing this error upwards to the caller. + * @throws CouchdbClashingUpdateException Couchdb can't do the update right now, the routine will be re-tried. + */ + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException; + } + + /** + * Calculates how much time delay we need to leave between attempts to update. + */ + public interface BackoffTimeCalculator { + /** + * @return The number of milliseconds to wait between successive re-tries of the couchdb update operation. + */ + public default long getBackoffDelayMillis() { + return 1000L + new Random().nextInt(3000); + } + } + + public RetryableCouchdbUpdateOperationProcessor(ITimeService timeService, LogFactory logFactory ) { + this.timeService = timeService; + this.logger = logFactory.getLog(this.getClass()); + } + + /** + * Pass an operation you want retried if it fails with a CouchdbClashingUpdateException, using defaults. + * + * It retries for a default number of times before giving up, with a default random backoff time between retry attempts. + * + * @param retryableOperation The operation we want to retry. + * @throws CouchdbException A failure occurred. + */ + public void retryCouchDbUpdateOperation(RetryableCouchdbUpdateOperation retryableOperation) throws CouchdbException { + retryCouchDbUpdateOperation(retryableOperation, DEFAULT_MAX_ATTEMPTS_TO_GO_BEFORE_GIVE_UP, new BackoffTimeCalculator() {} ); + } + + /** + * Pass an operation you want retried if it fails with a CouchdbClashingUpdateException. + * + * @param retryableOperation The operation we want to retry. + * @param attemptsToGoBeforeGiveUp The number of times the retryable operation is attempted before eventually giving up with a failure. + * @param backofftimeCalculator The lambda operation we consult to find out backoff times between retry attempts + * @throws CouchdbException A failure occurred. + */ + public void retryCouchDbUpdateOperation(RetryableCouchdbUpdateOperation retryableOperation, int attemptsToGoBeforeGiveUp, BackoffTimeCalculator backofftimeCalculator) throws CouchdbException{ + boolean isDone = false; + int retriesRemaining = attemptsToGoBeforeGiveUp; + + while (!isDone) { + + try { + retryableOperation.tryToUpdateCouchDb(); + + isDone = true; + } catch (CouchdbClashingUpdateException updateClashedEx) { + + logger.info("Clashing update detected. Backing off for a short time to avoid another clash immediately. "); + + waitForBackoffDelay(timeService, backofftimeCalculator); + + retriesRemaining -= 1; + if (retriesRemaining == 0) { + String msg = Errors.ERROR_GALASA_COUCHDB_UPDATED_FAILED_AFTER_RETRIES.getMessage(Integer.toString(attemptsToGoBeforeGiveUp)); + logger.info(msg); + throw new CouchdbException(msg, updateClashedEx); + } else { + logger.info("Failed to perform the couchdb operation, retrying..."); + } + } + } + } + + void waitForBackoffDelay(ITimeService timeService2, BackoffTimeCalculator backofftimeCalculator) { + long delayMilliSecs = backofftimeCalculator.getBackoffDelayMillis(); + try { + logger.info("Waiting "+delayMilliSecs+" during a back-off delay. starting now."); + timeService2.sleepMillis(delayMilliSecs); + } catch(InterruptedException ex ) { + logger.info("Interrupted from waiting during a back-off delay. Ignoring this, but cutting our wait short."); + } + } +} \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java index 61b39dbd..6decec20 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/main/java/dev/galasa/extensions/common/couchdb/pojos/ViewRow.java @@ -7,6 +7,7 @@ public class ViewRow { + public String id; public String key; public Object value; diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java new file mode 100644 index 00000000..c43099ea --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/CouchDbVersionTest.java @@ -0,0 +1,169 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package dev.galasa.extensions.common.couchdb; + +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.*; + +public class CouchDbVersionTest { + + @Test + public void testCanCreateAVersion() { + CouchDbVersion version = new CouchDbVersion(1,2,3); + assertThat(version.getVersion()).isEqualTo(1); + assertThat(version.getRelease()).isEqualTo(2); + assertThat(version.getModification()).isEqualTo(3); + } + + @Test + public void testCanCreateAVersionFromAString() throws Exception { + CouchDbVersion version = new CouchDbVersion("1.2.3"); + assertThat(version.getVersion()).isEqualTo(1); + assertThat(version.getRelease()).isEqualTo(2); + assertThat(version.getModification()).isEqualTo(3); + } + + @Test + public void testInvalidVersionStringThrowsParsingError() throws Exception { + + String invalidVersion = "1.2..3"; + + CouchdbException ex = catchThrowableOfType( ()->{ new CouchDbVersion(invalidVersion); }, + CouchdbException.class ); + assertThat(ex).hasMessageContaining("GAL6010E: Invalid CouchDB server version format detected. The CouchDB version '" + invalidVersion + "'"); + } + + @Test + public void testInvalidLeadingDotVersionStringThrowsParsingError() throws Exception { + + String invalidVersion = ".1.2.3"; + + CouchdbException ex = catchThrowableOfType( ()->{ new CouchDbVersion(invalidVersion); }, + CouchdbException.class ); + assertThat(ex).hasMessageContaining("GAL6010E: Invalid CouchDB server version format detected. The CouchDB version '" + invalidVersion + "'"); + } + + + @Test + public void testCanICompareTheSameThingIsEqualToItself() throws Exception { + CouchDbVersion version = new CouchDbVersion("1.2.3"); + assertThat(version.equals(version)).as("Could not compare the version with itself.").isTrue(); + } + + @Test + public void testHashCodeOfTwoSameVersionsIsTheSame() throws Exception { + CouchDbVersion version1 = new CouchDbVersion("1.2.3"); + CouchDbVersion version2 = new CouchDbVersion("1.2.3"); + assertThat(version1.hashCode()).isEqualTo(version2.hashCode()); + } + + @Test + public void testTwoSameVersionsDifferentObjectsAreTheSame() throws Exception { + CouchDbVersion version1 = new CouchDbVersion("1.2.3"); + CouchDbVersion version2 = new CouchDbVersion("1.2.3"); + assertThat(version1).isEqualTo(version2); + } + + @Test + public void testAddingTwoSameVersionsDifferentObjectsIntoASetResultInOneObjectInTheSet() throws Exception { + CouchDbVersion version1 = new CouchDbVersion("1.2.3"); + CouchDbVersion version2 = new CouchDbVersion("1.2.3"); + Set mySet = new HashSet<>(); + mySet.add(version1); + mySet.add(version2); + + assertThat(mySet).hasSize(1); + } + + @Test + public void testTwoDifferentVersionsAreNotComparable() throws Exception { + CouchDbVersion version1 = new CouchDbVersion("1.2.3"); + CouchDbVersion version2 = new CouchDbVersion("1.2.4"); + + int result = version1.compareTo(version2); + + assertThat(result).isNotEqualTo(0); + assertThat(result).isEqualTo(-1); + } + + + public static class CouchDbVersionComparator implements Comparator { + @Override + public int compare(CouchDbVersion v1, CouchDbVersion v2) { + return v1.compareTo(v2); + } + } + + @Test + public void testToVersionsGetsSortedInCorrectOrderSmallestFirst() throws Exception { + CouchDbVersion smallerVersion = new CouchDbVersion("1.2.3"); + CouchDbVersion biggerVersion = new CouchDbVersion("1.2.4"); + List versions = new ArrayList<>(); + versions.addAll( List.of(smallerVersion, biggerVersion )); + + CouchDbVersionComparator comparator = new CouchDbVersionComparator(); + Collections.sort(versions, comparator); + + assertThat(versions.get(0)).isEqualTo(smallerVersion); + assertThat(versions.get(1)).isEqualTo(biggerVersion); + } + + @Test + public void testToVersionsGetsSortedInCorrectOrderSmallestSecond() throws Exception { + CouchDbVersion smallerVersion = new CouchDbVersion("1.2.3"); + CouchDbVersion biggerVersion = new CouchDbVersion("1.2.4"); + List versions = new ArrayList<>(); + versions.addAll( List.of(biggerVersion, smallerVersion )); + + CouchDbVersionComparator comparator = new CouchDbVersionComparator(); + Collections.sort(versions, comparator); + + assertThat(versions.get(0)).isEqualTo(smallerVersion); + assertThat(versions.get(1)).isEqualTo(biggerVersion); + } + + @Test + public void testCanCompareVersionsDirectly() throws Exception{ + CouchDbVersion smallerVersion = new CouchDbVersion("1.2.3"); + CouchDbVersion biggerVersion = new CouchDbVersion("1.2.4"); + assertThat( smallerVersion).isLessThan(biggerVersion); + assertThat( biggerVersion).isGreaterThan(smallerVersion); + } + + @Test + public void testCanCompareVersionsWhereVersionDiffers() throws Exception{ + CouchDbVersion smallerVersion = new CouchDbVersion("1.2.3"); + CouchDbVersion biggerVersion = new CouchDbVersion("2.2.3"); + assertThat( smallerVersion).isLessThan(biggerVersion); + assertThat( biggerVersion).isGreaterThan(smallerVersion); + } + + @Test + public void testCanCompareVersionsWhereReleaseDiffers() throws Exception{ + CouchDbVersion smallerVersion = new CouchDbVersion("1.2.3"); + CouchDbVersion biggerVersion = new CouchDbVersion("1.3.3"); + assertThat( smallerVersion).isLessThan(biggerVersion); + assertThat( biggerVersion).isGreaterThan(smallerVersion); + } + + @Test + public void testToStringOfVersionIsCorrect() throws Exception { + CouchDbVersion version = new CouchDbVersion("1.2.3"); + assertThat(version.toString()).isEqualTo("1.2.3"); + + CouchDbVersion version2 = new CouchDbVersion(1,2,3); + assertThat(version2.toString()).isEqualTo("1.2.3"); + } + + @Test + public void testToStringMethodGetsCalledImplicitly() throws Exception { + CouchDbVersion version = new CouchDbVersion(1,2,3); + String s = "something "+version; + assertThat(s).isEqualTo("something 1.2.3"); + } +} \ No newline at end of file diff --git a/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessorTest.java b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessorTest.java new file mode 100644 index 00000000..44f2a7c6 --- /dev/null +++ b/galasa-extensions-parent/dev.galasa.extensions.common/src/test/java/dev/galasa/extensions/common/couchdb/RetryableCouchdbUpdateOperationProcessorTest.java @@ -0,0 +1,143 @@ +package dev.galasa.extensions.common.couchdb; + +import java.time.Instant; + +import org.junit.Test; + +import dev.galasa.extensions.common.couchdb.RetryableCouchdbUpdateOperationProcessor.BackoffTimeCalculator; +import dev.galasa.extensions.common.couchdb.RetryableCouchdbUpdateOperationProcessor.RetryableCouchdbUpdateOperation; +import dev.galasa.extensions.mocks.MockLogFactory; +import dev.galasa.extensions.mocks.MockTimeService; + +import static org.assertj.core.api.Assertions.*; + +public class RetryableCouchdbUpdateOperationProcessorTest { + + + @Test + public void testSuccessfulUpdateDoesNotThrowException() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH); + MockLogFactory mockLogFactory = new MockLogFactory(); + + int attemptsBeforeGivingUp = 10; + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() {}; + + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + + RetryableCouchdbUpdateOperation operationThatPasses = new RetryableCouchdbUpdateOperation() { + @Override + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException { + // Simulate successful update + } + }; + + // When... + processor.retryCouchDbUpdateOperation(operationThatPasses, attemptsBeforeGivingUp, backoffTimeCalculator); + + // Then... + // No errors should have been thrown + assertThat(mockTimeService.now()).as("time passed, procesor waited when it should not have done so.").isEqualTo(Instant.EPOCH); + assertThat(mockLogFactory.toString()).as("retry processor logged something, when nothing was expected if the retry operation passes first time.").isBlank(); + } + + @Test + public void testRetriesUntilItGivesUp() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH); + MockLogFactory mockLogFactory = new MockLogFactory(); + int attemptsBeforeGivingUp = 10; + + // A backoff of 1ms each time, so there is no random element in a unit test, and we can compare the time delayed later. + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() { + public long getBackoffDelayMillis() { + return 1; + } + }; + + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + + RetryableCouchdbUpdateOperation operationThatFails = new RetryableCouchdbUpdateOperation() { + @Override + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException { + throw new CouchdbClashingUpdateException("simulating constant failures"); + } + }; + + // When... + CouchdbException thrown = catchThrowableOfType(() -> { + processor.retryCouchDbUpdateOperation(operationThatFails, attemptsBeforeGivingUp, backoffTimeCalculator); + }, CouchdbException.class); + + // Then + assertThat(thrown).isNotNull(); + assertThat(thrown.getMessage()).contains("Couchdb operation failed after 10 attempts"); + + // We expect 10 backoff attempts, so the time would have advanced by 10 times the backoff time, which is a constant in this test of 1ms + assertThat(mockTimeService.now()).as("time passed, procesor waited when it should not have done so.").isEqualTo(Instant.EPOCH.plusMillis(attemptsBeforeGivingUp)); + + assertThat(mockLogFactory.toString()).as("retry processor didn't log what we expected. ").contains("Couchdb operation failed after 10 attempts","due to conflicts."); + } + + @Test + public void testOperationThatPassesAfterFailureDoesNotThrowError() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH); + MockLogFactory mockLogFactory = new MockLogFactory(); + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + int attemptsBeforeGivingUp = 10; + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() { + public long getBackoffDelayMillis() { + return 1; + } + }; + + RetryableCouchdbUpdateOperation operationThatPassesAfterFailure = new RetryableCouchdbUpdateOperation() { + private boolean hasTriedUpdating = false; + + @Override + public void tryToUpdateCouchDb() throws CouchdbException, CouchdbClashingUpdateException { + if (!hasTriedUpdating) { + hasTriedUpdating = true; + throw new CouchdbClashingUpdateException("simulating constant failures"); + } + } + }; + + // When... + processor.retryCouchDbUpdateOperation(operationThatPassesAfterFailure, attemptsBeforeGivingUp, backoffTimeCalculator); + + // Then + // No errors should have been thrown + } + + @Test + public void testDefaultBackoffTimeCalculatorGivesNumbersWithinExpectedRange() throws Exception { + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator(){}; + for(int i=0; i<100; i++) { + long millis = backoffTimeCalculator.getBackoffDelayMillis(); + assertThat(millis).isGreaterThan(1000L); + assertThat(millis).isLessThanOrEqualTo(4000L); + } + } + + @Test + public void testWaitForBackOffDelayLogsInterruptedException() throws Exception { + MockTimeService mockTimeService = new MockTimeService(Instant.EPOCH){ + @Override + public void sleepMillis(long millis) throws InterruptedException { + // Simulate InterruptedException + throw new InterruptedException(); + } + }; + MockLogFactory mockLogFactory = new MockLogFactory(); + + // A backoff of 1ms each time, so there is no random element in a unit test, and we can compare the time delayed later. + BackoffTimeCalculator backoffTimeCalculator = new BackoffTimeCalculator() {}; + + RetryableCouchdbUpdateOperationProcessor processor = new RetryableCouchdbUpdateOperationProcessor(mockTimeService, mockLogFactory); + + // When... + processor.waitForBackoffDelay(mockTimeService, backoffTimeCalculator); + + // Then + assertThat(mockLogFactory.toString()).as("retry processor didn't log what we expected. ").contains("Interrupted from waiting during a back-off delay. Ignoring this, but cutting our wait short."); + } +} diff --git a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/BaseHttpInteraction.java b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/BaseHttpInteraction.java index c80a42c3..3648486c 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/BaseHttpInteraction.java +++ b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/BaseHttpInteraction.java @@ -19,27 +19,26 @@ public abstract class BaseHttpInteraction implements HttpInteraction { private GalasaGson gson = new GalasaGson(); private String expectedBaseUri ; - private String returnedDocument; private String responsePayload = ""; private int responseStatusCode = HttpStatus.SC_OK; - public BaseHttpInteraction(String expectedBaseUri, String returnedDocument ) { - this.expectedBaseUri = expectedBaseUri; - this.returnedDocument = returnedDocument; + public BaseHttpInteraction(String expectedBaseUri, Object responsePayload) { + this(expectedBaseUri, responsePayload, HttpStatus.SC_OK); } - public BaseHttpInteraction(String expectedBaseUri, int responseStatusCode) { + public BaseHttpInteraction(String expectedBaseUri, Object responsePayload, int responseStatusCode) { this.expectedBaseUri = expectedBaseUri; this.responseStatusCode = responseStatusCode; + setResponsePayload(responsePayload); } - public String getExpectedBaseUri() { - return this.expectedBaseUri; + public BaseHttpInteraction(String expectedBaseUri, int responseStatusCode) { + this(expectedBaseUri, null, responseStatusCode); } - public String getReturnedDocument() { - return this.returnedDocument; + public String getExpectedBaseUri() { + return this.expectedBaseUri; } public String getExpectedHttpContentType() { diff --git a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockCloseableHttpClient.java b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockCloseableHttpClient.java index b3dd3d7e..8a8cff28 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockCloseableHttpClient.java +++ b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockCloseableHttpClient.java @@ -35,7 +35,7 @@ private void nextInteraction() { if (interactionWalker.hasNext()) { this.currentInteraction = interactionWalker.next(); } else { - this.currentInteraction = null ; + this.currentInteraction = null ; } } diff --git a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java index 2171709f..877cf151 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java +++ b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/MockTimeService.java @@ -22,13 +22,14 @@ public Instant now() { return currentTime; } - public void setCurrentTime(Instant currentTime) { - this.currentTime = currentTime; - } - @Override public void sleepMillis(long millisToSleep) throws InterruptedException { // Pretend we are sleeping, so the current time advances. - setCurrentTime(currentTime.plusMillis(millisToSleep)); + this.currentTime = this.currentTime.plusMillis(millisToSleep); } + + public void setCurrentTime(Instant currentTime) { + this.currentTime = currentTime; + } + } diff --git a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/couchdb/MockCouchdbValidator.java b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/couchdb/MockCouchdbValidator.java index d853c128..097446e1 100644 --- a/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/couchdb/MockCouchdbValidator.java +++ b/galasa-extensions-parent/dev.galasa.extensions.mocks/src/main/java/dev/galasa/extensions/mocks/couchdb/MockCouchdbValidator.java @@ -11,6 +11,7 @@ import dev.galasa.extensions.common.couchdb.CouchdbException; import dev.galasa.extensions.common.couchdb.CouchdbValidator; +import dev.galasa.framework.spi.utils.ITimeService; import dev.galasa.extensions.common.api.HttpRequestFactory; public class MockCouchdbValidator implements CouchdbValidator { @@ -22,7 +23,7 @@ public void setThrowException(boolean throwException) { } @Override - public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory requestFactory) throws CouchdbException { + public void checkCouchdbDatabaseIsValid(URI couchdbUri, CloseableHttpClient httpClient, HttpRequestFactory requestFactory, ITimeService timeService) throws CouchdbException { if (throwException) { throw new CouchdbException("simulating a validation failure!"); } diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasRegistration.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasRegistration.java index e4e4b453..1c2bcbcb 100644 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasRegistration.java +++ b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasRegistration.java @@ -6,7 +6,6 @@ package dev.galasa.ras.couchdb.internal; import java.net.URI; -import java.net.URISyntaxException; import java.util.List; import javax.validation.constraints.NotNull; diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasStore.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasStore.java index 6193bbbb..9d90b3d4 100644 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasStore.java +++ b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbRasStore.java @@ -32,10 +32,13 @@ import dev.galasa.framework.spi.ras.ResultArchiveStoreFileStore; import dev.galasa.framework.spi.teststructure.TestStructure; import dev.galasa.framework.spi.utils.GalasaGson; +import dev.galasa.framework.spi.utils.ITimeService; +import dev.galasa.framework.spi.utils.SystemTimeService; import dev.galasa.extensions.common.api.HttpClientFactory; import dev.galasa.extensions.common.api.LogFactory; import dev.galasa.extensions.common.couchdb.CouchdbException; import dev.galasa.extensions.common.couchdb.CouchdbStore; +import dev.galasa.extensions.common.couchdb.CouchdbValidator; import dev.galasa.extensions.common.couchdb.pojos.PutPostResponse; import dev.galasa.extensions.common.impl.HttpClientFactoryImpl; import dev.galasa.extensions.common.impl.HttpRequestFactoryImpl; @@ -76,6 +79,7 @@ public class CouchdbRasStore extends CouchdbStore implements IResultArchiveStore private String artifactDocumentRev; private TestStructure lastTestStructure; + private ITimeService timeService ; private LogFactory logFactory; @@ -93,14 +97,15 @@ public CouchdbRasStore(IFramework framework, URI rasUri) throws CouchdbException // Note: We use logFactory here so we can propogate it downwards during unit testing. public CouchdbRasStore(IFramework framework, URI rasUri, HttpClientFactory httpFactory , CouchdbValidator validator, LogFactory logFactory, HttpRequestFactory requestFactory - ) throws CouchdbRasException, CouchdbException { + ) throws CouchdbException { super(rasUri, requestFactory, httpFactory); this.logFactory = logFactory; this.logger = logFactory.getLog(getClass()); this.framework = framework; + this.timeService = new SystemTimeService(); // *** Validate the connection to the server and it's version - validator.checkCouchdbDatabaseIsValid(this.storeUri,this.httpClient, this.httpRequestFactory); + validator.checkCouchdbDatabaseIsValid(this.storeUri,this.httpClient, this.httpRequestFactory, timeService); this.run = this.framework.getTestRun(); @@ -121,7 +126,6 @@ public CouchdbRasStore(IFramework framework, URI rasUri, HttpClientFactory httpF this.provider = new CouchdbRasFileSystemProvider(fileStore, this, this.logFactory); } - // Protected so that we can create artifact documents from elsewhere. protected void createArtifactDocument() throws CouchdbException { Artifacts artifacts = new Artifacts(); diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java index 1ad4d522..7084dabc 100644 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java +++ b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/main/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImpl.java @@ -15,8 +15,6 @@ import java.net.URI; import java.util.Random; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -33,7 +31,11 @@ import dev.galasa.extensions.common.couchdb.pojos.Welcome; import dev.galasa.extensions.common.api.HttpRequestFactory; +import dev.galasa.extensions.common.couchdb.CouchDbVersion; +import dev.galasa.extensions.common.couchdb.CouchdbException; +import dev.galasa.extensions.common.couchdb.CouchdbValidator; import dev.galasa.framework.spi.utils.GalasaGson; +import dev.galasa.framework.spi.utils.ITimeService; public class CouchdbValidatorImpl implements CouchdbValidator { @@ -41,7 +43,16 @@ public class CouchdbValidatorImpl implements CouchdbValidator { private final Log logger = LogFactory.getLog(getClass()); private HttpRequestFactory requestFactory; - public void checkCouchdbDatabaseIsValid( URI rasUri, CloseableHttpClient httpClient , HttpRequestFactory httpRequestFactory) throws CouchdbRasException { + private static final CouchDbVersion minCouchDbVersion = new CouchDbVersion(3,3,3); + + @Override + public void checkCouchdbDatabaseIsValid( + URI rasUri, + CloseableHttpClient httpClient , + HttpRequestFactory httpRequestFactory, + ITimeService timeService + ) throws CouchdbException { + this.requestFactory = httpRequestFactory; HttpGet httpGet = requestFactory.getHttpGetRequest(rasUri.toString()); @@ -59,33 +70,33 @@ public void checkCouchdbDatabaseIsValid( URI rasUri, CloseableHttpClient httpCli throw new CouchdbRasException("Validation failed to CouchDB server - invalid json response"); } - checkVersion(welcome.version, 3, 3, 3); - checkDatabasePresent(httpClient, rasUri, 1, "galasa_run"); - checkDatabasePresent(httpClient, rasUri, 1, "galasa_log"); - checkDatabasePresent(httpClient, rasUri, 1, "galasa_artifacts"); + checkVersion(welcome.version, minCouchDbVersion); + checkDatabasePresent(httpClient, rasUri, 1, "galasa_run", timeService); + checkDatabasePresent(httpClient, rasUri, 1, "galasa_log", timeService); + checkDatabasePresent(httpClient, rasUri, 1, "galasa_artifacts", timeService); - checkRunDesignDocument(httpClient, rasUri,1); + checkRunDesignDocument(httpClient, rasUri,1, timeService); - checkIndex(httpClient, rasUri, 1, "galasa_run", "runName"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "requestor"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "queued"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "startTime"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "endTime"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "testName"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "bundle"); - checkIndex(httpClient, rasUri, 1, "galasa_run", "result"); + checkIndex(httpClient, rasUri, 1, "galasa_run", "runName",timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "requestor",timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "queued",timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "startTime", timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "endTime", timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "testName", timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "bundle", timeService); + checkIndex(httpClient, rasUri, 1, "galasa_run", "result", timeService); logger.debug("RAS CouchDB at " + rasUri.toString() + " validated"); - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Validation failed", e); + throw new CouchdbException("Validation failed "+ e); } } - private void checkDatabasePresent( CloseableHttpClient httpClient, URI rasUri, int attempts, String dbName) throws CouchdbRasException { + private void checkDatabasePresent( CloseableHttpClient httpClient, URI rasUri, int attempts, String dbName, ITimeService timeService) throws CouchdbException { HttpHead httpHead = requestFactory.getHttpHeadRequest(rasUri + "/" + dbName); try (CloseableHttpResponse response = httpClient.execute(httpHead)) { @@ -95,13 +106,13 @@ private void checkDatabasePresent( CloseableHttpClient httpClient, URI rasUri, i return; } if (statusLine.getStatusCode() != HttpStatus.SC_NOT_FOUND) { - throw new CouchdbRasException( + throw new CouchdbException( "Validation failed of database " + dbName + " - " + statusLine.toString()); } - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Validation failed", e); + throw new CouchdbException("Validation failed", e); } logger.info("CouchDB database " + dbName + " is missing, creating"); @@ -114,29 +125,29 @@ private void checkDatabasePresent( CloseableHttpClient httpClient, URI rasUri, i // Someone possibly updated attempts++; if (attempts > 10) { - throw new CouchdbRasException( + throw new CouchdbException( "Create Database " + dbName + " failed on CouchDB server due to conflicts, attempted 10 times"); } - Thread.sleep(1000 + new Random().nextInt(3000)); - checkDatabasePresent(httpClient, rasUri, attempts, dbName); + timeService.sleepMillis(1000 + new Random().nextInt(3000)); + checkDatabasePresent(httpClient, rasUri, attempts, dbName, timeService); return; } if (statusLine.getStatusCode() != HttpStatus.SC_CREATED) { EntityUtils.consumeQuietly(response.getEntity()); - throw new CouchdbRasException( + throw new CouchdbException( "Create Database " + dbName + " failed on CouchDB server - " + statusLine.toString()); } EntityUtils.consumeQuietly(response.getEntity()); - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Create database " + dbName + " failed", e); + throw new CouchdbException("Create database " + dbName + " failed", e); } } - private void checkRunDesignDocument( CloseableHttpClient httpClient , URI rasUri , int attempts) throws CouchdbRasException { + private void checkRunDesignDocument( CloseableHttpClient httpClient , URI rasUri , int attempts, ITimeService timeService) throws CouchdbException { HttpGet httpGet = requestFactory.getHttpGetRequest(rasUri + "/galasa_run/_design/docs"); String docJson = null; @@ -145,16 +156,16 @@ private void checkRunDesignDocument( CloseableHttpClient httpClient , URI rasUri docJson = EntityUtils.toString(response.getEntity()); if (statusLine.getStatusCode() != HttpStatus.SC_OK && statusLine.getStatusCode() != HttpStatus.SC_NOT_FOUND) { - throw new CouchdbRasException( + throw new CouchdbException( "Validation failed of database galasa_run designdocument - " + statusLine.toString()); } if (statusLine.getStatusCode() == HttpStatus.SC_NOT_FOUND) { docJson = "{}"; } - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Validation failed", e); + throw new CouchdbException("Validation failed", e); } boolean updated = false; @@ -236,25 +247,25 @@ private void checkRunDesignDocument( CloseableHttpClient httpClient , URI rasUri // Someone possibly updated attempts++; if (attempts > 10) { - throw new CouchdbRasException( + throw new CouchdbException( "Update of galasa_run design document failed on CouchDB server due to conflicts, attempted 10 times"); } - Thread.sleep(1000 + new Random().nextInt(3000)); - checkRunDesignDocument(httpClient, rasUri, attempts); + timeService.sleepMillis(1000 + new Random().nextInt(3000)); + checkRunDesignDocument(httpClient, rasUri, attempts, timeService); return; } if (statusCode != HttpStatus.SC_CREATED) { EntityUtils.consumeQuietly(response.getEntity()); - throw new CouchdbRasException( + throw new CouchdbException( "Update of galasa_run design document failed on CouchDB server - " + statusLine.toString()); } EntityUtils.consumeQuietly(response.getEntity()); - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Update of galasa_run design document faile", e); + throw new CouchdbException("Update of galasa_run design document faile", e); } } } @@ -297,60 +308,21 @@ private boolean checkViewString(JsonObject view, String field, String value) { return false; } - private void checkVersion(String version, int minVersion, int minRelease, int minModification) - throws CouchdbRasException { - String minVRM = minVersion + "." + minRelease + "." + minModification; - - Pattern vrm = Pattern.compile("^(\\d+)\\.(\\d+)\\.(\\d+)$"); - Matcher m = vrm.matcher(version); - - if (!m.find()) { - throw new CouchdbRasException("Invalid CouchDB version " + version); - } - - int actualVersion = 0; - int actualRelease = 0; - int actualModification = 0; - - try { - actualVersion = Integer.parseInt(m.group(1)); - actualRelease = Integer.parseInt(m.group(2)); - actualModification = Integer.parseInt(m.group(3)); - } catch (NumberFormatException e) { - throw new CouchdbRasException("Unable to determine CouchDB version " + version, e); - } - - if (actualVersion > minVersion) { - return; - } + private void checkVersion(String version, CouchDbVersion minVersion) + throws CouchdbException { - if (actualVersion < minVersion) { - throw new CouchdbRasException("CouchDB version " + version + " is below minimum " + minVRM); - } + CouchDbVersion actualCouchVersion = new CouchDbVersion(version); - if (actualRelease > minRelease) { - return; - } - - if (actualRelease < minRelease) { - throw new CouchdbRasException("CouchDB version " + version + " is below minimum " + minVRM); - } - - if (actualModification > minModification) { - return; - } - - if (actualModification < minModification) { - throw new CouchdbRasException("CouchDB version " + version + " is below minimum " + minVRM); + if ( actualCouchVersion.compareTo(minVersion) < 0) { + throw new CouchdbException("CouchDB version " + version + " is below minimum " + minVersion); } return; - } - private void checkIndex(CloseableHttpClient httpClient, URI rasUri , int attempts, String dbName, String field) throws CouchdbRasException { + private void checkIndex(CloseableHttpClient httpClient, URI rasUri , int attempts, String dbName, String field, ITimeService timeService) throws CouchdbException { HttpGet httpGet = requestFactory.getHttpGetRequest(rasUri + "/galasa_run/_index"); String idxJson = null; @@ -359,15 +331,15 @@ private void checkIndex(CloseableHttpClient httpClient, URI rasUri , int attempt idxJson = EntityUtils.toString(response.getEntity()); if (statusLine.getStatusCode() != HttpStatus.SC_OK && statusLine.getStatusCode() != HttpStatus.SC_NOT_FOUND) { - throw new CouchdbRasException("Validation failed of database indexes - " + statusLine.toString()); + throw new CouchdbException("Validation failed of database indexes - " + statusLine.toString()); } if (statusLine.getStatusCode() == HttpStatus.SC_NOT_FOUND) { idxJson = "{}"; } - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Validation failed", e); + throw new CouchdbException("Validation failed", e); } JsonObject idx = gson.fromJson(idxJson, JsonObject.class); @@ -424,23 +396,23 @@ private void checkIndex(CloseableHttpClient httpClient, URI rasUri , int attempt // Someone possibly updated attempts++; if (attempts > 10) { - throw new CouchdbRasException( + throw new CouchdbException( "Update of galasa_run index failed on CouchDB server due to conflicts, attempted 10 times"); } - Thread.sleep(1000 + new Random().nextInt(3000)); - checkIndex(httpClient, rasUri, attempts, dbName, field); + timeService.sleepMillis(1000 + new Random().nextInt(3000)); + checkIndex(httpClient, rasUri, attempts, dbName, field, timeService); return; } if (statusLine.getStatusCode() != HttpStatus.SC_OK) { - throw new CouchdbRasException( + throw new CouchdbException( "Update of galasa_run index failed on CouchDB server - " + statusLine.toString()); } - } catch (CouchdbRasException e) { + } catch (CouchdbException e) { throw e; } catch (Exception e) { - throw new CouchdbRasException("Update of galasa_run index faile", e); + throw new CouchdbException("Update of galasa_run index faile", e); } } diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImplTest.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImplTest.java index 56733b3a..f964adcc 100644 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImplTest.java +++ b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/CouchdbValidatorImplTest.java @@ -13,13 +13,16 @@ import org.junit.*; import org.junit.rules.TestName; +import java.time.Instant; import dev.galasa.framework.spi.utils.GalasaGson; import dev.galasa.extensions.common.couchdb.pojos.Welcome; import dev.galasa.extensions.common.impl.HttpRequestFactoryImpl; +import dev.galasa.extensions.common.couchdb.CouchdbException; +import dev.galasa.extensions.common.couchdb.CouchdbValidator; import dev.galasa.extensions.common.api.HttpRequestFactory; import dev.galasa.extensions.mocks.*; import dev.galasa.ras.couchdb.internal.mocks.CouchdbTestFixtures; -import dev.galasa.ras.couchdb.internal.mocks.CouchdbTestFixtures.BaseHttpInteraction;; +import dev.galasa.ras.couchdb.internal.mocks.CouchdbTestFixtures.BaseHttpInteraction; public class CouchdbValidatorImplTest { @@ -270,13 +273,14 @@ public void TestRasStoreCreateBlowsUpIfCouchDBDoesntReturnWelcomeString() throws CouchdbValidator validatorUnderTest = new CouchdbValidatorImpl(); HttpRequestFactory requestFactory = new HttpRequestFactoryImpl("Basic", "checkisvalid"); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); // When.. - Throwable thrown = catchThrowable(()-> validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory)); + Throwable thrown = catchThrowable(()-> validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory, mockTimeService)); // Then.. assertThat(thrown).isNotNull(); - assertThat(thrown).as("exception caught is of type "+thrown.getClass().toString()).isInstanceOf(CouchdbRasException.class); + assertThat(thrown).as("exception caught is of type "+thrown.getClass().toString()).isInstanceOf(CouchdbException.class); } @Test @@ -319,9 +323,10 @@ public void TestRasStoreCreatesDBIfCouchDBReturnsWelcomeString() throws Exceptio CouchdbValidator validatorUnderTest = new CouchdbValidatorImpl(); HttpRequestFactory requestFactory = new HttpRequestFactoryImpl("Basic", "checkisvalid"); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); // When.. - Throwable thrown = catchThrowable(()->validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory)); + Throwable thrown = catchThrowable(()->validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory, mockTimeService)); assertThat(thrown).isNull(); } @@ -378,9 +383,10 @@ public MockCloseableHttpResponse getResponse() { CouchdbValidator validatorUnderTest = new CouchdbValidatorImpl(); HttpRequestFactory requestFactory = new HttpRequestFactoryImpl("Basic", "checkisvalid"); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); // When.. - Throwable thrown = catchThrowable(()->validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory)); + Throwable thrown = catchThrowable(()->validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory, mockTimeService)); assertThat(thrown).isNotNull(); assertThat(thrown.getMessage()).contains("Validation failed of database galasa_run"); @@ -446,9 +452,10 @@ public MockCloseableHttpResponse getResponse() { CouchdbValidator validatorUnderTest = new CouchdbValidatorImpl(); HttpRequestFactory requestFactory = new HttpRequestFactoryImpl("Basic", "checkisvalid"); + MockTimeService mockTimeService = new MockTimeService(Instant.now()); // When.. - Throwable thrown = catchThrowable(()->validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory)); + Throwable thrown = catchThrowable(()->validatorUnderTest.checkCouchdbDatabaseIsValid( CouchdbTestFixtures.rasUri , mockHttpClient, requestFactory,mockTimeService)); assertThat(thrown).isNotNull(); assertThat(thrown.getMessage()).contains("Create Database galasa_run failed on CouchDB server due to conflicts, attempted 10 times"); diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/CouchdbTestFixtures.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/CouchdbTestFixtures.java index ff5fff8f..8c8a72d1 100644 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/CouchdbTestFixtures.java +++ b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/CouchdbTestFixtures.java @@ -35,6 +35,7 @@ import dev.galasa.framework.spi.IRun; import dev.galasa.framework.spi.utils.GalasaGson; import dev.galasa.ras.couchdb.internal.CouchdbRasStore; +import dev.galasa.extensions.mocks.couchdb.MockCouchdbValidator; public class CouchdbTestFixtures { diff --git a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/MockCouchdbValidator.java b/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/MockCouchdbValidator.java deleted file mode 100644 index af9a1eba..00000000 --- a/galasa-extensions-parent/dev.galasa.ras.couchdb/src/test/java/dev/galasa/ras/couchdb/internal/mocks/MockCouchdbValidator.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright contributors to the Galasa project - * - * SPDX-License-Identifier: EPL-2.0 - */ -package dev.galasa.ras.couchdb.internal.mocks; - -import java.net.URI; - -import org.apache.http.impl.client.CloseableHttpClient; - -import dev.galasa.extensions.common.api.HttpRequestFactory; -import dev.galasa.ras.couchdb.internal.CouchdbRasException; -import dev.galasa.ras.couchdb.internal.CouchdbValidator; - -public class MockCouchdbValidator implements CouchdbValidator { - - @Override - public void checkCouchdbDatabaseIsValid(URI rasUri, CloseableHttpClient httpClient, HttpRequestFactory requestFactory) throws CouchdbRasException { - // Do nothing. - } - -}