diff --git a/pom.xml b/pom.xml index 294419cc..bfc02bac 100644 --- a/pom.xml +++ b/pom.xml @@ -112,6 +112,12 @@ slf4j-api 2.0.13 + + org.junit.jupiter + junit-jupiter-params + 5.10.0 + test + diff --git a/src/main/java/Main.java b/src/main/java/Main.java index a533f101..41a88ac5 100644 --- a/src/main/java/Main.java +++ b/src/main/java/Main.java @@ -31,7 +31,7 @@ public static void main(String[] args) { throw new RuntimeException(e); } - new Server(endpointProvider).start(8080); + new Server(endpointProvider).start(); try { SubmissionService.reRunSubmissionsInQueue(); diff --git a/src/main/java/edu/byu/cs/controller/UserController.java b/src/main/java/edu/byu/cs/controller/UserController.java index 48e8a7c6..e03f2c77 100644 --- a/src/main/java/edu/byu/cs/controller/UserController.java +++ b/src/main/java/edu/byu/cs/controller/UserController.java @@ -18,16 +18,16 @@ import static spark.Spark.halt; public class UserController { - public static final Route repoPatch = (req, res) -> { + public static final Route setRepoUrl = (req, res) -> { User user = req.session().attribute("user"); - applyRepoPatch(user.netId(), null, req, res); + setRepoUrl(user.netId(), null, req, res); return "Successfully updated repoUrl"; }; - public static final Route repoPatchAdmin = (req, res) -> { + public static final Route setRepoUrlAdmin = (req, res) -> { User admin = req.session().attribute("user"); String studentNetId = req.params(":netId"); - applyRepoPatch(studentNetId, admin.netId(), req, res); + setRepoUrl(studentNetId, admin.netId(), req, res); return "Successfully updated repoUrl for user: " + studentNetId; }; @@ -52,7 +52,7 @@ public class UserController { return Serializer.serialize(updates); }; - private static void applyRepoPatch(String studentNetId, String adminNetId, Request req, Response res) { + private static void setRepoUrl(String studentNetId, String adminNetId, Request req, Response res) { JsonObject jsonObject = new Gson().fromJson(req.body(), JsonObject.class); String repoUrl = new Gson().fromJson(jsonObject.get("repoUrl"), String.class); diff --git a/src/main/java/edu/byu/cs/server/Server.java b/src/main/java/edu/byu/cs/server/Server.java index c2deb6be..7ee46988 100644 --- a/src/main/java/edu/byu/cs/server/Server.java +++ b/src/main/java/edu/byu/cs/server/Server.java @@ -4,6 +4,7 @@ import edu.byu.cs.server.endpointprovider.EndpointProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import spark.Spark; import static spark.Spark.*; @@ -16,12 +17,20 @@ public Server(EndpointProvider endpointProvider) { this.provider = endpointProvider; } + public int start() { + return start(0); + } + public int start(int desiredPort) { int chosenPort = setupEndpoints(desiredPort); LOGGER.info("Server started on port {}", chosenPort); return chosenPort; } + public void stop() { + Spark.stop(); + } + private int setupEndpoints(int port) { port(port); @@ -45,7 +54,7 @@ private int setupEndpoints(int port) { if (!req.requestMethod().equals("OPTIONS")) provider.verifyAuthenticatedMiddleware().handle(req, res); }); - patch("/repo", provider.repoPatch()); + post("/repo", provider.setRepoUrl()); get("/submit", provider.submitGet()); post("/submit", provider.submitPost()); @@ -64,7 +73,7 @@ private int setupEndpoints(int port) { if (!req.requestMethod().equals("OPTIONS")) provider.verifyAdminMiddleware().handle(req, res); }); - patch("/repo/:netId", provider.repoPatchAdmin()); + post("/repo/:netId", provider.setRepoUrlAdmin()); get("/repo/history", provider.repoHistoryAdminGet()); @@ -118,6 +127,8 @@ private int setupEndpoints(int port) { init(); + awaitInitialization(); + return port(); } } diff --git a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java index b89c1d6a..f3547981 100644 --- a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java +++ b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProvider.java @@ -58,7 +58,7 @@ public interface EndpointProvider { // UserController - Route repoPatch(); - Route repoPatchAdmin(); + Route setRepoUrl(); + Route setRepoUrlAdmin(); Route repoHistoryAdminGet(); } diff --git a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java index 1ba31d02..7d52d9ed 100644 --- a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java +++ b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java @@ -13,7 +13,7 @@ public class EndpointProviderImpl implements EndpointProvider { public Filter beforeAll() { return (request, response) -> { response.header("Access-Control-Allow-Headers", "Authorization,Content-Type"); - response.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,PATCH,OPTIONS"); + response.header("Access-Control-Allow-Methods", "GET,PUT,POST,DELETE,OPTIONS"); response.header("Access-Control-Allow-Credentials", "true"); response.header("Access-Control-Allow-Origin", ApplicationProperties.frontendUrl()); }; @@ -195,13 +195,13 @@ public Route submissionsReRunPost() { // UserController @Override - public Route repoPatch() { - return UserController.repoPatch; + public Route setRepoUrl() { + return UserController.setRepoUrl; } @Override - public Route repoPatchAdmin() { - return UserController.repoPatchAdmin; + public Route setRepoUrlAdmin() { + return UserController.setRepoUrlAdmin; } @Override diff --git a/src/test/java/edu/byu/cs/server/ServerTest.java b/src/test/java/edu/byu/cs/server/ServerTest.java new file mode 100644 index 00000000..9437fac4 --- /dev/null +++ b/src/test/java/edu/byu/cs/server/ServerTest.java @@ -0,0 +1,151 @@ +package edu.byu.cs.server; + +import edu.byu.cs.server.endpointprovider.MockEndpointProvider; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InOrder; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Stream; + +import static org.mockito.Mockito.*; + +class ServerTest { + private static TestServerFacade serverFacade; + private static Server server; + + private static final MockEndpointProvider mockedMockProvider = spy(new MockEndpointProvider()); + + public static Stream getPathParamEndpoints() { + return Stream.of( + Arguments.of( "GET", "/api/submission", "submissionXGet", ":phase"), + Arguments.of( "GET", "/api/admin/analytics/commit", "commitAnalyticsGet", ":option"), + Arguments.of("POST", "/api/admin/repo", "setRepoUrlAdmin", ":netid"), + Arguments.of( "GET", "/api/admin/honorChecker/zip", "honorCheckerZipGet", ":section"), + Arguments.of( "GET", "/api/admin/submissions/latest", "latestSubmissionsGet", ":count"), + Arguments.of( "GET", "/api/admin/submissions/student", "studentSubmissionsGet", ":netid") + ); + // api/admin/config/penalties + } + + public static Stream getEndpoints() { + return Stream.of( + Arguments.of( "GET", "/auth/callback", "callbackGet"), + Arguments.of( "GET", "/auth/login", "loginGet"), + Arguments.of("POST", "/auth/logout", "logoutPost"), + + Arguments.of( "GET", "/api/config", "getConfigStudent"), + Arguments.of( "GET", "/api/latest", "latestSubmissionForMeGet"), + Arguments.of( "GET", "/api/me", "meGet"), + Arguments.of("POST", "/api/repo", "setRepoUrl"), + Arguments.of( "GET", "/api/submission", "submissionXGet"), + Arguments.of( "GET", "/api/submit", "submitGet"), + Arguments.of("POST", "/api/submit", "submitPost"), + + Arguments.of( "GET", "/api/admin/config", "getConfigAdmin"), + Arguments.of("POST", "/api/admin/config/banner", "updateBannerMessage"), + Arguments.of("POST", "/api/admin/config/courseIds", "updateCourseIdsPost"), + Arguments.of( "GET", "/api/admin/config/courseIds", "updateCourseIdsUsingCanvasGet"), + Arguments.of("POST", "/api/admin/config/penalties", "updatePenalties"), + Arguments.of("POST", "/api/admin/config/phases", "updateLivePhases"), + Arguments.of("POST", "/api/admin/config/phases/shutdown", "scheduleShutdown"), + + Arguments.of( "GET", "/api/admin/submissions/active", "submissionsActiveGet"), + Arguments.of("POST", "/api/admin/submissions/approve", "approveSubmissionPost"), + Arguments.of( "GET", "/api/admin/submissions/latest", "latestSubmissionsGet"), + Arguments.of("POST", "/api/admin/submissions/rerun", "submissionsReRunPost"), + Arguments.of("POST", "/api/admin/submit", "adminRepoSubmitPost"), + + Arguments.of( "GET", "/api/admin/analytics/commit", "commitAnalyticsGet"), + Arguments.of( "GET", "/api/admin/repo/history", "repoHistoryAdminGet"), + Arguments.of( "GET", "/api/admin/sections", "sectionsGet"), + Arguments.of( "GET", "/api/admin/test_mode", "testModeGet"), + Arguments.of( "GET", "/api/admin/users", "usersGet") + ); + } + + @AfterAll + static void stopServer() { + server.stop(); + } + + @BeforeAll + public static void init() { + server = new Server(mockedMockProvider); + int port = server.start(); + System.out.println("Started test HTTP server on " + port); + + serverFacade = new TestServerFacade("localhost", port); + } + + @AfterEach + public void tearDown() { + reset(mockedMockProvider); + } + + @ParameterizedTest + @MethodSource("getEndpoints") + public void verifyEndpointCallsItsHandlersExactlyOnceInOrder(String method, String path, String endpointName) throws IOException { + // When + serverFacade.makeRequest(method, path); + + InOrder inOrder = inOrder(mockedMockProvider); + + // Then + inOrder.verify(mockedMockProvider, times(1)).runHandler("beforeAll"); + this.verifyInOrder_authenticationMiddleware(path, inOrder); + inOrder.verify(mockedMockProvider, times(1)).runHandler(endpointName); + inOrder.verify(mockedMockProvider, times(1)).runHandler("afterAll"); + } + + @ParameterizedTest + @MethodSource("getPathParamEndpoints") + public void verifyPathParameterHasAValueWhenGivenOne(String method, String path, String endpointName, + String pathParamName) throws IOException { + // Given + String fullPath = path + "/testParamValue"; + + // When + serverFacade.makeRequest(method, fullPath); + + // Then + verify(mockedMockProvider, times(1)).hasPathParam(endpointName, pathParamName, "testParamValue"); + } + + private void verifyInOrder_authenticationMiddleware(String path, InOrder inOrder) { + List pathNodes = Arrays.stream(path.split("/")).toList(); + + if (!pathNodes.contains("api")) { + return; + } + + if (!pathNodes.contains("auth")) { + // Requires authentication + inOrder.verify(mockedMockProvider, times(1)).runHandler("verifyAuthenticatedMiddleware"); + } + + if (pathNodes.contains("admin")) { + // Requires admin + inOrder.verify(mockedMockProvider, times(1)).runHandler("verifyAdminMiddleware"); + } + } + + @Test + void nonexistent_GET_endpoint_calls_beforeAll_then_defaultGet_then_afterAll_exactly_once_in_order() throws IOException { + serverFacade.makeRequest("GET", "/iDoNotExist"); + + // Verify they ran in order + InOrder inOrder = inOrder(mockedMockProvider); + inOrder.verify(mockedMockProvider).runHandler("beforeAll"); + inOrder.verify(mockedMockProvider).runHandler("defaultGet"); + inOrder.verify(mockedMockProvider).runHandler("afterAll"); + + // Verify they only ran once + verify(mockedMockProvider, times(1)).runHandler("beforeAll"); + verify(mockedMockProvider, times(1)).runHandler("defaultGet"); + verify(mockedMockProvider, times(1)).runHandler("afterAll"); + } +} diff --git a/src/test/java/edu/byu/cs/server/TestServerFacade.java b/src/test/java/edu/byu/cs/server/TestServerFacade.java new file mode 100644 index 00000000..43ffc4ce --- /dev/null +++ b/src/test/java/edu/byu/cs/server/TestServerFacade.java @@ -0,0 +1,109 @@ +package edu.byu.cs.server; + +import com.google.gson.Gson; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Map; + +public class TestServerFacade { + private final String serverURL; + + public TestServerFacade(String serverURL, int port) { + this.serverURL = "http://%s:%d".formatted(serverURL, port); + } + + public Object makeRequest(String method, String path) throws IOException { + return makeRequest(method, path, null, null, Object.class); + } + + public T makeRequest(String method, String path, Object request, Map headers, + Class responseClass) throws IOException { + HttpURLConnection http = getConnection(method, serverURL + path); + writeRequest(http, request, headers); + connect(http); + return readResponse(http, responseClass); + } + + private HttpURLConnection getConnection(String method, String urlString) throws IOException { + try { + URL url = (new URI(urlString)).toURL(); + HttpURLConnection http = (HttpURLConnection) url.openConnection(); + http.setRequestMethod(method); + http.setDoOutput("POST".equals(method) || "PUT".equals(method)); + return http; + } catch (IOException | URISyntaxException e) { + throw new IOException("Failed to set up HTTP connection: " + e.getMessage(), e); + } + } + + private void writeRequest(HttpURLConnection http, Object requestBody, Map headers) throws IOException { + try { + writeHeaders(http, headers); + writeRequestBody(http, requestBody); + } catch (IOException e) { + throw new IOException("Could not write request body: " + e.getMessage(), e); + } + } + + private void connect(HttpURLConnection http) throws IOException { + try { + http.connect(); + } catch (IOException e) { + throw new IOException("Failed to connect to server: " + e.getMessage(), e); + } + } + + private T readResponse(HttpURLConnection http, Class responseClass) throws IOException { + String respString = getRespString(http); + try { + return new Gson().fromJson(respString, responseClass); + } catch (Exception e) { + String message = String.format("Error parsing response. Expected JSON, got '%s'", respString); + throw new IOException(message, e); + } + } + + private void writeHeaders(HttpURLConnection http, Map headers) { + http.addRequestProperty("Content-type", "application/json"); + if (headers != null) { + for (String headerName : headers.keySet()) { + String headerValue = headers.get(headerName); + http.addRequestProperty(headerName, headerValue); + } + } + } + + private void writeRequestBody(HttpURLConnection http, Object request) throws IOException { + if (request != null) { + String requestData = new Gson().toJson(request); + try (OutputStream reqBody = http.getOutputStream()) { + reqBody.write(requestData.getBytes()); + } + } + } + + private String getRespString(HttpURLConnection http) throws IOException { + InputStream respBody; + if (http.getResponseCode() / 100 == 2) { + respBody = http.getInputStream(); + } else { + respBody = http.getErrorStream(); + } + StringBuilder sb = new StringBuilder(); + InputStreamReader sr = new InputStreamReader(respBody); + char[] buf = new char[1024]; + int len; + while ((len = sr.read(buf)) > 0) { + sb.append(buf, 0, len); + } + respBody.close(); + return sb.toString(); + } +} diff --git a/src/test/java/edu/byu/cs/server/endpointprovider/MockEndpointProvider.java b/src/test/java/edu/byu/cs/server/endpointprovider/MockEndpointProvider.java new file mode 100644 index 00000000..1b977b9c --- /dev/null +++ b/src/test/java/edu/byu/cs/server/endpointprovider/MockEndpointProvider.java @@ -0,0 +1,226 @@ +package edu.byu.cs.server.endpointprovider; + +import spark.Filter; +import spark.Request; +import spark.Response; +import spark.Route; + +import java.util.Map; + +/** + * A mock implementation of EndpointProvider designed for use with + * Mockito.spy() (not mock()). Because individual endpoint methods only run once at + * Server startup, they aren't useful for verifying endpoint calls. + * MockEndpointProvider provides a runHandler() method that can be used for + * that purpose. + */ +public class MockEndpointProvider implements EndpointProvider { + + /** + * An empty function that is run by every endpoint, designed for use in + * Mockito.verify() to verify endpoint calls. The individual endpoint + * methods only run once on Server startup, so they can't be used + * for that purpose. + * + * @param endpointName the name of the Route that was called + */ + public void runHandler(String endpointName) {} + + /** + * An empty function that is run for each path parameter in each endpoint + * that is called. It is designed for use with Mockito.verify() to verify + * that endpoints are called with specific path parameters. + * + * @param endpointName the name of the Route that was called + * @param paramName the name of the parameter + * @param paramValue the value of the parameter + */ + public void hasPathParam(String endpointName, String paramName, String paramValue) {} + + private Object extractRequestInfo(String endpointName, Request req, Response res) { + Map params = req.params(); + for (String paramName : params.keySet()) { + String paramValue = params.get(paramName); + this.hasPathParam(endpointName, paramName, paramValue); + } + + this.runHandler(endpointName); + + return "{}"; + } + + @Override + public Filter beforeAll() { + return (req, res) -> extractRequestInfo("beforeAll", req, res); + } + + @Override + public Filter afterAll() { + return (req, res) -> extractRequestInfo("afterAll", req, res); + } + + @Override + public Route defaultGet() { + return (req, res) -> extractRequestInfo("defaultGet", req, res); + } + + @Override + public Route usersGet() { + return (req, res) -> extractRequestInfo("usersGet", req, res); + } + + @Override + public Route testModeGet() { + return (req, res) -> extractRequestInfo("testModeGet", req, res); + } + + @Override + public Route commitAnalyticsGet() { + return (req, res) -> extractRequestInfo("commitAnalyticsGet", req, res); + } + + @Override + public Route honorCheckerZipGet() { + return (req, res) -> extractRequestInfo("honorCheckerZipGet", req, res); + } + + @Override + public Route sectionsGet() { + return (req, res) -> extractRequestInfo("sectionsGet", req, res); + } + + @Override + public Filter verifyAuthenticatedMiddleware() { + return (req, res) -> extractRequestInfo("verifyAuthenticatedMiddleware", req, res); + } + + @Override + public Filter verifyAdminMiddleware() { + return (req, res) -> extractRequestInfo("verifyAdminMiddleware", req, res); + } + + @Override + public Route meGet() { + return (req, res) -> extractRequestInfo("meGet", req, res); + } + + @Override + public Route callbackGet() { + return (req, res) -> extractRequestInfo("callbackGet", req, res); + } + + @Override + public Route loginGet() { + return (req, res) -> extractRequestInfo("loginGet", req, res); + } + + @Override + public Route logoutPost() { + return (req, res) -> extractRequestInfo("logoutPost", req, res); + } + + @Override + public Route getConfigAdmin() { + return (req, res) -> extractRequestInfo("getConfigAdmin", req, res); + } + + @Override + public Route getConfigStudent() { + return (req, res) -> extractRequestInfo("getConfigStudent", req, res); + } + + @Override + public Route updateLivePhases() { + return (req, res) -> extractRequestInfo("updateLivePhases", req, res); + } + + @Override + public Route scheduleShutdown() { + return (req, res) -> extractRequestInfo("scheduleShutdown", req, res); + } + + @Override + public Route updateBannerMessage() { + return (req, res) -> extractRequestInfo("updateBannerMessage", req, res); + } + + @Override + public Route updateCourseIdsPost() { + return (req, res) -> extractRequestInfo("updateCourseIdsPost", req, res); + } + + @Override + public Route updateCourseIdsUsingCanvasGet() { + return (req, res) -> extractRequestInfo("updateCourseIdsUsingCanvasGet", req, res); + } + + @Override + public Route updatePenalties() { + return (req, res) -> extractRequestInfo("updatePenalties", req, res); + } + + @Override + public Route submitPost() { + return (req, res) -> extractRequestInfo("submitPost", req, res); + } + + @Override + public Route adminRepoSubmitPost() { + return (req, res) -> extractRequestInfo("adminRepoSubmitPost", req, res); + } + + @Override + public Route submitGet() { + return (req, res) -> extractRequestInfo("submitGet", req, res); + } + + @Override + public Route latestSubmissionForMeGet() { + return (req, res) -> extractRequestInfo("latestSubmissionForMeGet", req, res); + } + + @Override + public Route submissionXGet() { + return (req, res) -> extractRequestInfo("submissionXGet", req, res); + } + + @Override + public Route latestSubmissionsGet() { + return (req, res) -> extractRequestInfo("latestSubmissionsGet", req, res); + } + + @Override + public Route submissionsActiveGet() { + return (req, res) -> extractRequestInfo("submissionsActiveGet", req, res); + } + + @Override + public Route studentSubmissionsGet() { + return (req, res) -> extractRequestInfo("studentSubmissionsGet", req, res); + } + + @Override + public Route approveSubmissionPost() { + return (req, res) -> extractRequestInfo("approveSubmissionPost", req, res); + } + + @Override + public Route submissionsReRunPost() { + return (req, res) -> extractRequestInfo("submissionsReRunPost", req, res); + } + + @Override + public Route setRepoUrl() { + return (req, res) -> extractRequestInfo("setRepoUrl", req, res); + } + + @Override + public Route setRepoUrlAdmin() { + return (req, res) -> extractRequestInfo("setRepoUrlAdmin", req, res); + } + + @Override + public Route repoHistoryAdminGet() { + return (req, res) -> extractRequestInfo("repoHistoryAdminGet", req, res); + } +}