diff --git a/README.md b/README.md index 31cdad6..15fe64c 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,5 @@ github actions artifact proxy # TODO 1. Tests + 1. better browser control in testing - headless default, stepping, etc 1. GUI for local use diff --git a/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java b/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java index 91c50d7..a0a7298 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java +++ b/app/src/main/java/dev/flowty/bowlby/app/github/GithubApiClient.java @@ -3,12 +3,14 @@ import static java.nio.file.StandardOpenOption.CREATE; import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; import static java.nio.file.StandardOpenOption.WRITE; +import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toCollection; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandler; @@ -51,7 +53,9 @@ public class GithubApiClient { private final String apiHost; private final String authToken; - private final HttpClient http = HttpClient.newBuilder().build(); + private final HttpClient http = HttpClient.newBuilder() + .version( Version.HTTP_1_1 ) + .build(); /** * Holds the last time that we made an api call. @@ -264,7 +268,12 @@ private synchronized HttpResponse send( HttpRequest request, BodyHandler< LOG.debug( "Sending request {}", request ); HttpResponse response = http.send( request, handler ); if( LOG.isDebugEnabled() ) { - LOG.debug( "Got response {}\n{}", response, Message.toJson( response.body() ) ); + LOG.debug( "Got response\n{}\n{}\n{}", + response, + response.headers().map().entrySet().stream() + .map( e -> e.getKey() + ": " + e.getValue().stream().collect( joining( ", " ) ) ) + .collect( joining( "\n" ) ), + Message.toJson( response.body() ) ); } lastCall = Instant.now(); diff --git a/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java b/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java index 713c2bf..77df276 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java +++ b/app/src/main/java/dev/flowty/bowlby/app/srv/LatestArtifactHandler.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Set; import java.util.function.BiConsumer; +import java.util.function.Function; import java.util.stream.Stream; import org.slf4j.Logger; @@ -25,8 +26,8 @@ import dev.flowty.bowlby.app.github.Entity.Repository; import dev.flowty.bowlby.app.github.Entity.Run; import dev.flowty.bowlby.app.github.Entity.Workflow; -import dev.flowty.bowlby.app.xml.Html; import dev.flowty.bowlby.app.github.GithubApiClient; +import dev.flowty.bowlby.app.xml.Html; /** * Handles requests to: @@ -97,7 +98,7 @@ public void handle( HttpExchange exchange ) throws IOException { if( path.isEmpty() ) { // the path has no indication of which artifact we're interested in - showArtifactLinks( exchange, 200, workflow, latest ); + showArtifactLinks( exchange, 300, workflow, latest ); return; } @@ -113,6 +114,9 @@ public void handle( HttpExchange exchange ) throws IOException { return; } + Duration cacheLife = Duration.between( Instant.now(), latest.expiry() ); + exchange.getResponseHeaders().add( "cache-control", + "public; immutable; max-age=" + cacheLife.getSeconds() ); ServeUtil.redirect( exchange, String.format( "/artifacts/%s/%s/%s/%s", workflow.repo().owner(), workflow.repo().repo(), selected.artifact().id(), @@ -163,14 +167,16 @@ private LatestArtifact getLatest( Workflow workflow ) { private static void showArtifactLinks( HttpExchange exchange, int status, Workflow workflow, LatestArtifact latest ) throws IOException { + + Function artifactPath = na -> String.format( "/latest/%s/%s/%s/%s", + workflow.repo().owner(), + workflow.repo().repo(), + workflow.name(), + na.name() ); + latest.artifacts().forEach( a -> exchange.getResponseHeaders() + .add( "link", "<" + artifactPath.apply( a ) + ">; rel=alternate" ) ); BiConsumer artifactItem = ( html, artifact ) -> { - html.li( i -> i.a( - String.format( "/latest/%s/%s/%s/%s", - workflow.repo().owner(), - workflow.repo().repo(), - workflow.name(), - artifact.name() ), - artifact.name() ) ); + html.li( i -> i.a( artifactPath.apply( artifact ), artifact.name() ) ); }; ServeUtil.respond( exchange, status, new Html() .head( h -> h @@ -184,11 +190,8 @@ private static void showArtifactLinks( HttpExchange exchange, int status, Workfl .conditional( c -> c .p( "These stable links will redirect to the latest artifacts for the ", workflow.name(), " workflow on the default branch. ", - "Feel free to append path components to address files within the artifacts" ) - .ul( u -> u - .repeat( artifactItem ).over( latest.artifacts() ) ) - .p( "If you use these links after ", latest.expiry, - ", then they might redirect to a newer artifact" ) ) + "Feel free to append path components to address files within the artifacts." ) + .ul( u -> u.repeat( artifactItem ).over( latest.artifacts() ) ) ) .on( !latest.artifacts().isEmpty() ) ) .toString() ); } diff --git a/app/src/main/java/dev/flowty/bowlby/app/srv/ServeUtil.java b/app/src/main/java/dev/flowty/bowlby/app/srv/ServeUtil.java index 2391d94..407745e 100644 --- a/app/src/main/java/dev/flowty/bowlby/app/srv/ServeUtil.java +++ b/app/src/main/java/dev/flowty/bowlby/app/srv/ServeUtil.java @@ -108,8 +108,7 @@ public static void redirect( HttpExchange exchange, String destination ) throws LOG.debug( "redirecting to {}", destination ); exchange.getResponseHeaders() .add( "location", destination ); - exchange.sendResponseHeaders( 303, 0 ); - exchange.getResponseBody().close(); + exchange.sendResponseHeaders( 303, -1 ); } /** diff --git a/app/src/test/java/dev/flowty/bowlby/app/MainTest.java b/app/src/test/java/dev/flowty/bowlby/app/MainTest.java index bf3505a..92ba0d8 100644 --- a/app/src/test/java/dev/flowty/bowlby/app/MainTest.java +++ b/app/src/test/java/dev/flowty/bowlby/app/MainTest.java @@ -3,15 +3,11 @@ import static dev.flowfty.bowlby.model.BowlbySystem.Actors.BOWLBY; import static dev.flowfty.bowlby.model.BowlbySystem.Unpredictables.BORING; import static dev.flowfty.bowlby.model.BowlbySystem.Unpredictables.RNG; -import static java.util.stream.Collectors.joining; import static org.junit.jupiter.api.Assertions.fail; import java.net.URI; import java.net.URISyntaxException; import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpRequest.BodyPublishers; -import java.net.http.HttpRequest.Builder; import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandlers; import java.util.stream.Stream; @@ -24,14 +20,14 @@ import com.mastercard.test.flow.assrt.AbstractFlocessor.State; import com.mastercard.test.flow.assrt.Reporting; import com.mastercard.test.flow.assrt.junit5.Flocessor; -import com.mastercard.test.flow.msg.ExposedMasking; -import com.mastercard.test.flow.msg.http.HttpMsg; import com.mastercard.test.flow.msg.http.HttpReq; -import com.mastercard.test.flow.msg.http.HttpRes; -import com.mastercard.test.flow.msg.txt.Text; import dev.flowfty.bowlby.model.BowlbySystem; +import dev.flowfty.bowlby.model.BowlbySystem.Actors; import dev.flowty.bowlby.app.cfg.Parameters; +import dev.flowty.bowlby.test.HttpFlow; +import dev.flowty.bowlby.test.MockHost; +import dev.flowty.bowlby.test.TestLog; /** * Exercises bowlby in isolation @@ -40,15 +36,25 @@ class MainTest { private static Main app; + private static URI target; + private static MockHost github; private static final HttpClient HTTP = HttpClient.newBuilder().build(); /** - * Starts the bowlby instance + * Starts the bowlby instance and github mock + * + * @throws URISyntaxException if we fail to build the target uri */ @BeforeAll - static void start() { - app = new Main( new Parameters( "-p", "0" ) ); + static void start() throws URISyntaxException { + github = new MockHost( Actors.GITHUB ); + github.start(); + app = new Main( new Parameters( + "-p", "0", + "-g", "http:/" + github.address(), + "-t", "_auth_token_" ) ); app.start(); + target = new URI( "http:/" + app.address() ); } /** @@ -59,15 +65,20 @@ Stream tests() { return new Flocessor( "isolation", BowlbySystem.MODEL ) .system( State.FUL, BOWLBY ) .masking( BORING, RNG ) + .logs( TestLog.TAIL ) .reporting( Reporting.FAILURES ) .behaviour( asrt -> { - HttpReq req = (HttpReq) asrt.expected().request().child(); + HttpReq request = (HttpReq) asrt.expected().request().child(); try { + github.seedResponses( asrt ); + HttpResponse response = HTTP.send( - map( req ), + HttpFlow.sendable( target, request ), BodyHandlers.ofString() ); + asrt.actual() - .response( content( response ) ); + .response( HttpFlow.assertable( response ) ); + asrt.assertConsequests( github.captured() ); } catch( Exception e ) { fail( e ); @@ -76,40 +87,13 @@ Stream tests() { .tests(); } - private static HttpRequest map( HttpReq req ) { - try { - URI uri = new URI( String.format( "http:/%s%s", app.address(), req.path() ) ); - Builder builder = HttpRequest.newBuilder() - .uri( uri ) - .method( req.method(), - BodyPublishers.ofByteArray( req.body() - .map( ExposedMasking::content ) - .orElse( new byte[0] ) ) ); - - req.headers().forEach( builder::header ); - return builder.build(); - } - catch( URISyntaxException e ) { - throw new IllegalStateException( e ); - } - } - - private static byte[] content( HttpResponse response ) { - HttpRes res = new HttpRes(); - res.set( HttpRes.STATUS, response.statusCode() ); - response.headers().map().forEach( ( n, v ) -> res.set( - HttpMsg.header( n ), - v.stream().collect( joining( "," ) ) ) ); - res.set( HttpMsg.BODY, new Text( response.body() ) ); - return res.content(); - } - /** - * Stops the bowlby instance + * Stops the bowlby instance and the github mock */ @AfterAll static void stop() { app.stop(); + github.stop(); } } diff --git a/assert/src/main/resources/simplelogger.properties b/app/src/test/resources/simplelogger.properties similarity index 100% rename from assert/src/main/resources/simplelogger.properties rename to app/src/test/resources/simplelogger.properties diff --git a/assert/src/main/java/dev/flowty/bowlby/test/HttpFlow.java b/assert/src/main/java/dev/flowty/bowlby/test/HttpFlow.java new file mode 100644 index 0000000..8262f7e --- /dev/null +++ b/assert/src/main/java/dev/flowty/bowlby/test/HttpFlow.java @@ -0,0 +1,62 @@ +package dev.flowty.bowlby.test; + +import static java.util.stream.Collectors.joining; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpRequest.Builder; +import java.net.http.HttpResponse; + +import com.mastercard.test.flow.msg.ExposedMasking; +import com.mastercard.test.flow.msg.http.HttpMsg; +import com.mastercard.test.flow.msg.http.HttpReq; +import com.mastercard.test.flow.msg.http.HttpRes; +import com.mastercard.test.flow.msg.txt.Text; + +/** + * A bridge between the flow data model and {@link HttpClient}. + */ +public class HttpFlow { + + /** + * Builds a request from flow data + * + * @param host The target of the request + * @param req The request content + * @return A sendable request + */ + public static HttpRequest sendable( URI host, HttpReq req ) { + URI uri = host.resolve( req.path() ); + Builder builder = HttpRequest.newBuilder() + .uri( uri ) + .method( req.method(), + BodyPublishers.ofByteArray( req.body() + .map( ExposedMasking::content ) + .orElse( new byte[0] ) ) ); + + req.headers().forEach( builder::header ); + return builder.build(); + } + + /** + * Converts a response into assertable content + * + * @param response The response + * @return the message content to supply to flow + */ + public static byte[] assertable( HttpResponse response ) { + HttpRes res = new HttpRes(); + if( response.version() == Version.HTTP_1_1 ) { + res.set( HttpMsg.VERSION, "HTTP/1.1" ); + } + res.set( HttpRes.STATUS, response.statusCode() ); + response.headers().map().forEach( ( n, v ) -> res.set( + HttpMsg.header( n ), + v.stream().collect( joining( "," ) ) ) ); + res.set( HttpMsg.BODY, new Text( response.body() ) ); + return res.content(); + } +} diff --git a/model/src/main/java/dev/flowfty/bowlby/model/BowlbySystem.java b/model/src/main/java/dev/flowfty/bowlby/model/BowlbySystem.java index f8f32c3..988d74d 100644 --- a/model/src/main/java/dev/flowfty/bowlby/model/BowlbySystem.java +++ b/model/src/main/java/dev/flowfty/bowlby/model/BowlbySystem.java @@ -6,6 +6,7 @@ import com.mastercard.test.flow.model.LazyModel; import dev.flowfty.bowlby.model.flow.Index; +import dev.flowfty.bowlby.model.flow.Latest; /** * Models the expected behaviour of the system @@ -36,7 +37,7 @@ public enum Actors implements Actor { /** * The mysterious download host where the artifacts are supplied from */ - ARTIFACT_HOST; + ARTIFACTS; } /** @@ -58,6 +59,7 @@ public enum Unpredictables implements Unpredictable { */ public static final Model MODEL = new LazyModel( "Bowlby" ) .with( Index.class ) + .with( Latest.class ) ; } diff --git a/model/src/main/java/dev/flowfty/bowlby/model/flow/Index.java b/model/src/main/java/dev/flowfty/bowlby/model/flow/Index.java index 354e4f2..f9aa4ed 100644 --- a/model/src/main/java/dev/flowfty/bowlby/model/flow/Index.java +++ b/model/src/main/java/dev/flowfty/bowlby/model/flow/Index.java @@ -1,35 +1,44 @@ package dev.flowfty.bowlby.model.flow; -import static dev.flowfty.bowlby.model.BowlbySystem.Actors.BROWSER; -import static dev.flowfty.bowlby.model.BowlbySystem.Actors.USER; +import static dev.flowfty.bowlby.model.msg.ntr.Interactions.BROWSER; +import static dev.flowfty.bowlby.model.msg.ntr.Interactions.rq; +import static dev.flowfty.bowlby.model.msg.ntr.Interactions.rs; import com.mastercard.test.flow.Flow; import com.mastercard.test.flow.builder.Creator; +import com.mastercard.test.flow.builder.Deriver; import com.mastercard.test.flow.model.EagerModel; +import com.mastercard.test.flow.msg.http.HttpReq; +import com.mastercard.test.flow.msg.http.HttpRes; import com.mastercard.test.flow.util.TaggedGroup; import com.mastercard.test.flow.util.Tags; import dev.flowfty.bowlby.model.BowlbySystem.Actors; import dev.flowfty.bowlby.model.msg.HttpMessage; import dev.flowfty.bowlby.model.msg.WebMessage; +import dev.flowfty.bowlby.model.msg.ntr.Interactions; /** * Flows that explore the behaviour of the index */ public class Index extends EagerModel { /***/ - public static final TaggedGroup MODEL_TAGS = new TaggedGroup(); + public static final TaggedGroup MODEL_TAGS = new TaggedGroup( "200", "404" ); + + public Flow get; /***/ public Index() { super( MODEL_TAGS ); - Flow index = Creator.build( flow -> flow + get = Creator.build( flow -> flow .meta( data -> data - .description( "index" ) ) + .description( "root" ) + .tags( Tags.add( "200" ) ) + .motivation( "Displaying the root page" ) ) .call( web -> web - .from( USER ) - .to( BROWSER ) + .from( Actors.USER ) + .to( Actors.BROWSER ) .request( WebMessage.index() ) .call( html -> html .to( Actors.BOWLBY ) @@ -43,6 +52,22 @@ public Index() { .response( HttpMessage.iconResponse() ) ) .response( WebMessage.dumpPage() ) ) ); - members( flatten( index ) ); + Flow notFound = Deriver.build( get, flow -> flow + .meta( data -> data + .description( "not found" ) + .tags( Tags.add( "404" ) ) + .motivation( "Requesting a non-existent page" ) ) + .prerequisite( get ) + .update( BROWSER, + rq( "path", "/no_such_file" ), + rs( "header", "[bowlby](http://[::]:56567/)", + "url", "http://[::]:56567/no_such_file" ) ) + .update( Interactions.BOWLBY, + rq( HttpReq.PATH, "/no_such_file" ), + rs( HttpRes.STATUS, 404, + "/html/body/h1/a/@href", "/" ) ) + .removeCall( ntr -> ntr.tags().contains( "icon" ) ) ); + + members( flatten( get, notFound ) ); } } diff --git a/model/src/main/java/dev/flowfty/bowlby/model/flow/Latest.java b/model/src/main/java/dev/flowfty/bowlby/model/flow/Latest.java new file mode 100644 index 0000000..1d3a08a --- /dev/null +++ b/model/src/main/java/dev/flowfty/bowlby/model/flow/Latest.java @@ -0,0 +1,125 @@ +package dev.flowfty.bowlby.model.flow; + +import com.mastercard.test.flow.Flow; +import com.mastercard.test.flow.builder.Chain; +import com.mastercard.test.flow.builder.Creator; +import com.mastercard.test.flow.builder.Deriver; +import com.mastercard.test.flow.model.EagerModel; +import com.mastercard.test.flow.msg.json.Json; +import com.mastercard.test.flow.util.TaggedGroup; +import com.mastercard.test.flow.util.Tags; + +import dev.flowfty.bowlby.model.BowlbySystem.Actors; +import dev.flowfty.bowlby.model.msg.ApiMessage; +import dev.flowfty.bowlby.model.msg.HttpMessage; +import dev.flowfty.bowlby.model.msg.WebMessage; +import dev.flowfty.bowlby.model.msg.ntr.Interactions; + +/** + * Flows that explore the get-latest-artifact behaviour + */ +public class Latest extends EagerModel { + + /***/ + public static final TaggedGroup MODEL_TAGS = new TaggedGroup( "200", "404" ); + + public Latest( Index index ) { + super( MODEL_TAGS ); + + Chain chain = new Chain( "workflow" ); + + Flow get = Deriver.build( index.get, + flow -> flow + .meta( data -> data + .description( "get form" ) + .motivation( + """ + This chain illustrates the generation of stable links to the artifacts of the latest run of a workflow. + We start by visiting the bowlby root page, which offers a form that accepts links to github workflows.""" ) ) + .prerequisite( index.get ) + .removeCall( Interactions.FAVICON ), + chain ); + + Flow submit = Creator.build( flow -> flow + .meta( data -> data + .description( "submit link" ) + .tags( Tags.add( "latest", "submit", "workflow" ) ) + .motivation( + """ + Submitting a workflow link via the form. Bowlby will: + 1. Redirect to the appropriate handler with the workflow repo details and name in the path + 1. Query github to find: + * The default branch of the repo + * The latest run of the workflow on that branch + * The artifacts for that run + 1. Display a [300-status](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/300) [redirection page](https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections#special_redirections) with links to those artifacts + + The displayed links are stable over time - they will always redirect to the artifacts of the most recent run of the default branch. + Users can append path elements to these links to address files within artifacts.""" ) ) + .prerequisite( get ) + .call( a -> a + .from( Actors.USER ) + .to( Actors.BROWSER ) + .request( WebMessage.submit( + "https://github.com/therealryan/bowlby/actions/workflows/artifacts.yml" ) ) + .call( b -> b + .to( Actors.BOWLBY ).tags( Tags.add( "submit" ) ) + .request( HttpMessage.chromeRequest( "GET", + "/?link=https%3A%2F%2Fgithub.com%2Ftherealryan%2Fbowlby%2Factions%2Fworkflows%2Fartifacts.yml" ) ) + .response( + HttpMessage.redirectResponse( "/latest/therealryan/bowlby/artifacts.yml" ) ) ) + .call( b -> b + .to( Actors.BOWLBY ).tags( Tags.add( "latest" ) ) + .request( HttpMessage.chromeRequest( + "GET", "/latest/therealryan/bowlby/artifacts.yml" ) ) + .call( c -> c.to( Actors.GITHUB ) + .tags( Tags.add( "branch" ) ) + .request( ApiMessage.request( "/repos/therealryan/bowlby" ) ) + .response( ApiMessage.response( + "default_branch", "main", + "license", Json.EMPTY_MAP, + "owner", Json.EMPTY_MAP, + "permissions", Json.EMPTY_MAP, + "topics", Json.EMPTY_LIST ) ) ) + .call( c -> c.to( Actors.GITHUB ) + .tags( Tags.add( "run" ) ) + .request( ApiMessage.request( + "/repos/therealryan/bowlby/actions/workflows/artifacts.yml/runs?branch=main&status=completed&per_page=1" ) ) + .response( ApiMessage.response( + "workflow_runs[0].id", 10249960639L, + "workflow_runs[0].actor", Json.EMPTY_MAP, + "workflow_runs[0].head_commit.author", Json.EMPTY_MAP, + "workflow_runs[0].head_commit.committer", Json.EMPTY_MAP, + "workflow_runs[0].head_repository.owner", Json.EMPTY_MAP, + "workflow_runs[0].pull_requests", Json.EMPTY_LIST, + "workflow_runs[0].referenced_workflows", Json.EMPTY_LIST, + "workflow_runs[0].repository.owner", Json.EMPTY_MAP, + "workflow_runs[0].triggering_actor", Json.EMPTY_MAP ) ) ) + .call( c -> c.to( Actors.GITHUB ) + .tags( Tags.add( "artifacts" ) ) + .request( ApiMessage.request( + "/repos/therealryan/bowlby/actions/runs/10249960639/artifacts" ) ) + .response( ApiMessage.response( + "artifacts[0].id", 1776512962, + "artifacts[0].name", "artifact_alpha", + "artifacts[0].workflow_run", Json.EMPTY_MAP, + "artifacts[1].id", 1776513003, + "artifacts[1].name", "artifact_beta", + "artifacts[1].workflow_run", Json.EMPTY_MAP ) ) ) + .response( HttpMessage.linkChoiceResponse( "artifact_alpha", "artifact_beta" ) ) ) + .response( WebMessage.dumpPage() + .set( "header", "[bowlby](http://[::]:56567/)" ) + .set( "forms", "" ) + .set( "lists", + """ + [artifact_alpha](http://[::]:33065/latest/therealryan/bowlby/artifacts.yml/artifact_alpha) + [artifact_beta](http://[::]:33065/latest/therealryan/bowlby/artifacts.yml/artifact_beta)""" ) + .set( "text", + "These stable links will redirect to the latest artifacts for the artifacts.yml workflow on the default branch." + + " Feel free to append path components to address files within the artifacts." ) + .set( "url", "http://[::]:56567/latest/therealryan/bowlby/artifacts.yml" ) ) ), + chain ); + + members( flatten( get, submit ) ); + } +} diff --git a/model/src/main/java/dev/flowfty/bowlby/model/msg/ApiMessage.java b/model/src/main/java/dev/flowfty/bowlby/model/msg/ApiMessage.java new file mode 100644 index 0000000..ba7bb56 --- /dev/null +++ b/model/src/main/java/dev/flowfty/bowlby/model/msg/ApiMessage.java @@ -0,0 +1,53 @@ +package dev.flowfty.bowlby.model.msg; + +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +import com.mastercard.test.flow.Message; +import com.mastercard.test.flow.msg.http.HttpMsg; +import com.mastercard.test.flow.msg.http.HttpReq; +import com.mastercard.test.flow.msg.http.HttpRes; +import com.mastercard.test.flow.msg.json.Json; + +import dev.flowfty.bowlby.model.BowlbySystem.Unpredictables; + +/** + * Provides github api messages + */ +public class ApiMessage { + + private ApiMessage() { + // no instances + } + + public static Message request( String path ) { + return new HttpReq() + .set( HttpReq.METHOD, "GET" ) + .set( HttpReq.PATH, path ) + .set( HttpMsg.header( "accept" ), "application/vnd.github+json" ) + .set( HttpMsg.header( "authorization" ), "Bearer _auth_token_" ) + .set( HttpMsg.header( "x-github-api-version" ), "2022-11-28" ) + .masking( Unpredictables.RNG, m -> m + .delete( Stream.of( + "connection", "host", "http2-settings", "upgrade", "user-agent" ) + .map( HttpMsg::header ) ) ); + } + + public static Message response( Object... nvp ) { + HttpRes res = new HttpRes() + .set( HttpRes.STATUS, 200 ) + .set( HttpMsg.BODY, new Json() ); + Set populated = new TreeSet<>(); + populated.add( HttpRes.STATUS ); + populated.add( HttpMsg.BODY ); + + for( int i = 0; i < nvp.length - 1; i += 2 ) { + res.set( (String) nvp[i], nvp[i + 1] ); + populated.add( (String) nvp[i] ); + } + res.masking( Unpredictables.BORING, m -> m + .retain( populated ) ); + return res; + } +} diff --git a/model/src/main/java/dev/flowfty/bowlby/model/msg/Bytes.java b/model/src/main/java/dev/flowfty/bowlby/model/msg/Bytes.java new file mode 100644 index 0000000..9df92cf --- /dev/null +++ b/model/src/main/java/dev/flowfty/bowlby/model/msg/Bytes.java @@ -0,0 +1,231 @@ +package dev.flowfty.bowlby.model.msg; + +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.mastercard.test.flow.msg.AbstractMessage; + +/** + * Raw byte array message. + *
    + *
  • individual bytes are addressed by zero-based index
  • + *
  • byte ranges are address by zero-based index ranges, e.g.: + *
      + *
    • x..y addresses the bytes from index x + * (inclusive) to y (exclusive)
    • + *
    • ..y addresses the bytes from index 0 + * (inclusive) to y (exclusive)
    • + *
    • x.. addresses the bytes from index x + * (inclusive) to the end of the array
    • + *
    • .. addresses the bytes from index 0 (inclusive) + * to the end of the array
    • + *
    + *
  • + *
+ */ +public class Bytes extends AbstractMessage { + + private final Supplier base; + + /** + * Builds a new, empty, message + */ + public Bytes() { + base = () -> new byte[0]; + } + + /** + * Builds a new message + * + * @param content The message content + */ + public Bytes( byte[] content ) { + byte[] copy = copy( content ); + base = () -> copy; + } + + private Bytes( Bytes parent ) { + base = parent::build; + } + + @Override + public Bytes child() { + return copyMasksTo( new Bytes( this ) ); + } + + @Override + public Bytes peer( byte[] content ) { + return copyMasksTo( new Bytes( content ) ); + } + + private byte[] build() { + byte[] array = base.get(); + for( Update update : updates ) { + byte[] content; + if( update.value() == DELETE ) { + content = new byte[0]; + } + else if( update.value() instanceof Byte ) { + content = new byte[] { (byte) update.value() }; + } + else { + content = (byte[]) update.value(); + } + + array = new Range( update.field() ).replace( content, array ); + } + return array; + } + + @Override + public byte[] content() { + return build(); + } + + @Override + public Set fields() { + // not supported - how do you enumerate all the ways the bytes could be + // addressed? + return Collections.emptySet(); + } + + @Override + protected String asHuman() { + StringBuilder sb = new StringBuilder(); + for( byte b : build() ) { + sb.append( String.format( "0b%s 0d%03d 0x%02X %s\n", + Integer.toBinaryString( (b & 0xFF) + 0x100 ).substring( 1 ), + b & 0xFF, + b, + 0 <= b // if the high bit isn't set... + ? Character.getName( b ) // ...then it's a valid character + : "" ) ); + } + return sb.toString(); + } + + @Override + protected byte[] access( String field ) { + return new Range( field ).infix( build() ); + } + + private static final byte[] copy( byte[] array ) { + byte[] copy = new byte[array.length]; + System.arraycopy( array, 0, copy, 0, copy.length ); + return copy; + } + + @Override + public Bytes set( String field, Object value ) { + // we want this to fail immediately so the exception traces to where the call is + // being made rather than where the message is being compiled + @SuppressWarnings("unused") + Range r = new Range( field ); + return super.set( field, value ); + } + + private static class Range { + private final int start; + private final int end; + private static final Pattern INDEX_PATTERN = Pattern.compile( "(\\d+)" ); + private static final Pattern RANGE_PATTERN = Pattern.compile( "(\\d*)\\.\\.(\\d*)" ); + + /** + * @param s The range specification + */ + Range( String s ) { + if( s == null ) { + throw new IllegalArgumentException( "'" + s + "' is not a valid range" ); + } + + Matcher im = INDEX_PATTERN.matcher( s ); + Matcher rm = RANGE_PATTERN.matcher( s ); + if( im.matches() ) { + start = Integer.parseInt( im.group( 1 ) ); + // false positive on 'Replaced integer addition with subtraction → SURVIVED' + end = start + 1; + } + else if( rm.matches() ) { + start = Optional.of( rm.group( 1 ) ) + // false positive on 'replaced boolean return with true for + // com/mastercard/test/flow/msg/bytes/Bytes$Range::lambda$new$0 → SURVIVED' + .filter( d -> !d.isEmpty() ) + .map( Integer::parseInt ) + .orElse( 0 ); + end = Optional.of( rm.group( 2 ) ) + .filter( d -> !d.isEmpty() ) + .map( Integer::parseInt ) + .orElse( Integer.MAX_VALUE ); + } + else { + throw new IllegalArgumentException( "'" + s + "' is not a valid range" ); + } + + // false postive on 'changed conditional boundary → SURVIVED' + if( start > end ) { + throw new IllegalArgumentException( "'" + s + "' is not a valid range" ); + } + } + + /** + * Extracts the prefix content of this range + * + * @param array an array + * @return The bytes of the supplied array that come before this range + */ + byte[] prefix( byte[] array ) { + int s = Math.min( start, array.length ); + byte[] prefix = new byte[s]; + System.arraycopy( array, 0, prefix, 0, prefix.length ); + return prefix; + } + + /** + * Extracts the infix content of this range + * + * @param array an array + * @return The bytes of the array that lie in this range + */ + byte[] infix( byte[] array ) { + int s = Math.min( start, array.length ); + int e = Math.min( end, array.length ); + byte[] infix = new byte[e - s]; + System.arraycopy( array, s, infix, 0, infix.length ); + return infix; + } + + /** + * Extracts the suffix content of this range + * + * @param array an array + * @return The bytes of the supplied array that come after this range + */ + byte[] suffix( byte[] array ) { + int e = Math.min( end, array.length ); + byte[] suffix = new byte[array.length - e]; + System.arraycopy( array, e, suffix, 0, suffix.length ); + return suffix; + } + + /** + * Inserts content into this range in an array + * + * @param content The content to insert + * @param array The array to insert into + * @return the new array + */ + byte[] replace( byte[] content, byte[] array ) { + byte[] prefix = prefix( array ); + byte[] suffix = suffix( array ); + byte[] combined = new byte[prefix.length + content.length + suffix.length]; + System.arraycopy( prefix, 0, combined, 0, prefix.length ); + System.arraycopy( content, 0, combined, prefix.length, content.length ); + System.arraycopy( suffix, 0, combined, prefix.length + content.length, suffix.length ); + return combined; + } + } +} diff --git a/model/src/main/java/dev/flowfty/bowlby/model/msg/HttpMessage.java b/model/src/main/java/dev/flowfty/bowlby/model/msg/HttpMessage.java index 4060cbd..8199456 100644 --- a/model/src/main/java/dev/flowfty/bowlby/model/msg/HttpMessage.java +++ b/model/src/main/java/dev/flowfty/bowlby/model/msg/HttpMessage.java @@ -1,12 +1,14 @@ package dev.flowfty.bowlby.model.msg; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.stream.Collectors.joining; + import java.util.stream.Stream; import com.mastercard.test.flow.Message; import com.mastercard.test.flow.msg.http.HttpMsg; import com.mastercard.test.flow.msg.http.HttpReq; import com.mastercard.test.flow.msg.http.HttpRes; -import com.mastercard.test.flow.msg.txt.Text; import com.mastercard.test.flow.msg.xml.XML; import dev.flowfty.bowlby.model.BowlbySystem.Unpredictables; @@ -38,6 +40,22 @@ public static Message chromeRequest( String method, String path ) { .map( HttpMsg::header ) ) ); } + /** + * Builds a redirect request + * + * @param location the location header + * @return the message + */ + public static Message redirectResponse( String location ) { + return new HttpRes() + .set( HttpMsg.VERSION, "HTTP/1.1" ) + .set( HttpRes.STATUS, 303 ) + .set( HttpMsg.header( "content-length" ), 0 ) + .set( HttpMsg.header( "location" ), location ) + .masking( Unpredictables.BORING, m -> m + .delete( HttpMsg.header( "date" ) ) ); + } + /** * Builds the basic bowlby index response page * @@ -46,6 +64,7 @@ public static Message chromeRequest( String method, String path ) { */ public static Message bowlbyResponse( int status ) { return new HttpRes() + .set( HttpMsg.VERSION, "HTTP/1.1" ) .set( HttpRes.STATUS, status ) .set( HttpMsg.header( "content-type" ), "text/html; charset=utf-8" ) .set( HttpMsg.BODY, new XML() ) @@ -66,6 +85,36 @@ public static Message bowlbyResponse( int status ) { .map( HttpMsg::header ) ) ); } + public static Message linkChoiceResponse( String... artifacts ) { + HttpRes res = new HttpRes() + .set( HttpMsg.VERSION, "HTTP/1.1" ) + .set( HttpRes.STATUS, 300 ) + .set( HttpMsg.header( "content-type" ), "text/html; charset=utf-8" ) + .set( HttpMsg.BODY, new XML() ) + .set( "/html/head/title", "bowlby" ) + .set( "/html/body/h1/a", "bowlby" ) + .set( "/html/body/h1/a/@href", "/" ) + .set( "/html/body/p", + """ + These stable links will redirect to the latest artifacts for the artifacts.yml workflow on the default branch.\ + Feel free to append path components to address files within the artifacts.""" ) + .masking( Unpredictables.BORING, m -> m + .delete( Stream.of( + "content-length", "date" ) + .map( HttpMsg::header ) ) ); + + for( int i = 0; i < artifacts.length; i++ ) { + String link = "/latest/therealryan/bowlby/artifacts.yml/" + artifacts[i]; + res.set( "/html/body/ul/li[" + i + "]/a/@href", link ); + res.set( "/html/body/ul/li[" + i + "]/a", artifacts[i] ); + } + res.set( HttpMsg.header( "link" ), Stream.of( artifacts ) + .map( a -> "; rel=alternate" ) + .collect( joining( "," ) ) ); + + return res; + } + /** * Builds bowlby's icon response. Note that we're not bothering to model the * icon content @@ -74,12 +123,14 @@ public static Message bowlbyResponse( int status ) { */ public static Message iconResponse() { return new HttpRes() + .set( HttpMsg.VERSION, "HTTP/1.1" ) .set( HttpRes.STATUS, 200 ) .set( HttpMsg.header( "cache-control" ), "max-age=31536000, immutable" ) .set( HttpMsg.header( "content-type" ), "image/vnd.microsoft.icon" ) - .set( HttpMsg.BODY, new Text( "image bytes, which we're not modelling in tests" ) ) + .set( HttpMsg.BODY, + new Bytes( "image bytes, which we're not modelling in tests".getBytes( UTF_8 ) ) ) .masking( Unpredictables.BORING, m -> m - .replace( ".+", "_masked_" ) + .replace( "..", "_masked_".getBytes( UTF_8 ) ) .delete( Stream.of( "content-length", "date" ) .map( HttpMsg::header ) ) ); diff --git a/model/src/main/java/dev/flowfty/bowlby/model/msg/WebMessage.java b/model/src/main/java/dev/flowfty/bowlby/model/msg/WebMessage.java index 0f1cd3f..57c8bc8 100644 --- a/model/src/main/java/dev/flowfty/bowlby/model/msg/WebMessage.java +++ b/model/src/main/java/dev/flowfty/bowlby/model/msg/WebMessage.java @@ -27,8 +27,27 @@ private WebMessage() { public static Message index() { return new WebSequence() .set( "bowlby_url", "http://determinedatruntime.com" ) + .set( "path", "/" ) .operation( "index", ( driver, params ) -> { - driver.navigate().to( params.get( "bowlby_url" ) ); + driver.navigate().to( params.get( "bowlby_url" ) + params.get( "path" ) ); + } ) + .masking( Unpredictables.RNG, m -> m + .replace( "bowlby_url", "_masked_" ) ); + } + + /** + * @param workflow A workflow URL + * @return submits a workflow url into the form + */ + public static Message submit( String workflow ) { + return new WebSequence() + .set( "bowlby_url", "http://determinedatruntime.com" ) + .set( "workflow_link", workflow ) + .operation( "submit", ( driver, params ) -> { + driver.findElement( By.id( "link_input" ) ) + .sendKeys( params.get( "workflow_link" ) ); + driver.findElement( By.id( "submit_input" ) ) + .click(); } ) .masking( Unpredictables.RNG, m -> m .replace( "bowlby_url", "_masked_" ) ); @@ -54,7 +73,11 @@ public static Message dumpPage() { params.put( "lists", summarise( driver, "ul" ) ); } ) .masking( Unpredictables.RNG, m -> m - .string( "url", s -> s.replaceAll( "^.*:\\d+(.*)$", "_masked_$1" ) ) ); + .string( "url", s -> s.replaceAll( "^.*:\\d+(.*)$", "_masked_$1" ) ) + .string( "header", s -> s.replaceAll( "\\(.*:\\d+", "_masked_" ) ) + .string( "lists", s -> s.replaceAll( "\\(.*:\\d+", "_masked_" ) ) + .string( "text", s -> s.replaceAll( + "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d+Z", "_masked_" ) ) ); } private static String summarise( WebDriver driver, String tag ) { @@ -77,10 +100,19 @@ private static String summarise( WebDriver driver, WebElement e ) { .map( input -> input.getAttribute( "name" ) + ":" + input.getAttribute( "type" ) ) .collect( joining( "," ) ) ); } - if( "a".equals( e.getTagName() ) || "a".equals( e.getTagName() ) ) { + if( "a".equals( e.getTagName() ) ) { return "[" + e.getText() + "](" + e.getAttribute( "href" ) + ")"; } - if( "h1".equals( e.getTagName() ) || "a".equals( e.getTagName() ) ) { + if( "h1".equals( e.getTagName() ) ) { + return summariseChildren( driver, e, "a" ); + } + if( "p".equals( e.getTagName() ) ) { + return e.getText(); + } + if( "ul".equals( e.getTagName() ) ) { + return summariseChildren( driver, e, "li" ); + } + if( "li".equals( e.getTagName() ) ) { return summariseChildren( driver, e, "a" ); } diff --git a/model/src/main/java/dev/flowfty/bowlby/model/msg/ntr/Interactions.java b/model/src/main/java/dev/flowfty/bowlby/model/msg/ntr/Interactions.java new file mode 100644 index 0000000..f3f2203 --- /dev/null +++ b/model/src/main/java/dev/flowfty/bowlby/model/msg/ntr/Interactions.java @@ -0,0 +1,76 @@ +package dev.flowfty.bowlby.model.msg.ntr; + +import java.util.function.Consumer; + +import com.mastercard.test.flow.Message; +import com.mastercard.test.flow.builder.mutable.MutableInteraction; +import com.mastercard.test.flow.util.InteractionPredicate; + +import dev.flowfty.bowlby.model.BowlbySystem.Actors; + +/** + * Functions for working with interations + */ +public class Interactions { + + private Interactions() { + // no instances + } + + /** + * Identifies interactions with the browser + */ + public static final InteractionPredicate BROWSER = new InteractionPredicate() + .to( Actors.BROWSER ); + + /** + * Identifies interactions with bowlby + */ + public static final InteractionPredicate BOWLBY = new InteractionPredicate() + .to( Actors.BOWLBY ) + .without( "icon" ); + /** + * Identifies interactions with bowlby + */ + public static final InteractionPredicate FAVICON = BOWLBY + .with( "icon" ) + .without(); + + /** + * Identifies interactions with github + */ + public static final InteractionPredicate GITHUB = new InteractionPredicate() + .to( Actors.GITHUB ); + + /** + * Identifies interactions with the artifact host + */ + public static final InteractionPredicate ARTIFACTS = new InteractionPredicate() + .to( Actors.ARTIFACTS ); + + /** + * Sets request message fields + * + * @param nvp name/value pairs + * @return A message-building operation + */ + public static Consumer rq( Object... nvp ) { + return ntr -> set( ntr.request(), nvp ); + } + + /** + * Sets response message fields + * + * @param nvp name/value pairs + * @return A message-building operation + */ + public static Consumer rs( Object... nvp ) { + return ntr -> set( ntr.response(), nvp ); + } + + private static void set( Message msg, Object... nvp ) { + for( int i = 0; i < nvp.length - 1; i += 2 ) { + msg.set( String.valueOf( nvp[i] ), nvp[i + 1] ); + } + } +} diff --git a/test/src/test/java/dev/flowty/bowlby/it/ApiIT.java b/test/src/test/java/dev/flowty/bowlby/it/ApiIT.java new file mode 100644 index 0000000..ecee441 --- /dev/null +++ b/test/src/test/java/dev/flowty/bowlby/it/ApiIT.java @@ -0,0 +1,78 @@ +package dev.flowty.bowlby.it; + +import static dev.flowfty.bowlby.model.BowlbySystem.Actors.GITHUB; +import static dev.flowfty.bowlby.model.BowlbySystem.Unpredictables.BORING; +import static dev.flowfty.bowlby.model.BowlbySystem.Unpredictables.RNG; +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DynamicNode; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import com.mastercard.test.flow.assrt.AbstractFlocessor.State; +import com.mastercard.test.flow.assrt.Reporting; +import com.mastercard.test.flow.assrt.junit5.Flocessor; +import com.mastercard.test.flow.msg.http.HttpMsg; +import com.mastercard.test.flow.msg.http.HttpReq; + +import dev.flowfty.bowlby.model.BowlbySystem; +import dev.flowty.bowlby.test.HttpFlow; +import dev.flowty.bowlby.test.TestLog; + +/** + * Exercises github in isolation + */ +@SuppressWarnings("static-method") +@EnabledIfEnvironmentVariable(named = "BOWLBY_GH_AUTH_TOKEN", matches = ".+", + disabledReason = "auth token required to exercise github integration") +public class ApiIT { + + private static final HttpClient HTTP = HttpClient.newBuilder().build(); + private static final URI TARGET; + static { + try { + TARGET = new URI( "https://api.github.com" ); + } + catch( URISyntaxException e ) { + throw new IllegalArgumentException( "Bad github URI", e ); + } + } + + /** + * @return test instances + */ + @TestFactory + Stream tests() { + return new Flocessor( "github", BowlbySystem.MODEL ) + .system( State.FUL, GITHUB ) + .masking( BORING, RNG ) + .logs( TestLog.TAIL ) + .reporting( Reporting.ALWAYS, "github" ) + .behaviour( asrt -> { + HttpReq request = (HttpReq) asrt.expected().request().child(); + + request.set( HttpMsg.header( "authorization" ), + "Bearer " + System.getenv( "BOWLBY_GH_AUTH_TOKEN" ) ); + + try { + HttpResponse response = HTTP.send( + HttpFlow.sendable( TARGET, request ), + BodyHandlers.ofString() ); + + asrt.actual() + .response( HttpFlow.assertable( response ) ); + } + catch( Exception e ) { + fail( e ); + } + } ) + .tests(); + } +} diff --git a/test/src/test/java/dev/flowty/bowlby/test/End2EndIT.java b/test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java similarity index 94% rename from test/src/test/java/dev/flowty/bowlby/test/End2EndIT.java rename to test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java index 49d0218..a60c20f 100644 --- a/test/src/test/java/dev/flowty/bowlby/test/End2EndIT.java +++ b/test/src/test/java/dev/flowty/bowlby/it/End2EndIT.java @@ -1,6 +1,6 @@ -package dev.flowty.bowlby.test; +package dev.flowty.bowlby.it; -import static dev.flowfty.bowlby.model.BowlbySystem.Actors.ARTIFACT_HOST; +import static dev.flowfty.bowlby.model.BowlbySystem.Actors.ARTIFACTS; import static dev.flowfty.bowlby.model.BowlbySystem.Actors.BOWLBY; import static dev.flowfty.bowlby.model.BowlbySystem.Actors.BROWSER; import static dev.flowfty.bowlby.model.BowlbySystem.Actors.GITHUB; @@ -25,6 +25,7 @@ import dev.flowfty.bowlby.model.BowlbySystem; import dev.flowty.bowlby.app.Main; import dev.flowty.bowlby.app.cfg.Parameters; +import dev.flowty.bowlby.test.TestLog; /** * Exercises the complete system, clicking at the browser and hitting github on @@ -33,7 +34,7 @@ @SuppressWarnings("static-method") @EnabledIfEnvironmentVariable(named = "BOWLBY_GH_AUTH_TOKEN", matches = ".+", disabledReason = "auth token required to exercise github integration") -class End2EndIT { +class EndToEndIT { private static Main app; @@ -52,7 +53,7 @@ static void start() { @TestFactory Stream tests() { return new Flocessor( "end-to-end", BowlbySystem.MODEL ) - .system( State.FUL, BROWSER, BOWLBY, GITHUB, ARTIFACT_HOST ) + .system( State.FUL, BROWSER, BOWLBY, GITHUB, ARTIFACTS ) .masking( BORING, RNG ) .logs( TestLog.TAIL ) .reporting( Reporting.ALWAYS, "e2e" ) diff --git a/test/src/test/java/dev/flowty/bowlby/test/BrowserIT.java b/test/src/test/java/dev/flowty/bowlby/test/BrowserTest.java similarity index 99% rename from test/src/test/java/dev/flowty/bowlby/test/BrowserIT.java rename to test/src/test/java/dev/flowty/bowlby/test/BrowserTest.java index 0307645..f224851 100644 --- a/test/src/test/java/dev/flowty/bowlby/test/BrowserIT.java +++ b/test/src/test/java/dev/flowty/bowlby/test/BrowserTest.java @@ -25,7 +25,7 @@ * Exercises the browser in isolation */ @SuppressWarnings("static-method") -class BrowserIT { +class BrowserTest { private static MockHost mock; diff --git a/test/src/test/resources/simplelogger.properties b/test/src/test/resources/simplelogger.properties new file mode 100644 index 0000000..91252d9 --- /dev/null +++ b/test/src/test/resources/simplelogger.properties @@ -0,0 +1,11 @@ +# https://www.slf4j.org/api/org/slf4j/simple/SimpleLogger.html +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd'T'HH:mm:ss.SSSZ + +org.slf4j.simpleLogger.showLogName=true +org.slf4j.simpleLogger.showShortLogName=false +org.slf4j.simpleLogger.showThreadName=true +org.slf4j.simpleLogger.levelInBrackets=true + +org.slf4j.simpleLogger.defaultLogLevel=trace +org.slf4j.simpleLogger.logFile=target/log.txt