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
+