diff --git a/.github/workflows/branch-cicd.yaml.off b/.github/workflows/branch-cicd.yaml.off index 34cea1e7..ca66d9cd 100644 --- a/.github/workflows/branch-cicd.yaml.off +++ b/.github/workflows/branch-cicd.yaml.off @@ -31,7 +31,7 @@ jobs: strategy: matrix: - java-version: [17] + java-version: [21, 25] steps: - diff --git a/.github/workflows/legacy-branch-cicd.yaml b/.github/workflows/legacy-branch-cicd.yaml index 48b6d9a9..3db10e79 100644 --- a/.github/workflows/legacy-branch-cicd.yaml +++ b/.github/workflows/legacy-branch-cicd.yaml @@ -31,7 +31,7 @@ jobs: strategy: matrix: - java-version: [17] + java-version: [21, 25] steps: - diff --git a/.gitignore b/.gitignore index b5b2349b..b99c8390 100644 --- a/.gitignore +++ b/.gitignore @@ -81,4 +81,3 @@ target/ # macOS specific stuff .DS_Store ._* - diff --git a/common/.github/workflows/branch-cicd.yaml b/common/.github/workflows/branch-cicd.yaml index b3398822..dc764311 100644 --- a/common/.github/workflows/branch-cicd.yaml +++ b/common/.github/workflows/branch-cicd.yaml @@ -34,7 +34,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - java-version: [11,18] + java-version: [21, 25] steps: - diff --git a/common/.github/workflows/stable-cicd.yaml b/common/.github/workflows/stable-cicd.yaml index 9ea6f365..646a042c 100644 --- a/common/.github/workflows/stable-cicd.yaml +++ b/common/.github/workflows/stable-cicd.yaml @@ -72,7 +72,7 @@ jobs: uses: NASA-PDS/roundup-action@stable with: assembly: stable - packages: openjdk11-jdk + packages: openjdk21-jdk env: central_portal_username: ${{secrets.CENTRAL_REPOSITORY_USERNAME}} central_portal_token: ${{secrets.CENTRAL_REPOSITORY_TOKEN}} diff --git a/common/.github/workflows/unstable-cicd.yaml b/common/.github/workflows/unstable-cicd.yaml index 09b07ca2..fe883cc9 100644 --- a/common/.github/workflows/unstable-cicd.yaml +++ b/common/.github/workflows/unstable-cicd.yaml @@ -72,7 +72,7 @@ jobs: uses: NASA-PDS/roundup-action@stable with: assembly: unstable - packages: openjdk11-jdk + packages: openjdk21-jdk env: central_portal_username: ${{secrets.CENTRAL_REPOSITORY_USERNAME}} central_portal_token: ${{secrets.CENTRAL_REPOSITORY_TOKEN}} diff --git a/common/pom.xml b/common/pom.xml index 1ae1a3b4..d7732676 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -60,12 +60,6 @@ POSSIBILITY OF SUCH DAMAGE. - - 11 - 11 - UTF-8 - - diff --git a/harvest/.github/workflows/branch-cicd.yaml b/harvest/.github/workflows/branch-cicd.yaml index 09d5426f..95e9c9b8 100644 --- a/harvest/.github/workflows/branch-cicd.yaml +++ b/harvest/.github/workflows/branch-cicd.yaml @@ -36,7 +36,7 @@ jobs: # 👉 For the `harvest` repo, we only need to use JDK 11 for some reason. (In the branch # workflow pre-template-rollout-summer-2021, there was no matrix strategy and JDK 11 # was "specifically specified".) - java-version: [17, 21] + java-version: [21, 25] steps: - diff --git a/harvest/.github/workflows/stable-cicd.yaml b/harvest/.github/workflows/stable-cicd.yaml index 59887d31..773a12ac 100644 --- a/harvest/.github/workflows/stable-cicd.yaml +++ b/harvest/.github/workflows/stable-cicd.yaml @@ -72,7 +72,7 @@ jobs: uses: NASA-PDS/roundup-action@stable with: assembly: stable - packages: openjdk17-jdk + packages: openjdk21-jdk maven-build-phases: install maven-doc-phases: clean,site,site:stage maven-stable-artifact-phases: clean,site,site:stage,deploy diff --git a/harvest/.github/workflows/unstable-cicd.yaml b/harvest/.github/workflows/unstable-cicd.yaml index d1f074b8..c2b8b67d 100644 --- a/harvest/.github/workflows/unstable-cicd.yaml +++ b/harvest/.github/workflows/unstable-cicd.yaml @@ -72,7 +72,7 @@ jobs: name: 🤠 Roundup uses: NASA-PDS/roundup-action@stable with: - packages: openjdk17-jdk + packages: openjdk21-jdk assembly: unstable env: central_portal_username: ${{secrets.CENTRAL_REPOSITORY_USERNAME}} diff --git a/harvest/pom.xml b/harvest/pom.xml index 3691887a..ea456271 100644 --- a/harvest/pom.xml +++ b/harvest/pom.xml @@ -65,12 +65,6 @@ scm:git:ssh://git@github.com/NASA-PDS/harvest.git - - 11 - 11 - UTF-8 - - diff --git a/manager/.github/workflows/branch-cicd.yaml b/manager/.github/workflows/branch-cicd.yaml index bf751b53..e7bea5de 100644 --- a/manager/.github/workflows/branch-cicd.yaml +++ b/manager/.github/workflows/branch-cicd.yaml @@ -33,7 +33,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - java-version: [17] + java-version: [21, 25] steps: - diff --git a/manager/.github/workflows/stable-cicd.yaml b/manager/.github/workflows/stable-cicd.yaml index e13b6ca3..c9a8d6f6 100644 --- a/manager/.github/workflows/stable-cicd.yaml +++ b/manager/.github/workflows/stable-cicd.yaml @@ -72,7 +72,7 @@ jobs: uses: NASA-PDS/roundup-action@stable with: assembly: stable - packages: openjdk17-jdk + packages: openjdk21-jdk maven-build-phases: install maven-doc-phases: clean,site,site:stage maven-stable-artifact-phases: clean,site,site:stage,deploy diff --git a/manager/.github/workflows/unstable-cicd.yaml b/manager/.github/workflows/unstable-cicd.yaml index e1097bc5..ec548f8f 100644 --- a/manager/.github/workflows/unstable-cicd.yaml +++ b/manager/.github/workflows/unstable-cicd.yaml @@ -61,7 +61,7 @@ jobs: uses: NASA-PDS/roundup-action@stable with: assembly: unstable - packages: openjdk17-jdk + packages: openjdk21-jdk env: central_portal_username: ${{secrets.CENTRAL_REPOSITORY_USERNAME}} central_portal_token: ${{secrets.CENTRAL_REPOSITORY_TOKEN}} diff --git a/manager/pom.xml b/manager/pom.xml index 7988c770..966d7b56 100644 --- a/manager/pom.xml +++ b/manager/pom.xml @@ -60,13 +60,6 @@ POSSIBILITY OF SUCH DAMAGE. - - 17 - 17 - 17 - UTF-8 - - diff --git a/pom.xml b/pom.xml index d8bb232c..b42f16c6 100644 --- a/pom.xml +++ b/pom.xml @@ -1,116 +1,120 @@ + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + --> + Go through this file line-by-line and replace the template values with your own. + --> - - 4.0.0 + + 4.0.0 - - - gov.nasa.pds - parent - 1.20.0 - + + gov.nasa.pds + parent + 1.20.0 + - - - - registry-loader - - - UTF-8 - + + + + 21 + 21 + 21 + + registry-loader + + UTF-8 + - - gov.nasa.pds - - registry-loader - - 1.4.0-SNAPSHOT - - pom + registry-loader + + 1.4.0-SNAPSHOT + + pom - ${project.groupId}:${project-name} - - Registry loader tools, from creation of the registry, to loading new products in it. - - - 2021 + ${project.groupId}:${project-name} + + Registry loader tools, from creation of the registry, to loading new products in it. + + + 2021 - + https://nasa-pds.github.io/registry-loader + + GitHub + https://github.com/NASA-PDS/registry-loader/issues + + + scm:git:https://github.com/NASA-PDS/registry-loader.git + scm:git:ssh://github.com/NASA-PDS/registry-loader.git https://nasa-pds.github.io/registry-loader - - GitHub - https://github.com/NASA-PDS/registry-loader/issues - - - scm:git:https://github.com/NASA-PDS/registry-loader.git - scm:git:ssh://github.com/NASA-PDS/registry-loader.git - https://nasa-pds.github.io/registry-loader - main - - - common - harvest - manager - + main + + + common + harvest + manager + testing + OSGeo @@ -118,47 +122,47 @@ Go through this file line-by-line and replace the template values with your own. https://repo.osgeo.org/repository/release/ - - - - - org.junit.jupiter - junit-jupiter - test - - + + + + org.junit.jupiter + junit-jupiter + test + + - - org.apache.commons - commons-numbers-primes - 1.0 - jar - compile - false - - - - org.slf4j - slf4j-nop - 2.0.18 - - - - + --> + + org.apache.commons + commons-numbers-primes + 1.0 + jar + compile + false + + - org.junit - junit-bom - 5.13.4 - pom - import + org.slf4j + slf4j-nop + 2.0.18 - + + + + org.junit + junit-bom + 5.13.4 + pom + import + + + - + org.sonatype.central @@ -171,191 +175,191 @@ Go through this file line-by-line and replace the template values with your own. published - - org.apache.maven.plugins - maven-site-plugin - 4.0.0-M13 - - + + org.apache.maven.plugins + maven-site-plugin + 4.0.0-M13 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + org.apache.maven.plugins + maven-release-plugin + 3.1.1 + + v@{project.version} + + + + org.apache.maven.plugins + maven-install-plugin + 3.0.0-M1 + + + default-install + install + + install + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + + + maven-failsafe-plugin + 3.0.0-M3 + + + + + + + release + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 + true + + central + true + published + 5400 + 7200 + + + + org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - + maven-source-plugin + 3.3.1 + + + make-source-jar + prepare-package + + jar-no-fork + test-jar-no-fork + + + true + + + + + org.apache.maven.plugins - maven-release-plugin - 3.1.1 + maven-javadoc-plugin + 3.3.0 - v@{project.version} + 1.8 + false - - - org.apache.maven.plugins - maven-install-plugin - 3.0.0-M1 - - default-install - install - - install - - + + attach-javadocs + + jar + + - - - org.apache.maven.plugins - maven-surefire-plugin - 3.2.5 - - false - - - - maven-failsafe-plugin - 3.0.0-M3 - - - - - - - release - - - - - org.sonatype.central - central-publishing-maven-plugin - 0.9.0 - true - - central - true - published - 5400 - 7200 - - - - - org.apache.maven.plugins - maven-source-plugin - 3.3.1 - - - make-source-jar - prepare-package - - jar-no-fork - test-jar-no-fork - - - true - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.3.0 - - 1.8 - false - - - - attach-javadocs - - jar - - - - - - org.apache.maven.plugins - maven-gpg-plugin - 3.0.1 - - - sign-artifacts - verify - - sign - - - - --pinentry-mode - loopback - - - - - - - - - - - - - - - org.apache.maven.plugins - maven-project-info-reports-plugin - 3.1.2 - - - - dependencies - issue-management - licenses - mailing-lists - team - summary - scm - - - - - - org.apache.maven.plugins - maven-changes-plugin - 2.12.1 - - - - changes-report - - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.3.0 + + + org.apache.maven.plugins + maven-gpg-plugin + 3.0.1 + + + sign-artifacts + verify + + sign + - 1.8 - false + + --pinentry-mode + loopback + - - - org.apache.maven.plugins - maven-linkcheck-plugin - 1.2 - + + + - - - - The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - - + + + + + + + + + org.apache.maven.plugins + maven-project-info-reports-plugin + 3.1.2 + + + + dependencies + issue-management + licenses + mailing-lists + team + summary + scm + + + + + + org.apache.maven.plugins + maven-changes-plugin + 2.12.1 + + + + changes-report + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.3.0 + + 1.8 + false + + + + org.apache.maven.plugins + maven-linkcheck-plugin + 1.2 + + + + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + - - Planetary Data System - https://pds.nasa.gov/ - + + Planetary Data System + https://pds.nasa.gov/ + diff --git a/testing/pom.xml b/testing/pom.xml new file mode 100644 index 00000000..bca2634c --- /dev/null +++ b/testing/pom.xml @@ -0,0 +1,166 @@ + + + + 4.0.0 + + + + gov.nasa.pds + registry-loader + 1.4.0-SNAPSHOT + + + gov.nasa.pds.registry-loader + registry-loader-testing + jar + + PDS Registry Loader Testing + + Gherkin based testing - both integrated and regression + + + + Jet Propulsion Laboratory, California Institute of Technology + + + + + The Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + + + io.cucumber + cucumber-bom + 7.34.6 + pom + import + + + io.javalin + javalin-bom + 7.2.2 + pom + import + + + + + + + com.fasterxml.jackson.core + jackson-databind + 2.22.1 + compile + + + io.cucumber + cucumber-java + test + + + io.cucumber + cucumber-junit-platform-engine + test + + + org.junit.platform + junit-platform-suite-api + test + + + io.javalin + javalin + + + io.javalin.community.ssl + javalin-ssl + + + jakarta.annotation + jakarta.annotation-api + 3.0.0 + + + org.slf4j + slf4j-simple + 2.0.16 + + + + + + + + maven-site-plugin + + true + true + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + false + + + + + + src/main/resources + false + + + src/main/resources/app + true + + + + + src/test/resources + + + + + + https://github.com/NASA-PDS/pds-registry-loader-testing + scm:git:git@github.com/NASA-PDS/pds-registry-loader-testing.git + scm:git:git@github.com:NASA-PDS/pds-registry-loader-testing.git + v1.1.0 + + diff --git a/testing/src/test/java/cucumber/RunTest.java b/testing/src/test/java/cucumber/RunTest.java new file mode 100644 index 00000000..5b42c344 --- /dev/null +++ b/testing/src/test/java/cucumber/RunTest.java @@ -0,0 +1,15 @@ +package cucumber; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") // Points to src/test/resources/features +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "cucumber") // Points to your step definition package +public class RunTest { + // intentionally left blank but surefire has to find this file +} diff --git a/testing/src/test/java/cucumber/SingleScenerio.java b/testing/src/test/java/cucumber/SingleScenerio.java new file mode 100644 index 00000000..f88f6076 --- /dev/null +++ b/testing/src/test/java/cucumber/SingleScenerio.java @@ -0,0 +1,60 @@ +package cucumber; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +public class SingleScenerio { + public static final String TEST_DATA_DIR = + System.getProperty("user.dir") + File.separator + "src/test/resources"; + + private static String clean(String arg) { + arg = arg.strip(); + return arg.substring(1,arg.length()-1); + } + public static void main(String[] args) throws NumberFormatException, IOException { + if (args.length > 2 || args.length == 0) { + System.out.println ("usage: "); + System.out.println (" is the first column in the feature file like 1066"); + System.out.println (" is the second column and should be omitted if there are no subtests for the "); + return; + } + + for (File file : Paths.get(TEST_DATA_DIR, "features").toFile().listFiles((dir, name) -> name.endsWith(".feature"))) { + for (String line : Files.readAllLines(file.toPath())) { + line = line.strip(); + if (line.startsWith("|")) { + String[] scenerio = line.split("\\|"); + if (scenerio[1].strip().equals(args[0])) { + if (args.length == 1 && !scenerio[2].strip().isBlank()) { + System.out.println ("Scenerio " + args[0] + " requires a subtest value too."); + return; + } + if (args.length == 2 && scenerio[2].strip().isBlank()) { + System.out.println ("Scenerio " + args[0] + " does not require a subtest value."); + return; + } + if (args.length == 2 && !args[1].equals(scenerio[2].strip())) continue; + StepDefs engine = new StepDefs(); + System.out.println("construct"); + engine.construct( + Integer.valueOf(scenerio[1].strip()), + args.length == 1 ? null : Integer.valueOf(scenerio[2].strip()), + clean(scenerio[3])); + System.out.println(""); + engine.execute (clean(scenerio[4]), clean(scenerio[5])); + System.out.println ("compare"); + engine.compare (clean(scenerio[6])); + System.out.println ("success"); + return; + } + } + } + } + System.out.print ("Could not find issue number " + args[0]); + if (args.length == 2) System.out.print (" and subtest " + args[1]); + System.out.println(); + return; + } +} diff --git a/testing/src/test/java/cucumber/StepDefs.java b/testing/src/test/java/cucumber/StepDefs.java new file mode 100644 index 00000000..650d35b6 --- /dev/null +++ b/testing/src/test/java/cucumber/StepDefs.java @@ -0,0 +1,72 @@ +package cucumber; + +import java.util.ArrayList; +import io.cucumber.java.AfterAll; +import io.cucumber.java.Before; +import io.cucumber.java.BeforeAll; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import mock.MockAware; +import mock.OpensearchEngine; +import mock.OpensearchSupportedFunctionality; +import suite.CliAware; + +public class StepDefs { + private final ArrayList mocks = new ArrayList(); + private static final OpensearchEngine motor = new OpensearchEngine(); + + @AfterAll + public static void stop() { + StepDefs.motor.stop(); + } + + @Before + public void reset() { + StepDefs.motor.clear(); + } + + @BeforeAll + public static void start() { + StepDefs.motor.start(19022); + } + + @Given("registry-loader issue {int}, test {int}, and opensearch mocks {string}") + public void construct(Integer issueNumber, Integer count, String mocks) { + for (String mock : mocks.split(",")) { + this.mocks.add(classForName(mock)); + } + } + + @When("test suite {string} is executed with CLI arguments {string}") + public void execute(String suite, String cliargline) { + Runnable task = this.classForName(suite); + if (task instanceof CliAware) { + ((CliAware)task).arguments(cliargline); + } else if (cliargline != null && !cliargline.isBlank()) { + throw new IllegalStateException("An argument line was given to the suite " + suite + " that is not suite.CliAware"); + } + task.run(); + } + + @Then("compare to the expected outcome {string}.") + public void compare(String expectation) { + this.classForName(expectation).run(); + } + + @SuppressWarnings("unchecked") + private T classForName(String className) { + try { + Class clazz = Class.forName(className.trim()); + Object obj = clazz.getDeclaredConstructor().newInstance(); + if (obj instanceof MockAware) { + ((MockAware)obj).mocks(this.mocks); + } + return (T)obj; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Failed to instantiate mock class '" + className + "' — check spelling/package in the feature file's mocks column.", e + ); + } + } +} diff --git a/testing/src/test/java/expect/Sane.java b/testing/src/test/java/expect/Sane.java new file mode 100644 index 00000000..50073e73 --- /dev/null +++ b/testing/src/test/java/expect/Sane.java @@ -0,0 +1,40 @@ +package expect; + +import java.util.ArrayList; +import java.util.List; +import mock.MockAware; +import mock.OpensearchSupportedFunctionality; +import mock.osf.JUnitish; + +public final class Sane implements MockAware { + private final List knownMocks = new ArrayList(); + @Override + public void run() { + int count = 0; + int failed = 0; + int passed = 0; + for (OpensearchSupportedFunctionality osf : this.knownMocks) { + if (osf instanceof JUnitish) { + count += ((JUnitish) osf).results.values().size(); + for (Boolean b : ((JUnitish) osf).results.values()) { + if (b) { + passed++; + } else { + failed++; + } + } + } + } + if (count == 0) { + assert false : "No results found. Cannot meet any expectation without results."; + } else { + if (failed > 0 || passed == 0) { + assert false: "out of " + count + " tests, " + passed + " tests passed and " + failed + " tests failed"; + } + } + } + @Override + public void mocks(List mocks) { + this.knownMocks.addAll(mocks); + } +} diff --git a/testing/src/test/java/mock/JsonHelper.java b/testing/src/test/java/mock/JsonHelper.java new file mode 100644 index 00000000..e5c21330 --- /dev/null +++ b/testing/src/test/java/mock/JsonHelper.java @@ -0,0 +1,58 @@ +package mock; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class JsonHelper { + public record BulkCreateRequest (BulkCreateRequestLine1 create) {}; + public record BulkCreateRequestLine1(String _id, String _index) {}; + public record BulkCreateResponse(int took, boolean errors, List items) {}; + public record BulkCreateResponseItem(BulkCreateResponseItemResult create) {}; + public record BulkCreateResponseItemResult(String _index, String _id, int _version, String result, Shards _shards, int _seq_no, int _primary_term, int status) {}; + public record Shards(int total, int successful, int failed) {}; + + private final Logger log = LoggerFactory.getLogger(this.getClass()); + private final ObjectMapper mapper = new ObjectMapper(); + + public T decode (String body, Class target) { + try { + return this.mapper.readValue(body, target); + } catch (JsonProcessingException e) { + log.error("Could not convert body to the desired record {}", body, e); + throw new NoOpException("invalid json body for conversion"); + } + } + + public Map decodeTopLevel(String body) { + if (body == null || body.isBlank()) + return Map.of(); + try { + JsonNode root = this.mapper.readTree(body); + Map result = new LinkedHashMap<>(); + if (!root.isObject()) + return Map.of(); // not a JSON object at top level + for (Map.Entry e : root.properties()) { + result.put(e.getKey(), this.mapper.writeValueAsString(e.getValue())); + } + return result; + } catch (Exception e) { + return Map.of(); // not valid JSON at all + } + } + + public String encode (T object) { + try { + return this.mapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + log.error("invalid object (not a record) for encoding to json", e); + throw new NoOpException("invalid object for conversion to json"); + } + } + +} diff --git a/testing/src/test/java/mock/MockAware.java b/testing/src/test/java/mock/MockAware.java new file mode 100644 index 00000000..dacaa68d --- /dev/null +++ b/testing/src/test/java/mock/MockAware.java @@ -0,0 +1,7 @@ +package mock; + +import java.util.List; + +public interface MockAware extends Runnable { + public void mocks (List mocks); +} diff --git a/testing/src/test/java/mock/NoOpException.java b/testing/src/test/java/mock/NoOpException.java new file mode 100644 index 00000000..4bbe3eb3 --- /dev/null +++ b/testing/src/test/java/mock/NoOpException.java @@ -0,0 +1,21 @@ +package mock; + +public class NoOpException extends RuntimeException { + private static final long serialVersionUID = 8994806487926218572L; + public NoOpException() { + super(); + } + public NoOpException(String message) { + super(message); + } + public NoOpException(Throwable cause) { + super(cause); + } + public NoOpException(String message, Throwable cause) { + super(message, cause); + } + public NoOpException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/testing/src/test/java/mock/OpensearchEngine.java b/testing/src/test/java/mock/OpensearchEngine.java new file mode 100644 index 00000000..9bd72f81 --- /dev/null +++ b/testing/src/test/java/mock/OpensearchEngine.java @@ -0,0 +1,213 @@ +package mock; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import io.javalin.Javalin; +import io.javalin.community.ssl.SslPlugin; +import io.javalin.http.Handler; +import io.javalin.http.HandlerType; +import jakarta.annotation.Nonnull; +import mock.OpensearchSupportedFunctionality.Context; +import mock.OpensearchSupportedFunctionality.Response; +import mock.annotation.Replace; +import mock.osf.Standard; + +public final class OpensearchEngine { + public record MethodTarget(Object instance, Method method) { + } + + private final JsonHelper json = new JsonHelper(); + private final Logger log = LoggerFactory.getLogger(this.getClass()); + private final Map redirect = new ConcurrentHashMap<>(); + private Javalin app; + + /** + * Scans the composite for @mock.annotation.replace methods and updates the redirect map. + */ + public void add(@Nonnull OpensearchSupportedFunctionality composite) { + Map interfaceMethods = + Arrays.stream(OpensearchSupportedFunctionality.class.getMethods()) + .collect(Collectors.toMap(Method::getName, m -> m)); + + // Walk the class hierarchy top (most ancestral, excluding Object) to bottom (composite's own + // class). + List> hierarchy = new ArrayList<>(); + for (Class c = composite.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + hierarchy.add(c); + } + Collections.reverse(hierarchy); + for (Class clazz : hierarchy) { + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(Replace.class)) { + Method interfaceMethod = interfaceMethods.get(method.getName()); + if (interfaceMethod == null + || !Arrays.equals(interfaceMethod.getParameterTypes(), method.getParameterTypes())) { + log.warn( + "Method {} in class {} is not a valid override of OpensearchSupportedFunctionality", + method.getName(), composite.getClass().getName()); + continue; + } + method.setAccessible(true); + redirect.put(method.getName(), new MethodTarget(composite, method)); + } + } + } + } + + public Map redirectDeepCopy() { + ConcurrentHashMap dcp = new ConcurrentHashMap<>(); + for (Map.Entry item : this.redirect.entrySet()) { + dcp.put(item.getKey(), new MethodTarget(item.getValue().instance(), item.getValue().method())); + } + return dcp; + } + + /** + * For cucumber to signal the beginning or ending of a scenerio by clearing all of the test + * functions. + */ + public void clear() { + this.redirect.clear(); + } + + /** + * Converts HTTP paths into safe, matching Java method names. Examples: GET / -> getRoot POST + * /_bulk -> postBulk POST /my-index/_search -> postMyIndexSearch + */ + private String determineMethodName(String method, String path) { + String sanitizedPath = path.replaceAll("[^a-zA-Z0-9/]", ""); + String camelCasePath = + Arrays.stream(sanitizedPath.split("/")).filter(segment -> !segment.isEmpty()) + .map(segment -> Character.toUpperCase(segment.charAt(0)) + segment.substring(1)) + .collect(Collectors.joining()); + return method.toLowerCase() + camelCasePath; + } + + private String indexFrom(String path) { + return path.split("/")[1]; + } + + /** + * Resolves the target interface method by climbing the profile's class hierarchy, respecting your + * explicit @Replace annotation policies. + */ + public Response process(String methodName, Context context) { + log.info("Method name: {}", methodName); + log.info("Context:"); + log.info(" index: {}", context.index()); + log.info(" body: {}", context.body()); + log.info(" header: {}", context.headers()); + log.info(" query: {}", context.queryParams()); + log.info(" params: {}", context.pathParams()); + Map endpoint = json.decodeTopLevel(context.body()); + for (String name : endpoint.keySet().stream().map(String::toLowerCase).sorted().toList()) { + methodName = methodName + Character.toUpperCase(name.charAt(0)) + name.substring(1); + } + return subprocess(methodName, context); + } + + private void sendResponse(io.javalin.http.Context ctx, Response response) { + ctx.status(response.statusCode()); + ctx.contentType(response.contentType()); + ctx.result(response.body()); + } + + /** + * Spins up the fixed Javalin instance. Called once during Cucumber initialization. + */ + public void start(int port) { + this.app = Javalin.create(config -> { + config.registerPlugin(new SslPlugin(ssl -> { + ssl.host = "127.0.0.1"; + ssl.insecure = false; + ssl.securePort = port; + try { + Process process = new ProcessBuilder("sh", "-c", + "openssl req -x509 -newkey rsa:2048 -keyout /dev/stdout -out /dev/stdout -sha256 -days 1 -nodes -subj '/CN=localhost' -addext 'subjectAltName = DNS:localhost' 2>/dev/null") + .start(); + process.waitFor(); // ignoring exceptions and bad waits as this is a test harness and developer can deal with it. + String openSslOutput = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + ssl.pemFromString(openSslOutput, openSslOutput); + } catch (Exception e) { + throw new RuntimeException("Failed to auto-generate localhost cert via openssl", e); + } + })); + + Handler catchAll = ctx -> { + Context facadeContext = + + new Context( + indexFrom(ctx.path()), + ctx.body(), ctx.headerMap(), + ctx.queryParamMap().entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0))), + ctx.pathParamMap()); + + String targetMethodName = determineMethodName(ctx.method().name(), ctx.path().substring(facadeContext.index().length()+1)); + log.info("request path: {}", ctx.path()); + // FIXME: need to always check authorize() here??? + Response aResponse = subprocess("authorize", facadeContext); + if (200 <= aResponse.statusCode() && aResponse.statusCode() < 300) { + aResponse = process(targetMethodName, facadeContext); + } + sendResponse(ctx, aResponse); + }; + + for (HandlerType method : new HandlerType[] {HandlerType.GET, HandlerType.POST, + HandlerType.PUT, HandlerType.PATCH, HandlerType.DELETE, HandlerType.HEAD, + HandlerType.OPTIONS}) { + config.routes.addHttpHandler(method, "/*", catchAll); + } + }); + this.app.start(); + } + + public void stop() { + if (this.app != null) { + this.app.stop(); + } + } + + private Response subprocess(String methodName, Context context) { + // have a more appropriate name for the endpoint and just its data (maybe) + MethodTarget target = redirect.get(methodName); + + if (target == null) { + return new Response(501, + "{\"error\": \"Method '" + methodName + "' not found in active profile hierarchy.\"}", + "application/json"); + } + + try { + return (Response) target.method().invoke(target.instance(), context); + } catch (InvocationTargetException e) { + log.error("Invocation problem processing the testing request", e); + return new Response(500, + "{\"error\": \"Mock runtime error: " + e.getCause().getMessage() + "\"}", + "application/json"); + } catch (IllegalAccessException e) { + log.error("Access is to testing functioon is wrong", e); + return new Response(500, "{\"error\": \"Security constraint executing mock method\"}", + "application/json"); + } + } + + public static void main(String[] argv) throws InterruptedException { + OpensearchEngine me = new OpensearchEngine(); + me.start(9200); + me.add(new Standard()); + Thread.sleep(1000 * 1000); + me.stop(); + } +} diff --git a/testing/src/test/java/mock/OpensearchSpy.java b/testing/src/test/java/mock/OpensearchSpy.java new file mode 100644 index 00000000..e68e9155 --- /dev/null +++ b/testing/src/test/java/mock/OpensearchSpy.java @@ -0,0 +1,363 @@ +package mock; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.javalin.Javalin; +import io.javalin.community.ssl.SslPlugin; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HandlerType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Sits on 9200 exactly like OpensearchEngine, but instead of dispatching to mock + * {@code @Replace} methods, forwards every request byte-for-byte to a real OpenSearch + * (e.g. running in docker), captures the upstream response, relays it back to the SDK, + * and records the request/response pair to disk for later mock-building analysis. + * + *

Deliberately does NOT reuse OpensearchEngine's {@code Context}/{@code Response} + * facade or the {@code redirect} reflection machinery — none of that applies to a + * transparent proxy, and staying on raw Javalin {@code Context} + byte arrays avoids + * losing anything the SDK sends (in particular NDJSON bulk bodies, which a + * parse-as-one-JSON-doc facade would mangle). + */ +public final class OpensearchSpy { + + private final Logger log = LoggerFactory.getLogger(OpensearchSpy.class); + private final ObjectMapper mapper = new ObjectMapper(); + private final AtomicLong sequence = new AtomicLong(); + private final Object indexWriteLock = new Object(); + private Javalin app; + + private final HttpClient upstreamClient; + private final URI upstreamBase; + private final Path captureDir; + + // Headers we do NOT re-set on the *outgoing* upstream request or the *outgoing* + // client response, because the HTTP stack (HttpClient / Jetty) computes and sets + // these itself based on the actual bytes being sent — copying stale values across + // would corrupt the framing. Everything is still captured in the JSON record + // unfiltered; this list only affects what gets blindly re-set on the wire. + // + // The h2-specific entries (keep-alive, upgrade, proxy-connection, te) matter even + // though the listener now runs HTTP/1.1-only (see start(), ssl.http2 = false): + // OpenSearch's HTTP/1.1 upstream commonly sends "Keep-Alive: timeout=5" etc, and + // these are meaningless/stale once relayed over a fresh connection, so we drop them + // on principle rather than only when strictly required by the protocol in use. + private static final Set HOP_BY_HOP_HEADERS = Set.of( + "host", "content-length", "connection", "transfer-encoding", "expect", + "keep-alive", "upgrade", "proxy-connection", "te"); + + public OpensearchSpy(URI upstreamBase, Path captureDir) { + this.upstreamBase = upstreamBase; + this.captureDir = captureDir; + this.upstreamClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .sslContext(trustAllSslContext()) // local docker cluster w/ demo self-signed certs + .build(); + try { + Files.createDirectories(captureDir); + } catch (IOException e) { + throw new RuntimeException("Could not create capture directory " + captureDir, e); + } + } + + /** + * Trust-all SSLContext used ONLY for talking to the local docker OpenSearch upstream. + * Never applied to the listener the SDK connects to on 9200 (that keeps its normal + * openssl-generated cert via SslPlugin below). Fine for a local test double; do not + * reuse this pattern anywhere that touches a real network. + */ + private static SSLContext trustAllSslContext() { + try { + TrustManager[] trustAll = new TrustManager[] {new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + }}; + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, trustAll, new SecureRandom()); + return ctx; + } catch (Exception e) { + throw new RuntimeException("Failed building trust-all SSLContext for upstream client", e); + } + } + + public void start(int port) { + this.app = Javalin.create(config -> { + config.registerPlugin(new SslPlugin(ssl -> { + ssl.host = "127.0.0.1"; + ssl.insecure = false; + ssl.securePort = port; + // Disable HTTP/2 (SslPlugin defaults this on via ALPN). A raw proxy is exactly + // the kind of thing that trips HTTP/2's stricter framing rules -- a header + // that's merely sloppy over HTTP/1.1 (e.g. a stray Keep-Alive from the + // upstream, a duplicate) can get the whole stream RST_STREAM'd by the SDK's h2 + // client ("Stream reset (8)" / CANCEL). We don't need h2 for a test double, so + // pin HTTP/1.1 to remove that entire bug class rather than chase every header + // h2 happens to be strict about. + ssl.http2 = false; + try { + Process process = new ProcessBuilder("sh", "-c", + "openssl req -x509 -newkey rsa:2048 -keyout /dev/stdout -out /dev/stdout -sha256 -days 1 -nodes -subj '/CN=localhost' -addext 'subjectAltName = DNS:localhost' 2>/dev/null") + .start(); + String openSslOutput = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + ssl.pemFromString(openSslOutput, openSslOutput); + } catch (Exception e) { + throw new RuntimeException("Failed to auto-generate localhost cert via openssl", e); + } + })); + + Handler catchAll = this::handle; + + for (HandlerType method : new HandlerType[] {HandlerType.GET, HandlerType.POST, + HandlerType.PUT, HandlerType.PATCH, HandlerType.DELETE, HandlerType.HEAD, + HandlerType.OPTIONS}) { + config.routes.addHttpHandler(method, "/*", catchAll); + } + }); + this.app.start(); + log.info("OpensearchSpy listening on 9200, forwarding to {}, capturing to {}", + upstreamBase, captureDir.toAbsolutePath()); + } + + public void stop() { + if (this.app != null) { + this.app.stop(); + } + } + + private void handle(Context ctx) { + long id = sequence.incrementAndGet(); + byte[] requestBody = ctx.bodyAsBytes(); + String rawPathAndQuery = ctx.path() + (ctx.queryString() != null ? "?" + ctx.queryString() : ""); + + // Full, unfiltered, multi-valued incoming headers, pulled from the raw servlet + // request rather than ctx.headerMap() (which is single-valued / last-wins and + // would silently drop a repeated header like a second Set-Cookie or X-Forwarded-For). + Map> incomingHeaders = multiValuedRequestHeaders(ctx); + + HttpRequest.Builder upstreamReq = HttpRequest.newBuilder() + .uri(upstreamBase.resolve(rawPathAndQuery)) + .method(ctx.method().name(), + requestBody.length == 0 + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofByteArray(requestBody)); + + // Real SDK auth is forwarded as-is (docker was started with matching credentials) -- + // no substitution. We only skip headers HttpClient manages itself (see comment above). + for (Map.Entry> header : incomingHeaders.entrySet()) { + if (HOP_BY_HOP_HEADERS.contains(header.getKey().toLowerCase())) { + continue; + } + for (String value : header.getValue()) { + try { + upstreamReq.header(header.getKey(), value); + } catch (IllegalArgumentException restrictedHeader) { + // A handful of headers (e.g. Host) are restricted even outside our explicit + // skip list depending on JDK version; HttpClient sets the wire equivalent + // itself, so this is safe to ignore. + } + } + } + + HttpResponse upstreamResp; + Instant start = Instant.now(); + try { + upstreamResp = upstreamClient.send(upstreamReq.build(), HttpResponse.BodyHandlers.ofByteArray()); + } catch (Exception e) { + log.error("Failed forwarding {} {} to upstream", ctx.method(), rawPathAndQuery, e); + ctx.status(502); + ctx.contentType("application/json"); + ctx.result("{\"error\": \"OpensearchSpy failed to reach upstream: " + e.getMessage() + "\"}"); + recordFailure(id, ctx, rawPathAndQuery, incomingHeaders, requestBody, e); + return; + } + long tookMillis = Instant.now().toEpochMilli() - start.toEpochMilli(); + + ctx.status(upstreamResp.statusCode()); + upstreamResp.headers().map().forEach((name, values) -> { + if (HOP_BY_HOP_HEADERS.contains(name.toLowerCase())) { + return; // still captured below in full -- just not blindly re-set on the wire + } + for (String value : values) { + ctx.header(name, value); + } + }); + ctx.result(upstreamResp.body()); + + record(id, ctx, rawPathAndQuery, incomingHeaders, requestBody, + upstreamResp, tookMillis); + } + + /** Pulls every header value (not just the last one per name) off the raw servlet request. */ + private Map> multiValuedRequestHeaders(Context ctx) { + Map> result = new LinkedHashMap<>(); + var servletRequest = ctx.req(); + Enumeration names = servletRequest.getHeaderNames(); + while (names != null && names.hasMoreElements()) { + String name = names.nextElement(); + List values = new ArrayList<>(); + Enumeration valueEnum = servletRequest.getHeaders(name); + while (valueEnum.hasMoreElements()) { + values.add(valueEnum.nextElement()); + } + result.put(name, values); + } + return result; + } + + private void record(long id, Context ctx, String pathAndQuery, Map> reqHeaders, + byte[] reqBody, HttpResponse upstreamResp, long tookMillis) { + try { + ObjectNode root = mapper.createObjectNode(); + root.put("id", id); + root.put("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); + root.put("tookMillis", tookMillis); + + populateRequest(root.putObject("request"), ctx, pathAndQuery, reqHeaders, reqBody); + + ObjectNode response = root.putObject("response"); + response.put("status", upstreamResp.statusCode()); + response.put("httpVersion", upstreamResp.version().name()); + response.put("upstreamUri", upstreamResp.uri().toString()); + ObjectNode respHeadersNode = response.putObject("headers"); + upstreamResp.headers().map().forEach((k, v) -> { + var arr = respHeadersNode.putArray(k); + v.forEach(arr::add); + }); + attachBody(response, "body", upstreamResp.body()); + + writeCaptureFile(id, ctx.method().name(), pathAndQuery, root); + appendToIndex(root); + } catch (IOException e) { + log.error("Failed to record capture #{}", id, e); + } + } + + private void recordFailure(long id, Context ctx, String pathAndQuery, + Map> reqHeaders, byte[] reqBody, Exception failure) { + try { + ObjectNode root = mapper.createObjectNode(); + root.put("id", id); + root.put("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); + root.put("error", failure.toString()); + + populateRequest(root.putObject("request"), ctx, pathAndQuery, reqHeaders, reqBody); + + writeCaptureFile(id, ctx.method().name(), pathAndQuery + "-FAILED", root); + appendToIndex(root); + } catch (IOException e) { + log.error("Failed to record failure capture #{}", id, e); + } + } + + /** Captures everything we can cheaply pull off the incoming request, not just body+headers. */ + private void populateRequest(ObjectNode request, Context ctx, String pathAndQuery, + Map> reqHeaders, byte[] reqBody) { + request.put("method", ctx.method().name()); + request.put("path", ctx.path()); + request.put("pathAndQuery", pathAndQuery); + request.put("queryString", ctx.queryString()); + request.put("matchedPath", ctx.endpoint() != null ? ctx.endpoint().path : null); + request.put("protocol", ctx.protocol()); + request.put("scheme", ctx.scheme()); + request.put("host", ctx.host()); + request.put("contentType", ctx.contentType()); + ObjectNode reqHeadersNode = request.putObject("headers"); + reqHeaders.forEach((k, v) -> { + var arr = reqHeadersNode.putArray(k); + v.forEach(arr::add); + }); + attachBody(request, "body", reqBody); + } + + private void writeCaptureFile(long id, String method, String pathAndQuery, ObjectNode root) + throws IOException { + String filename = "%05d-%s-%s.json".formatted(id, method, sanitize(pathAndQuery)); + Files.writeString(captureDir.resolve(filename), + mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + /** Append-only NDJSON index across all captures, for quick grepping / batch analysis. */ + private void appendToIndex(ObjectNode root) throws IOException { + String line = mapper.writeValueAsString(root) + System.lineSeparator(); + synchronized (indexWriteLock) { + Files.writeString(captureDir.resolve("_index.ndjson"), line, + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } + } + + /** + * Bulk/NDJSON and ordinary JSON bodies both round-trip fine as text; falls back to + * base64 only if the payload isn't valid UTF-8. Note: plain + * {@code new String(bytes, UTF_8)} never throws -- it silently swaps bad bytes for + * U+FFFD -- so we decode strictly with CodingErrorAction.REPORT to actually detect + * that case instead of quietly corrupting the capture. + */ + private void attachBody(ObjectNode parent, String field, byte[] body) { + if (body == null || body.length == 0) { + parent.putNull(field); + return; + } + try { + String text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(body)) + .toString(); + parent.put(field, text); + parent.put(field + "Encoding", "utf8"); + } catch (CharacterCodingException notUtf8) { + parent.put(field, java.util.Base64.getEncoder().encodeToString(body)); + parent.put(field + "Encoding", "base64"); + } + } + + private String sanitize(String pathAndQuery) { + String pathOnly = pathAndQuery.split("\\?")[0]; + String s = pathOnly.replaceAll("[^a-zA-Z0-9]", "_"); + return s.length() > 80 ? s.substring(0, 80) : s; + } + + public static void main(String[] argv) throws InterruptedException { + // Point this at wherever docker-compose exposes OpenSearch, e.g. https://localhost:9201 + URI upstream = URI.create(System.getProperty("spy.upstream", "https://localhost:19200")); + Path captures = Path.of(System.getProperty("spy.captureDir", "target/captures")); + + OpensearchSpy spy = new OpensearchSpy(upstream, captures); + spy.start(9200); + Thread.sleep(1000L * 1000); + spy.stop(); + } +} diff --git a/testing/src/test/java/mock/OpensearchSupportedFunctionality.java b/testing/src/test/java/mock/OpensearchSupportedFunctionality.java new file mode 100644 index 00000000..1aa14d54 --- /dev/null +++ b/testing/src/test/java/mock/OpensearchSupportedFunctionality.java @@ -0,0 +1,31 @@ +package mock; + +import java.util.Map; + +public interface OpensearchSupportedFunctionality { + public record Context( + String index, // the index that the JSON body applies + String body, // original headers sent to the socket + Map headers, // original headers sent to the socket + Map queryParams, // original query parameters sent to the socket + Map pathParams) { // original path parameters sent to the socket + } + + public record Response( + int statusCode, + String body, + String contentType) { + // Convenience factory for standard 200 OK JSON responses + public static Response json(String jsonBody) { + return new Response(200, jsonBody, "application/json"); + } + // Convenience factory for standard empty success responses + public static Response empty(int statusCode) { + return new Response(statusCode, "{}", "application/json"); + } + } + + public Response authorize(Context ctx); + public Response postBulkCreate(Context ctx); + public Response putMappingsSettings(Context ctx); +} diff --git a/testing/src/test/java/mock/annotation/Replace.java b/testing/src/test/java/mock/annotation/Replace.java new file mode 100644 index 00000000..6048c9fb --- /dev/null +++ b/testing/src/test/java/mock/annotation/Replace.java @@ -0,0 +1,6 @@ +package mock.annotation; + +@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME) +@java.lang.annotation.Target(java.lang.annotation.ElementType.METHOD) +public @interface Replace { +} diff --git a/testing/src/test/java/mock/osf/JUnitish.java b/testing/src/test/java/mock/osf/JUnitish.java new file mode 100644 index 00000000..7b714cc1 --- /dev/null +++ b/testing/src/test/java/mock/osf/JUnitish.java @@ -0,0 +1,47 @@ +package mock.osf; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import mock.OpensearchSupportedFunctionality; + +public final class JUnitish extends NoOp implements OpensearchSupportedFunctionality { + public final HashMap results = new HashMap(); + private final Logger log = LoggerFactory.getLogger(JUnitish.class); + public void runTests(Object from) { + int count = 0; + for (Method method : from.getClass().getDeclaredMethods()) { + if (method.isAnnotationPresent(Test.class)) { + try { + count++; + method.invoke(from); + this.results.put(method.getName(), true); + } catch (AssertionError | IllegalAccessException | InvocationTargetException e) { + Throwable t = e; + while (t.getCause() != null) { + t = t.getCause(); + } + if (t instanceof AssertionError) { + StackTraceElement element = java.util.Arrays.stream(t.getStackTrace()) + .findFirst() + .orElse(null); + + log.warn((element != null) + ? String.format("assertion failed at %s.%s():%d - {}", + element.getClassName(), element.getMethodName(), element.getLineNumber()) + : "assertion failed at unknown source - {}", t.getMessage()); + } else { + log.error("Test failed due to implementation error", t); + } + this.results.put(method.getName(), false); + } + } + } + if (count == 0) { + results.put("found tests", false); + } + } +} diff --git a/testing/src/test/java/mock/osf/NoOp.java b/testing/src/test/java/mock/osf/NoOp.java new file mode 100644 index 00000000..69ce2e50 --- /dev/null +++ b/testing/src/test/java/mock/osf/NoOp.java @@ -0,0 +1,19 @@ +package mock.osf; + +import java.lang.invoke.MethodHandles; +import mock.NoOpException; +import mock.OpensearchSupportedFunctionality; + +public class NoOp implements OpensearchSupportedFunctionality { + private Response placeholder() { + String methodName = new Throwable().getStackTrace()[1].getMethodName(); + String className = MethodHandles.lookup().lookupClass().getSimpleName(); + throw new NoOpException("Placeholder: " + className + "." + methodName + "()"); + } + @Override + public Response authorize(Context ctx) { return placeholder(); } + @Override + public Response postBulkCreate(Context ctx) { return placeholder(); } + @Override + public Response putMappingsSettings(Context ctx) { return placeholder(); } +} diff --git a/testing/src/test/java/mock/osf/Standard.java b/testing/src/test/java/mock/osf/Standard.java new file mode 100644 index 00000000..3f283147 --- /dev/null +++ b/testing/src/test/java/mock/osf/Standard.java @@ -0,0 +1,46 @@ +package mock.osf; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import mock.JsonHelper; +import mock.JsonHelper.BulkCreateRequest; +import mock.JsonHelper.BulkCreateResponse; +import mock.JsonHelper.BulkCreateResponseItem; +import mock.JsonHelper.BulkCreateResponseItemResult; +import mock.JsonHelper.Shards; +import mock.annotation.Replace; + +public class Standard extends NoOp { + private final JsonHelper json = new JsonHelper(); + + @Override @Replace + public Response authorize (Context ctc) { + if (ctc.headers().containsKey("authorization") && ctc.headers().get("authorization").startsWith("Basic ")) { + return Response.empty(200); + } + return Response.empty(401); + } + @Override @Replace + public Response postBulkCreate (Context ctx) { + int seq = 0; + Iterator requestsText = List.of(ctx.body().split("\\R")).iterator(); + List items = new LinkedList<>(); + while (requestsText.hasNext()) { + BulkCreateRequest request = json.decode(requestsText.next(), BulkCreateRequest.class); + items.add (new BulkCreateResponseItem( + new BulkCreateResponseItemResult( + request.create()._index(), + request.create()._id(), + 1, "created", + new Shards(1, 1, 0), + seq, 1, 201))); + requestsText.next(); // throw away the body of the message + } + return Response.json(json.encode(new BulkCreateResponse(11, false, items))); + } + @Override @Replace + public Response putMappingsSettings (Context ctx) { + return Response.json("{\"acknowledged\":true,\"shards_acknowledged\":true,\"index\":\"" + ctx.index() + "\"}"); + } +} diff --git a/testing/src/test/java/suite/ArtificialComposite.java b/testing/src/test/java/suite/ArtificialComposite.java new file mode 100644 index 00000000..45a3d57f --- /dev/null +++ b/testing/src/test/java/suite/ArtificialComposite.java @@ -0,0 +1,50 @@ +package suite; + +import java.util.List; +import mock.MockAware; +import mock.NoOpException; +import mock.OpensearchEngine; +import mock.OpensearchSupportedFunctionality; + +class ArtificialComposite implements OpensearchSupportedFunctionality, MockAware { + private final OpensearchEngine redirect = new OpensearchEngine(); + + @Override + public final Response authorize(Context ctx) { + return this.redirect.process( + StackWalker.getInstance() + .walk(stream -> stream.findFirst().map(StackWalker.StackFrame::getMethodName)) + .orElse("unknown"), + ctx); + } + + @Override + public void mocks(List mocks) { + for (OpensearchSupportedFunctionality mock : mocks) { + redirect.add(mock); + } + } + + @Override + public Response postBulkCreate(Context ctx) { + return this.redirect.process( + StackWalker.getInstance() + .walk(stream -> stream.findFirst().map(StackWalker.StackFrame::getMethodName)) + .orElse("unknown"), + ctx); + } + + @Override + public final Response putMappingsSettings(Context ctx) { + return this.redirect.process( + StackWalker.getInstance() + .walk(stream -> stream.findFirst().map(StackWalker.StackFrame::getMethodName)) + .orElse("unknown"), + ctx); + } + + @Override + public void run() { + throw new NoOpException("This should be overriden by suites"); + } +} diff --git a/testing/src/test/java/suite/CliAware.java b/testing/src/test/java/suite/CliAware.java new file mode 100644 index 00000000..8878025a --- /dev/null +++ b/testing/src/test/java/suite/CliAware.java @@ -0,0 +1,5 @@ +package suite; + +public interface CliAware extends Runnable { + public void arguments(String args); +} diff --git a/testing/src/test/java/suite/Sanity.java b/testing/src/test/java/suite/Sanity.java new file mode 100644 index 00000000..5b23fbfe --- /dev/null +++ b/testing/src/test/java/suite/Sanity.java @@ -0,0 +1,47 @@ +package suite; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import org.junit.jupiter.api.Test; +import mock.OpensearchSupportedFunctionality; +import mock.osf.JUnitish; + +public final class Sanity extends ArtificialComposite { + private final List knownMocks = new ArrayList(); + + @Override + public void mocks(List mocks) { + super.mocks(mocks); + this.knownMocks.addAll(mocks); + } + + @Override + public void run() { + System.out.println("running sanity"); + System.out.println("mocks: " + this.knownMocks); + for (OpensearchSupportedFunctionality osf : this.knownMocks) { + if (osf instanceof JUnitish) { + System.out.println("running junitish"); + ((JUnitish) osf).runTests(this); + } + } + } + + @Test + public void testAuthorize() { + HashMap headers = new HashMap(); + Context ctx = new Context("test", "body", headers, null, null); + headers.put("user-agent", "opensearch-java/3.2.0 (Java/21.0.11)"); + headers.put("accept", "application/json; charset=UTF-8"); + headers.put("authorization", "Basic fakekey"); + headers.put("content-type", "application/json; charset=UTF-8"); + assert this.authorize(ctx).statusCode() == 200 : "authorization was not requested"; + } + + @Test + public void testMappingSettings() { + Context ctx = new Context("test", "body", null, null, null); + assert this.putMappingsSettings(ctx).statusCode() == 200 : "did not return a success status code"; + } +} diff --git a/testing/src/test/resources/features/1.4.x.feature b/testing/src/test/resources/features/1.4.x.feature new file mode 100644 index 00000000..31484fad --- /dev/null +++ b/testing/src/test/resources/features/1.4.x.feature @@ -0,0 +1,11 @@ +Feature: 1.4.x + Scenario Outline: NASA-PDS/registry-loader#- + Given registry-loader issue , test , and opensearch mocks + When test suite is executed with CLI arguments + Then compare to the expected outcome . + @1.4.x + Examples: + | issueNumber | subtest | mocks | suite | cliargline | expectation | +# | 139 | 0 | "mock.osf.Standard" | "suite.Sanity" | "" | "expect.Sane" | + +| 139 | 0 | "mock.osf.Standard,mock.osf.JUnitish" | "suite.Sanity" | "" | "expect.Sane" | diff --git a/testing/src/test/resources/junit-platform.properties b/testing/src/test/resources/junit-platform.properties new file mode 100644 index 00000000..bcb0125c --- /dev/null +++ b/testing/src/test/resources/junit-platform.properties @@ -0,0 +1,5 @@ +cucumber.features=classpath:features/ +cucumber.glue=cucumber +cucumber.plugin=pretty,summary,html:target/cucumber.html +cucumber.publish.quiet=true +