diff --git a/e2e-tests/build.gradle b/e2e-tests/build.gradle index 390cfd0eee..4d215bffc4 100644 --- a/e2e-tests/build.gradle +++ b/e2e-tests/build.gradle @@ -68,7 +68,7 @@ dependencies { testImplementation "com.zaxxer:HikariCP:5.1.0" testImplementation("org.postgresql:postgresql:42.6.0") - testImplementation 'com.microsoft.playwright:playwright:1.37.0' + testImplementation 'com.microsoft.playwright:playwright:1.55.0' testImplementation 'org.glassfish:javax.json:1.1.4' testImplementation 'org.apache.commons:commons-lang3:3.13.0' diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/BindingsTests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/BindingsTests.java index 359190521c..264f9d2027 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/BindingsTests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/BindingsTests.java @@ -12,6 +12,7 @@ import gov.nasa.jpl.aerie.e2e.types.ExternalDataset; import gov.nasa.jpl.aerie.e2e.types.User; import gov.nasa.jpl.aerie.e2e.types.ValueSchema; +import gov.nasa.jpl.aerie.e2e.types.workspaces.BulkPutItem; import gov.nasa.jpl.aerie.e2e.utils.BaseURL; import gov.nasa.jpl.aerie.e2e.utils.GatewayRequests; import gov.nasa.jpl.aerie.e2e.utils.HasuraRequests; @@ -37,6 +38,7 @@ import java.io.IOException; import java.io.StringReader; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; @@ -1237,7 +1239,7 @@ class WorkspaceBindings { private int cdictId; private int parcelId; - private User owner = new User( + private final User owner = new User( "ws_bindings_owner", "user", new String[] {"user"}, @@ -1484,7 +1486,7 @@ void nonEmptyWorkspaceWithDepth() { } /** - * Tests for the /ws/{workspaceId}/ routes. + * Tests for the /ws/{workspaceId}/ routes. * * Disabled because the suite is skeletoned but not implemented. */ @@ -1898,6 +1900,1677 @@ void conflictedIfDestinationExists() { } } + /** + * Tests for the /ws/bulk/{workspaceId}/ routes. + */ + @Nested + class BulkWSRoutes { + @Nested + class BulkPut { + private int workspaceId; + + @BeforeEach + void beforeEach() throws IOException { + workspaceId = wsServer.createWorkspace("bulkPutWS", parcelId); + } + + @AfterEach + void afterEach() throws IOException { + wsServer.deleteWorkspace(workspaceId); + } + + /** + * Basic successful cases. + * All of these should return a top level status of 207, + * and an array of JSON objects with the same length as the input list. + * Each object in the array should have a status of 200, an 'item' field with the uploaded item's name, + * and a 'result' field that either says "directory created" or "file uploaded", + * as appropriate based on the created item's type. + * Additionally, a GET request for the item should succeed after the PUT. + */ + @ParameterizedTest + @MethodSource("bulkPutBasicCasesArgs") + void bulkPutBasicCases(List inputs) { + final var resp = wsServer.bulkPut(ownerToken, workspaceId, inputs); + + // Check status code + assertEquals(207, resp.status()); + + // Check details of response + final var respBody = getArrayBody(resp); + assertEquals(inputs.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = inputs.get(i); + final var actual = respBody.get(i).asJsonObject(); + + // Check the PUT response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.getPath().toString(), actual.getString("item")); + if (expected instanceof BulkPutItem.FileBulkPutItem file) { + assertEquals( + "File " + expected.getPath().getFileName() + " uploaded to " + expected.getPath(), + actual.getString("response")); + // Check that file was uploaded with the correct contents + final var getResp = wsServer.get(ownerToken, workspaceId, expected.getPath()); + assertEquals(200, getResp.status()); + assertEquals(file.fileContents(), getResp.text()); + } else { + assertEquals("Directory created.", actual.getString("response")); + // Simple check that the item was actually uploaded -- does not check directory contents + final var getResp = wsServer.get(ownerToken, workspaceId, expected.getPath()); + assertEquals(200, getResp.status()); + } + } + } + + /** + * Generate arguments to test basic upload cases. + */ + private static Stream bulkPutBasicCasesArgs() { + final var myFileInput = new BulkPutItem.FileBulkPutItem( + Path.of("myFile.txt"), + "this is my file contents"); + final var myDirInput = new BulkPutItem.DirectoryBulkPutItem(Path.of("myDir")); + final var folderDirInput = new BulkPutItem.DirectoryBulkPutItem(Path.of("myDir/subDir"), true); + final var secondFileInput = new BulkPutItem.FileBulkPutItem( + Path.of("myDir/otherFile.txt"), + "this is another file", + "anotherFile.txt"); + + return Stream.of( + Arguments.arguments(named("Single File Bulk PUT", List.of(myFileInput))), + Arguments.arguments(named("Single Directory Bulk PUT", List.of(myDirInput))), + Arguments.arguments(named("Multiple Files Bulk PUT", List.of(myFileInput, secondFileInput))), + Arguments.arguments(named("Multiple Directories Bulk PUT", List.of(myDirInput, folderDirInput))), + Arguments.arguments(named( + "Mixed Files and Directories Bulk PUT", + List.of(myDirInput, folderDirInput, myFileInput, secondFileInput))) + ); + } + + /** + * When only one item upload fails, the overall status is 207, the successful items have a status of 200, + * and the unsuccessful items have an appropriate error status. + */ + @Test + void mixedResults() { + // Setup: upload a conflicting file using the non-bulk endpoint + final var putResp = wsServer.putFile(ownerToken, workspaceId, Path.of("file.txt"), "original file contents"); + assertEquals(200, putResp.status()); + + // Upload a list of items, including one conflict + final List toUpload = List.of( + new BulkPutItem.FileBulkPutItem(Path.of("file.txt"), "conflicting file"), + new BulkPutItem.DirectoryBulkPutItem(Path.of("myDir")), + new BulkPutItem.FileBulkPutItem( + Path.of("myDir/file.txt"), + "file with same name in another folder", + "otherFile.txt")); + + final var resp = wsServer.bulkPut(ownerToken, workspaceId, toUpload); + + // Check Response + assertEquals(207, resp.status()); + final var respBody = getArrayBody(resp); + assertEquals(3, respBody.size()); + + // First item should be the conflicted file with a 409 Conflicted + final var conflictFile = respBody.getFirst().asJsonObject(); + assertEquals("file.txt", conflictFile.getString("item")); + assertEquals(409, conflictFile.getInt("status")); + assertEquals("original file contents", wsServer.get(ownerToken, workspaceId, Path.of("file.txt")).text()); + + // Second item should be the unconflicted directory + final var dir = respBody.get(1).asJsonObject(); + assertEquals("myDir", dir.getString("item")); + assertEquals(200, dir.getInt("status")); + assertEquals( + "[{\"name\":\"file.txt\",\"type\":\"TEXT\"}]", + wsServer.get(ownerToken, workspaceId, Path.of("myDir")).text()); + + // Third item should be the unconflicted file + final var otherFile = respBody.getLast().asJsonObject(); + assertEquals("myDir/file.txt", otherFile.getString("item")); + assertEquals(200, otherFile.getInt("status")); + assertEquals( + "file with same name in another folder", + wsServer.get(ownerToken, workspaceId, Path.of("myDir/file.txt")).text()); + } + + /** + * File contents can be attached under a name other than the file's uploaded name using the input_file_name field + */ + @Test + void customInputName() { + // Upload two items with the same name to different folders. + final List toUpload = List.of( + new BulkPutItem.FileBulkPutItem(Path.of("file.txt"), "file in one folder"), + new BulkPutItem.FileBulkPutItem( + Path.of("myDir/file.txt"), + "file with same name in another folder", + "otherFile.txt")); + + final var resp = wsServer.bulkPut(ownerToken, workspaceId, toUpload); + + // Check Response + assertEquals(207, resp.status()); + final var respBody = getArrayBody(resp); + assertEquals(toUpload.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = toUpload.get(i); + final var actual = respBody.get(i).asJsonObject(); + + // Check the PUT response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.getPath().toString(), actual.getString("item")); + if (expected instanceof BulkPutItem.FileBulkPutItem file) { + assertEquals( + "File " + expected.getPath().getFileName() + " uploaded to " + expected.getPath(), + actual.getString("response")); + // Check that file was uploaded with the correct contents + final var getResp = wsServer.get(ownerToken, workspaceId, expected.getPath()); + assertEquals(200, getResp.status()); + assertEquals(file.fileContents(), getResp.text()); + } else { + fail(); + } + } + } + + /** + * File contents can be attached under a name other than the file's uploaded name using the input_file_name field + */ + @Test + void bulkUploadCreate() { + // Upload a list of items, including one conflict + final List toUpload = List.of( + new BulkPutItem.FileBulkPutItem(Path.of("file.txt"), "file in one folder"), + new BulkPutItem.FileBulkPutItem( + Path.of("myDir/file.txt"), + "file with same name in another folder", + "otherFile.txt")); + + final var resp = wsServer.bulkPut(ownerToken, workspaceId, toUpload); + + // Check Response + assertEquals(207, resp.status()); + final var respBody = getArrayBody(resp); + assertEquals(toUpload.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = toUpload.get(i); + final var actual = respBody.get(i).asJsonObject(); + + // Check the PUT response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.getPath().toString(), actual.getString("item")); + if (expected instanceof BulkPutItem.FileBulkPutItem file) { + assertEquals( + "File " + expected.getPath().getFileName() + " uploaded to " + expected.getPath(), + actual.getString("response")); + // Check that file was uploaded with the correct contents + final var getResp = wsServer.get(ownerToken, workspaceId, expected.getPath()); + assertEquals(200, getResp.status()); + assertEquals(file.fileContents(), getResp.text()); + } else { + fail(); + } + } + } + + @Nested + class MalformedRequest { + private final static String endpoint = "/ws/bulk/%d"; + + /** + * A PUT request with no "body" component fails with a 400 + */ + @Test + void noBodyRejected() { + final var formData = FormData.create(); + final var fileContents = new FilePayload( + "myFile.txt", + "text/plain", + "example file contents".getBytes(StandardCharsets.UTF_8)); + + // Generate the request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setMultipart(formData.set("files", fileContents)); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.PUT); + assertEquals(400, resp.status()); + final var respBody = getBody(resp); + assertEquals("MALFORMED_REQUEST", respBody.getString("type")); + assertEquals("Invalid body format.", respBody.getString("message")); + } + + /** + * A PUT request attempting to upload files must include a "files" component + */ + @Test + void noFilesFileUploadRejected() { + final BulkPutItem fileUpload = new BulkPutItem.FileBulkPutItem(Path.of("file.txt"), "file contents"); + final var formData = FormData.create(); + + // Generate the request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setMultipart(formData.set("body", Json.createArrayBuilder().add(fileUpload.toJson()).build().toString())); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.PUT); + assertEquals(207, resp.status()); + final var fileResp = getArrayBody(resp).getFirst().asJsonObject(); + assertEquals("file.txt", fileResp.getString("item")); + assertEquals(400, fileResp.getInt("status")); + + final var fileError = fileResp.getJsonObject("response"); + assertEquals("MALFORMED_REQUEST", fileError.getString("type")); + assertEquals("No file provided with the name file.txt", fileError.getString("message")); + assertEquals("Attach file contents under the 'files' part of the request.", fileError.getString("cause")); + + assertEquals(404, wsServer.get(ownerToken, workspaceId, fileUpload.getPath()).status()); + } + + /** + * If multiple files are attached under the same name, the request is rejected. + */ + @Test + void attachedFileNameConflictRejected() { + final List toUpload = List.of( + new BulkPutItem.FileBulkPutItem(Path.of("file.txt"), "file in one folder"), + new BulkPutItem.FileBulkPutItem(Path.of("myDir/file.txt"), "file with same name in another folder")); + + final var resp = wsServer.bulkPut(ownerToken, workspaceId, toUpload); + + // Check Response + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Cannot process request: multiple files are attached under the same name.", body.getString("message")); + + // Check that no files were actually uploaded + for(final var item : toUpload) { + assertEquals(404, wsServer.get(ownerToken, workspaceId, item.getPath()).status()); + } + } + + /** + * Reject the request if multiple files are trying to be uploaded to the same location. + */ + @ParameterizedTest + @MethodSource("multipleItemsSameLocationArgs") + void twoItemsToSameLocationRejected(List toUpload) { + final var resp = wsServer.bulkPut(ownerToken, workspaceId, toUpload); + + // Check Response + assertEquals(409, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Multiple items are attempting to be uploaded to the same location. Please give all items unique names.", body.getString("message")); + + // Check that no items were actually created + for(final var item : toUpload) { + assertEquals(404, wsServer.get(ownerToken, workspaceId, item.getPath()).status()); + } + } + + private static Stream multipleItemsSameLocationArgs() { + final var fileName = Path.of("file.txt"); + final var dirName = Path.of("myDir"); + final List fileExample = List.of( + new BulkPutItem.FileBulkPutItem(fileName, "file in one folder"), + new BulkPutItem.FileBulkPutItem(fileName, "file with same name", "otherFile.txt")); + + final List dirExample = List.of( + new BulkPutItem.DirectoryBulkPutItem(dirName, true), + new BulkPutItem.DirectoryBulkPutItem(dirName)); + + final List mixedExample = List.of( + new BulkPutItem.DirectoryBulkPutItem(dirName), + new BulkPutItem.FileBulkPutItem(dirName, "file with directory name")); + + final List nestedExample = List.of( + new BulkPutItem.FileBulkPutItem(dirName.resolve(fileName), "file in one folder"), + new BulkPutItem.FileBulkPutItem(dirName.resolve(fileName), "file with same name", "otherFile.txt")); + + return Stream.of( + Arguments.arguments(named("Two Files", fileExample)), + Arguments.arguments(named("Two Directories", dirExample)), + Arguments.arguments(named("File and Directory", mixedExample)), + Arguments.arguments(named("Two Files in a Directory", nestedExample))); + } + + /** + * The input must be a multipart/form-data, even when just creating multiple directories + */ + @Test + void nonMultipartFails() { + final BulkPutItem directoryUpload = new BulkPutItem.DirectoryBulkPutItem(Path.of("myDir")); + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("content-type", "application/json") + .setData(Json.createArrayBuilder().add(directoryUpload.toJson()).build().toString()); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.PUT); + assertEquals(400, resp.status()); + final var respBody = getBody(resp); + assertEquals("MALFORMED_REQUEST", respBody.getString("type")); + assertEquals("Invalid body format.", respBody.getString("message")); + + assertEquals(404, wsServer.get(ownerToken, workspaceId, directoryUpload.getPath()).status()); + } + + /** + * The "body" part of the request must be a JSON + */ + @Test + void nonJSONBodyRejected() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setMultipart(FormData.create().set("body", "make a new folder please")); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.PUT); + assertEquals(400, resp.status()); + final var respBody = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", respBody.getString("type")); + assertTrue(respBody.getString("message").startsWith("Invalid body format. Expected body format is an array of JSON objects with the form:")); + } + + /** + * Directories can't have custom input names. + */ + @Test + void customInputNameDisallowedDirectory() { + final var dirInput = Json.createObjectBuilder() + .add("path", "myDir") + .add("type", "directory") + .add("input_file_name", "otherDir") + .build(); + + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer " + ownerToken) + .setMultipart(FormData.create().set("body", dirInput.toString())); + + final var resp = wsServer.makeRequest( + endpoint.formatted(workspaceId), + options, + WorkspaceRequests.RequestType.PUT); + assertEquals(400, resp.status()); + final var respBody = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", respBody.getString("type")); + assertTrue(respBody + .getString("message") + .startsWith( + "Invalid body format. Expected body format is an array of JSON objects with the form:")); + } + + /** + * The "files" part of the request, if provided, must contain files. + */ + @Test + void bodyInFilesRejected() { + final BulkPutItem fileUpload = new BulkPutItem.FileBulkPutItem(Path.of("file.txt"), "file contents"); + final var body = Json.createArrayBuilder().add(fileUpload.toJson()).build().toString(); + final var formData = FormData.create(); + + // Generate the request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setMultipart(formData.set("body", body).set("files", body)); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.PUT); + assertEquals(207, resp.status()); + final var fileResp = getArrayBody(resp).getFirst().asJsonObject(); + assertEquals("file.txt", fileResp.getString("item")); + assertEquals(400, fileResp.getInt("status")); + + final var fileError = fileResp.getJsonObject("response"); + assertEquals("MALFORMED_REQUEST", fileError.getString("type")); + assertEquals("No file provided with the name file.txt", fileError.getString("message")); + assertEquals("Attach file contents under the 'files' part of the request.", fileError.getString("cause")); + + assertEquals(404, wsServer.get(ownerToken, workspaceId, fileUpload.getPath()).status()); + } + + /** + * The PUT request must specify items to upload + */ + @Test + void emptyBodyRejected() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setMultipart(FormData.create().set("body", "[]")); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.PUT); + assertEquals(400, resp.status()); + final var respBody = getBody(resp); + assertEquals("MALFORMED_REQUEST", respBody.getString("type")); + assertEquals("Cannot process request: at least one item must be specified.", respBody.getString("message")); + } + } + + @Nested + class Overwrite { + @BeforeEach + void beforeEach() { + wsServer.putFile(ownerToken, workspaceId, Path.of("myFile.txt"), "original file contents"); + } + + /** + * Directories can't use the overwrite flag. + */ + @Test + void overwriteDisallowedDirectory() { + final var dirInput = Json.createObjectBuilder() + .add("path", "myDir") + .add("type", "directory") + .add("overwrite", true) + .build(); + + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer " + ownerToken) + .setMultipart(FormData.create().set("body", dirInput.toString())); + + final var resp = wsServer.makeRequest( + "/ws/bulk/%d".formatted(workspaceId), + options, + WorkspaceRequests.RequestType.PUT); + assertEquals(400, resp.status()); + final var respBody = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", respBody.getString("type")); + assertTrue(respBody + .getString("message") + .startsWith( + "Invalid body format. Expected body format is an array of JSON objects with the form:")); + } + + /** + * When the "overwrite" flag is not set, it defaults to false + */ + @Test + void overwriteUnset() { + final BulkPutItem fileUpload = new BulkPutItem.FileBulkPutItem( + Path.of("myFile.txt"), + "new file contents" + ); + + // The overall response was a 207 Multipart + final var resp = wsServer.bulkPut(ownerToken, workspaceId, List.of(fileUpload)); + assertEquals(207, resp.status()); + final var body = getArrayBody(resp); + assertEquals(1, body.size()); + + // The item's specific response was a 409 + final var item = body.getFirst().asJsonObject(); + final var itemResp = item.getJsonObject("response"); + assertEquals("myFile.txt", item.getString("item")); + assertEquals(409, item.getInt("status")); + assertEquals("INTERNAL_ERROR", itemResp.getString("type")); + assertEquals("myFile.txt already exists.", itemResp.getString("message")); + + // The file's contents were NOT overwritten + assertEquals("original file contents", wsServer.get(ownerToken, workspaceId, Path.of("myFile.txt")).text()); + } + + /** + * When the "overwrite" flag is set to false, the server will not upload a file if it already exists + */ + @Test + void overwriteFalse() { + final BulkPutItem fileUpload = new BulkPutItem.FileBulkPutItem( + Path.of("myFile.txt"), + "new file contents", + false + ); + + // The overall response was a 207 Multipart + final var resp = wsServer.bulkPut(ownerToken, workspaceId, List.of(fileUpload)); + assertEquals(207, resp.status()); + final var body = getArrayBody(resp); + assertEquals(1, body.size()); + + // The item's specific response was a 409 + final var item = body.getFirst().asJsonObject(); + final var itemResp = item.getJsonObject("response"); + assertEquals("myFile.txt", item.getString("item")); + assertEquals(409, item.getInt("status")); + assertEquals("INTERNAL_ERROR", itemResp.getString("type")); + assertEquals("myFile.txt already exists.", itemResp.getString("message")); + + // The file's contents were NOT overwritten + assertEquals("original file contents", wsServer.get(ownerToken, workspaceId, Path.of("myFile.txt")).text()); + } + + /** + * When the "overwrite" flag is set to true, the server will overwrite a file if it is present + */ + @Test + void overwriteTrue() { + final BulkPutItem fileUpload = new BulkPutItem.FileBulkPutItem( + Path.of("myFile.txt"), + "new file contents", + true + ); + + // The overall response was a 207 Multipart + final var resp = wsServer.bulkPut(ownerToken, workspaceId, List.of(fileUpload)); + assertEquals(207, resp.status()); + final var body = getArrayBody(resp); + assertEquals(1, body.size()); + + // The item's specific response was a 200 + final var item = body.getFirst().asJsonObject(); + assertEquals("myFile.txt", item.getString("item")); + assertEquals(200, item.getInt("status")); + assertEquals("File myFile.txt uploaded to myFile.txt", item.getString("response")); + + // The file's contents were overwritten + assertEquals("new file contents", wsServer.get(ownerToken, workspaceId, Path.of("myFile.txt")).text()); + } + } + } + + @Nested + class BulkPost { + private int workspaceId; + private int otherWorkspaceId; + private final Path destinationPath = Path.of("./destination_dir"); + + @BeforeEach + void beforeEach() throws IOException { + workspaceId = wsServer.createWorkspace("bulkPostWS", parcelId); + otherWorkspaceId = wsServer.createWorkspace("otherBulkPostWs", parcelId); + + // Prepopulate ws with contents + final List wsContents = List.of( + new BulkPutItem.DirectoryBulkPutItem("top_dir"), + new BulkPutItem.DirectoryBulkPutItem("top_dir/nested_dir"), + new BulkPutItem.DirectoryBulkPutItem("top_dir/other_nested_dir"), + new BulkPutItem.DirectoryBulkPutItem("other_dir"), + new BulkPutItem.DirectoryBulkPutItem("other_dir/nested_dir"), + new BulkPutItem.FileBulkPutItem("top_file.txt", "top level file"), + new BulkPutItem.FileBulkPutItem("top_dir/sub_file.txt", "file within a directory"), + new BulkPutItem.FileBulkPutItem("other_dir/other_file.txt", "another file within a directory"), + new BulkPutItem.FileBulkPutItem("top_dir/nested_dir/nested_file.txt", "file within a nested directory"), + new BulkPutItem.DirectoryBulkPutItem("destination_dir"), + new BulkPutItem.DirectoryBulkPutItem("destination") + ); + wsServer.bulkPut(ownerToken, workspaceId, wsContents); + wsServer.bulkPut(ownerToken, otherWorkspaceId, List.of(new BulkPutItem.DirectoryBulkPutItem("destination_dir"))); + } + + @AfterEach + void afterEach() throws IOException { + wsServer.deleteWorkspace(workspaceId); + wsServer.deleteWorkspace(otherWorkspaceId); + } + + /** + * Basic successful cases of moving files within a workspace. + * All of these should return a top level status of 207, + * and an array of JSON objects with the same length as the input list. + * Each object in the array should have a status of 200, an 'item' field with the uploaded item's name, + * and a 'response' field that says the item was moved. + * Additionally, the item should only be findable at its new location + */ + @ParameterizedTest + @MethodSource("bulkPostBasicCasesArgs") + void bulkMoveSameWorkspaceBasicCases(List inputs) { + final var resp = wsServer.bulkMove( + ownerToken, + workspaceId, + inputs, + destinationPath, + Optional.empty(), + Optional.empty()); + + + // Check status code + assertEquals(207, resp.status()); + + // Check details of response + final var respBody = getArrayBody(resp); + assertEquals(inputs.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = inputs.get(i); + final var expectedDestination = destinationPath.resolve(expected.getFileName()); + final var actual = respBody.get(i).asJsonObject(); + + // Check the POST response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.toString(), actual.getString("item")); + assertEquals("'%s' in Workspace %d moved to '%s' in Workspace %d" + .formatted(expected, workspaceId, expectedDestination, workspaceId), + actual.getString("response")); + + // Simple check that the item was actually moved: + // trying to get it at its old location should return a 404 Resource Not Found + // while trying to get it at its new location should return a 200 + final var getOldResp = wsServer.get(ownerToken, workspaceId, expected); + assertEquals(404, getOldResp.status()); + + final var getNewResp = wsServer.get(ownerToken, workspaceId, expectedDestination); + assertEquals(200, getNewResp.status()); + } + } + + /** + * Basic successful cases of moving files from one workspace to another. + * All of these should return a top level status of 207, + * and an array of JSON objects with the same length as the input list. + * Each object in the array should have a status of 200, an 'item' field with the uploaded item's name, + * and a 'response' field that says the item was moved. + * Additionally, the item should only be findable at its new location + */ + @ParameterizedTest + @MethodSource("bulkPostBasicCasesArgs") + void bulkMoveBtwnWorkspaceBasicCases(List inputs) { + final var resp = wsServer.bulkMove( + ownerToken, + workspaceId, + inputs, + destinationPath, + Optional.of(otherWorkspaceId), + Optional.empty()); + + + // Check status code + assertEquals(207, resp.status()); + + // Check details of response + final var respBody = getArrayBody(resp); + assertEquals(inputs.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = inputs.get(i); + final var expectedDestination = destinationPath.resolve(expected.getFileName()); + final var actual = respBody.get(i).asJsonObject(); + + // Check the POST response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.toString(), actual.getString("item")); + assertEquals("'%s' in Workspace %d moved to '%s' in Workspace %d" + .formatted(expected, workspaceId, expectedDestination, otherWorkspaceId), + actual.getString("response")); + + // Simple check that the item was actually moved: + // trying to get it at both its old and new location should return a 200 + // while trying to get it at its new location should return a 200 + final var getOldResp = wsServer.get(ownerToken, workspaceId, expected); + assertEquals(404, getOldResp.status()); + + final var getNewResp = wsServer.get(ownerToken, otherWorkspaceId, expectedDestination); + assertEquals(200, getNewResp.status()); + } + } + + /** + * Basic successful cases of copying files within a workspace. + * All of these should return a top level status of 207, + * and an array of JSON objects with the same length as the input list. + * Each object in the array should have a status of 200, an 'item' field with the uploaded item's name, + * and a 'response' field that says the item was moved. + * Additionally, the item should be findable at both its old and new location + */ + @ParameterizedTest + @MethodSource("bulkPostBasicCasesArgs") + void bulkCopySameWorkspaceBasicCases(List inputs) { + final var resp = wsServer.bulkCopy( + ownerToken, + workspaceId, + inputs, + destinationPath, + Optional.empty(), + Optional.empty()); + + + // Check status code + assertEquals(207, resp.status()); + + // Check details of response + final var respBody = getArrayBody(resp); + assertEquals(inputs.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = inputs.get(i); + final var expectedDestination = destinationPath.resolve(expected.getFileName()); + final var actual = respBody.get(i).asJsonObject(); + + // Check the POST response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.toString(), actual.getString("item")); + assertEquals("'%s' in Workspace %d copied to '%s' in Workspace %d" + .formatted(expected, workspaceId, expectedDestination, workspaceId), + actual.getString("response")); + + // Simple check that the item was actually copied: + // trying to get it at both its old and new location should return a 200 + final var getOldResp = wsServer.get(ownerToken, workspaceId, expected); + assertEquals(200, getOldResp.status()); + + final var getNewResp = wsServer.get(ownerToken, workspaceId, expectedDestination); + assertEquals(200, getNewResp.status()); + + // The copied file has the same contents + assertEquals(getOldResp.text(), getNewResp.text()); + } + } + + /** + * Basic successful cases of copying files from one workspace to another. + * All of these should return a top level status of 207, + * and an array of JSON objects with the same length as the input list. + * Each object in the array should have a status of 200, an 'item' field with the uploaded item's name, + * and a 'response' field that says the item was moved. + * Additionally, the item should be findable at both its old and new location + */ + @ParameterizedTest + @MethodSource("bulkPostBasicCasesArgs") + void bulkCopyBtwnWorkspaceBasicCases(List inputs) { + final var resp = wsServer.bulkCopy( + ownerToken, + workspaceId, + inputs, + destinationPath, + Optional.of(otherWorkspaceId), + Optional.empty()); + + + // Check status code + assertEquals(207, resp.status()); + + // Check details of response + final var respBody = getArrayBody(resp); + assertEquals(inputs.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = inputs.get(i); + final var expectedDestination = destinationPath.resolve(expected.getFileName()); + final var actual = respBody.get(i).asJsonObject(); + + // Check the POST response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.toString(), actual.getString("item")); + assertEquals("'%s' in Workspace %d copied to '%s' in Workspace %d" + .formatted(expected, workspaceId, expectedDestination, otherWorkspaceId), + actual.getString("response")); + + // Simple check that the item was actually copied: + // trying to get it at both its old and new location should return a 200 + final var getOldResp = wsServer.get(ownerToken, workspaceId, expected); + assertEquals(200, getOldResp.status()); + + final var getNewResp = wsServer.get(ownerToken, otherWorkspaceId, expectedDestination); + assertEquals(200, getNewResp.status()); + + // The copied file has the same contents + assertEquals(getOldResp.text(), getNewResp.text()); + } + } + + /** + * Generate arguments to test basic upload cases. + */ + private static Stream bulkPostBasicCasesArgs() { + final var topFileInput = Path.of("top_file.txt"); + final var nestedFileInput = Path.of("other_dir/other_file.txt"); + final var topDirInput = Path.of("top_dir"); + final var nestedDirInput = Path.of("other_dir/nested_dir"); + final var siblingNameInput = Path.of("destination"); + + return Stream.of( + Arguments.arguments(named("Top Level File Single Bulk POST", List.of(topFileInput))), + Arguments.arguments(named("Top Level Directory Single Bulk POST", List.of(topDirInput))), + Arguments.arguments(named("Nested File Single Bulk POST", List.of(nestedFileInput))), + Arguments.arguments(named("Nested Directory Single Bulk POST", List.of(nestedDirInput))), + Arguments.arguments(named("Multiple Files Bulk POST", List.of(topFileInput, nestedFileInput))), + Arguments.arguments(named("Multiple Directories Bulk POST", List.of(topDirInput, nestedDirInput))), + Arguments.arguments(named( + "Mixed Files and Directories Bulk POST", + List.of(topFileInput, nestedFileInput, nestedDirInput, topDirInput))), + Arguments.arguments(named("Sibling Directory with Subset Name", List.of(siblingNameInput))) + ); + } + + /** + * When only one item move fails, the overall status is 207, the successful items have a status of 200, + * and the unsuccessful items have an appropriate error status. + */ + @Test + void mixedResultsMove() { + final var resp = wsServer.bulkMove( + ownerToken, + workspaceId, + List.of(Path.of("fake_file.seq"), Path.of("top_file.txt"), Path.of("other_dir")), + Path.of("top_dir/other_nested_dir"), + Optional.empty(), + Optional.empty()); + + // Check Response + assertEquals(207, resp.status()); + final var respBody = getArrayBody(resp); + assertEquals(3, respBody.size()); + + // First item should be the nonexistant file with a 404 File Not Found + final var fakeFile = respBody.getFirst().asJsonObject(); + assertEquals("fake_file.seq", fakeFile.getString("item")); + assertEquals(404, fakeFile.getInt("status")); + + // Second item should be the file that exists + final var realFile = respBody.get(1).asJsonObject(); + assertEquals("top_file.txt", realFile.getString("item")); + assertEquals(200, realFile.getInt("status")); + // Check the item was moved + assertEquals(404, wsServer.get(ownerToken, workspaceId, Path.of("top_file.txt")).status()); + assertEquals(200, wsServer.get(ownerToken, workspaceId, Path.of("top_dir/other_nested_dir/top_file.txt")).status()); + + // Third item should be the directory that exists + final var otherFile = respBody.getLast().asJsonObject(); + assertEquals("other_dir", otherFile.getString("item")); + assertEquals(200, otherFile.getInt("status")); + // Check the item was moved + assertEquals(404, wsServer.get(ownerToken, workspaceId, Path.of("other_dir")).status()); + assertEquals(200, wsServer.get(ownerToken, workspaceId, Path.of("top_dir/other_nested_dir/other_dir")).status()); + } + + /** + * When only one item copy fails, the overall status is 207, the successful items have a status of 200, + * and the unsuccessful items have an appropriate error status. + */ + @Test + void mixedResultsCopy() { + final var resp = wsServer.bulkCopy( + ownerToken, + workspaceId, + List.of(Path.of("fake_file.seq"), Path.of("top_file.txt"), Path.of("other_dir")), + Path.of("top_dir/other_nested_dir"), + Optional.empty(), + Optional.empty()); + + // Check Response + assertEquals(207, resp.status()); + final var respBody = getArrayBody(resp); + assertEquals(3, respBody.size()); + + // First item should be the nonexistant file with a 404 File Not Found + final var fakeFile = respBody.getFirst().asJsonObject(); + assertEquals("fake_file.seq", fakeFile.getString("item")); + assertEquals(404, fakeFile.getInt("status")); + + // Second item should be the file that exists + final var realFile = respBody.get(1).asJsonObject(); + assertEquals("top_file.txt", realFile.getString("item")); + assertEquals(200, realFile.getInt("status")); + // Check the item was copied + assertEquals(200, wsServer.get(ownerToken, workspaceId, Path.of("top_file.txt")).status()); + assertEquals(200, wsServer.get(ownerToken, workspaceId, Path.of("top_dir/other_nested_dir/top_file.txt")).status()); + + // Third item should be the directory that exists + final var otherFile = respBody.getLast().asJsonObject(); + assertEquals("other_dir", otherFile.getString("item")); + assertEquals(200, otherFile.getInt("status")); + // Check the item was copied + assertEquals(200, wsServer.get(ownerToken, workspaceId, Path.of("other_dir")).status()); + assertEquals(200, wsServer.get(ownerToken, workspaceId, Path.of("top_dir/other_nested_dir/other_dir")).status()); + } + + @Nested + class Overwrite { + /** + * Prep for the Overwrite Tests by putting conflict files in the destination directory + */ + @BeforeEach + void prepOverwriteTests() { + final List conflictContents = List.of( + new BulkPutItem.FileBulkPutItem("destination_dir/top_file.txt", "conflicting top level file"), + new BulkPutItem.DirectoryBulkPutItem("destination_dir/top_dir"), + new BulkPutItem.DirectoryBulkPutItem("destination_dir/nested_dir"), + new BulkPutItem.FileBulkPutItem("destination_dir/other_file.txt", "conflicting file within a directory") + ); + + wsServer.bulkPut(ownerToken, workspaceId, conflictContents); + wsServer.bulkPut(ownerToken, otherWorkspaceId, conflictContents); + } + + /** + * An item that exists in both the original and destination locations + * + * @param originalPath the path of the item to be moved or copied + * @param originalContents the contents of the item at its original location + * @param conflictContents the contents of the conflicting + */ + private record ConflictItem(Path originalPath, String originalContents, String conflictContents) {} + + /** + * Generate arguments to test basic upload cases. + */ + private static Stream overwriteCasesArgs() { + final var topFile = new ConflictItem( + Path.of("top_file.txt"), + "top level file", + "conflicting top level file"); + final var nestedFile = new ConflictItem( + Path.of("other_dir/other_file.txt"), + "another file within a directory", + "conflicting file within a directory"); + final var topDir = new ConflictItem( + Path.of("top_dir"), + "[" + + "{\"name\":\"nested_dir\",\"type\":\"DIRECTORY\",\"contents\":[" + + "{\"name\":\"nested_file.txt\",\"type\":\"TEXT\"}]}," + + "{\"name\":\"other_nested_dir\",\"type\":\"DIRECTORY\",\"contents\":[]}," + + "{\"name\":\"sub_file.txt\",\"type\":\"TEXT\"}]", + JsonArray.EMPTY_JSON_ARRAY.toString()); + final var nestedDir = new ConflictItem( + Path.of("other_dir/nested_dir"), + JsonArray.EMPTY_JSON_ARRAY.toString(), + JsonArray.EMPTY_JSON_ARRAY.toString()); + + return Stream.of( + Arguments.arguments(named("Top Level File", List.of(topFile))), + Arguments.arguments(named("Top Level Directory", List.of(topDir))), + Arguments.arguments(named("Nested File", List.of(nestedFile))), + Arguments.arguments(named("Nested Directory", List.of(nestedDir))), + Arguments.arguments(named("Multiple Files", List.of(topFile, nestedFile))), + Arguments.arguments(named("Multiple Directories", List.of(topDir, nestedDir))), + Arguments.arguments(named("Mixed Files and Directories", List.of(topFile, nestedFile, nestedDir, topDir))) + ); + } + + /** + * With overwrite unset, a conflict is returned. This tests for both within and between workspaces + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkMoveOverwriteUnset(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var withinResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.empty(), + Optional.empty()); + + final var betweenResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.of(otherWorkspaceId), + Optional.empty()); + + // Check Status Code + assertEquals(207, withinResp.status()); + assertEquals(207, betweenResp.status()); + + // Check Details of Responses + final var withinRespBody = getArrayBody(withinResp); + final var betweenRespBody = getArrayBody(betweenResp); + + assertEquals(withinRespBody.size(), betweenRespBody.size()); + + for (int i = 0; i < withinRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualWithin = withinRespBody.get(i).asJsonObject(); + final var actualBetween = betweenRespBody.get(i).asJsonObject(); + + assertEquals(409, actualWithin.getInt("status")); + assertEquals(409, actualBetween.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, workspaceId, conflictLocation).text()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, otherWorkspaceId, conflictLocation).text()); + + // Check that the original file was not moved due to the conflict, + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, expected.originalPath).text()); + } + } + + /** + * With overwrite set to false, a conflict is returned. This tests for both within and between workspaces + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkMoveOverwriteFalse(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var withinResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.empty(), + Optional.of(false)); + + final var betweenResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.of(otherWorkspaceId), + Optional.of(false)); + + // Check Status Code + assertEquals(207, withinResp.status()); + assertEquals(207, betweenResp.status()); + + // Check Details of Responses + final var withinRespBody = getArrayBody(withinResp); + final var betweenRespBody = getArrayBody(betweenResp); + + assertEquals(withinRespBody.size(), betweenRespBody.size()); + + for (int i = 0; i < withinRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualWithin = withinRespBody.get(i).asJsonObject(); + final var actualBetween = betweenRespBody.get(i).asJsonObject(); + + assertEquals(409, actualWithin.getInt("status")); + assertEquals(409, actualBetween.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, workspaceId, conflictLocation).text()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, otherWorkspaceId, conflictLocation).text()); + + // Check that the original file was not moved due to the conflict, + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, expected.originalPath).text()); + } + } + + /** + * With overwrite set to true, no conflict occurs. This tests for moving within a workspace + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkMoveOverwriteTrueWithinWS(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var withinResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.empty(), + Optional.of(true)); + + // Check Status Code + assertEquals(207, withinResp.status()); + + // Check Details of Responses + final var withinRespBody = getArrayBody(withinResp); + + + for (int i = 0; i < withinRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualWithin = withinRespBody.get(i).asJsonObject(); + + assertEquals(200, actualWithin.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, conflictLocation).text()); + + // Check that the original file was moved + assertEquals(404, wsServer.get(ownerToken, workspaceId, expected.originalPath).status()); + } + } + + /** + * With overwrite set to true, no conflict occurs. This tests for moving between workspaces + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkMoveOverwriteTrueBetweenWS(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var betweenResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.of(otherWorkspaceId), + Optional.of(true)); + + // Check Status Code + assertEquals(207, betweenResp.status()); + + // Check Details of Responses + final var betweenRespBody = getArrayBody(betweenResp); + + for (int i = 0; i < betweenRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualBetween = betweenRespBody.get(i).asJsonObject(); + + assertEquals(200, actualBetween.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.originalContents, wsServer.get(ownerToken, otherWorkspaceId, conflictLocation).text()); + + // Check that the original file was moved + assertEquals(404, wsServer.get(ownerToken, workspaceId, expected.originalPath).status()); + } + } + + /** + * With overwrite unset, a conflict is returned. This tests for both within and between workspaces + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkCopyOverwriteUnset(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var withinResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.empty(), + Optional.empty()); + + final var betweenResp = wsServer.bulkMove( + ownerToken, + workspaceId, + paths, + destination, + Optional.of(otherWorkspaceId), + Optional.empty()); + + // Check Status Code + assertEquals(207, withinResp.status()); + assertEquals(207, betweenResp.status()); + + // Check Details of Responses + final var withinRespBody = getArrayBody(withinResp); + final var betweenRespBody = getArrayBody(betweenResp); + + assertEquals(withinRespBody.size(), betweenRespBody.size()); + + for (int i = 0; i < withinRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualWithin = withinRespBody.get(i).asJsonObject(); + final var actualBetween = betweenRespBody.get(i).asJsonObject(); + + assertEquals(409, actualWithin.getInt("status")); + assertEquals(409, actualBetween.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, workspaceId, conflictLocation).text()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, otherWorkspaceId, conflictLocation).text()); + + // Check that the original file was untouched. + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, expected.originalPath).text()); + } + } + + /** + * With overwrite set to false, a conflict is returned. This tests for both within and between workspaces + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkCopyOverwriteFalse(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var withinResp = wsServer.bulkCopy( + ownerToken, + workspaceId, + paths, + destination, + Optional.empty(), + Optional.of(false)); + + final var betweenResp = wsServer.bulkCopy( + ownerToken, + workspaceId, + paths, + destination, + Optional.of(otherWorkspaceId), + Optional.of(false)); + + // Check Status Code + assertEquals(207, withinResp.status()); + assertEquals(207, betweenResp.status()); + + // Check Details of Responses + final var withinRespBody = getArrayBody(withinResp); + final var betweenRespBody = getArrayBody(betweenResp); + + assertEquals(withinRespBody.size(), betweenRespBody.size()); + + for (int i = 0; i < withinRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualWithin = withinRespBody.get(i).asJsonObject(); + final var actualBetween = betweenRespBody.get(i).asJsonObject(); + + assertEquals(409, actualWithin.getInt("status")); + assertEquals(409, actualBetween.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, workspaceId, conflictLocation).text()); + assertEquals(expected.conflictContents, wsServer.get(ownerToken, otherWorkspaceId, conflictLocation).text()); + + // Check that the original file was not touched + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, expected.originalPath).text()); + } + } + + /** + * With overwrite set to true, no conflict occurs. This tests for moving within a workspace + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkCopyOverwriteTrueWithinWS(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var withinResp = wsServer.bulkCopy( + ownerToken, + workspaceId, + paths, + destination, + Optional.empty(), + Optional.of(true)); + + // Check Status Code + assertEquals(207, withinResp.status()); + + // Check Details of Responses + final var withinRespBody = getArrayBody(withinResp); + + + for (int i = 0; i < withinRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualWithin = withinRespBody.get(i).asJsonObject(); + + assertEquals(200, actualWithin.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, conflictLocation).text()); + + // Check that the original file is untouched + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, expected.originalPath).text()); + } + } + + /** + * With overwrite set to true, no conflict occurs. This tests for moving between workspaces + */ + @ParameterizedTest + @MethodSource("overwriteCasesArgs") + void bulkCopyOverwriteTrueBetweenWS(List inputs) { + final var paths = inputs.stream().map(i -> i.originalPath).toList(); + final var destination = Path.of("./destination_dir"); + + final var betweenResp = wsServer.bulkCopy( + ownerToken, + workspaceId, + paths, + destination, + Optional.of(otherWorkspaceId), + Optional.of(true)); + + // Check Status Code + assertEquals(207, betweenResp.status()); + + // Check Details of Responses + final var betweenRespBody = getArrayBody(betweenResp); + + for (int i = 0; i < betweenRespBody.size(); ++i) { + final var expected = inputs.get(i); + final var actualBetween = betweenRespBody.get(i).asJsonObject(); + + assertEquals(200, actualBetween.getInt("status")); + + // Check file contents + final var conflictLocation = destination.resolve(expected.originalPath.getFileName()); + assertEquals(expected.originalContents, wsServer.get(ownerToken, otherWorkspaceId, conflictLocation).text()); + + // Check that the original file is untouched + assertEquals(expected.originalContents, wsServer.get(ownerToken, workspaceId, expected.originalPath).text()); + } + } + } + + @Nested + class MalformedRequest { + private static final String endpoint = "/ws/bulk/%d"; + + @Test + void noBody() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", body.getString("type")); + assertTrue(body.getString("message").startsWith("Invalid body format. Expected body format is a JSON object with the form:")); + } + + @Test + void nonJsonBody() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "text/plain") + .setData("Delete some file please"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Body must be type application/json", body.getString("message")); + } + + @Test + void incorrectContentTypeHeader() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/octet-stream") + .setData("{}"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Body must be type application/json", body.getString("message")); + } + + @Test + void emptyBody() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json") + .setData("{}"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", body.getString("type")); + assertTrue(body.getString("message").startsWith("Invalid body format. Expected body format is a JSON object with the form:")); + } + + @Test + void emptyItemsArray() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json") + .setData("{\"items\": [], \"moveTo\": \".\"}"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Cannot process request: at least one item must be specified.", body.getString("message")); + } + + @Test + void bothMoveAndCopy() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json") + .setData("{\"items\": [{\"path\": \"top_file.txt\"}], \"moveTo\": \".\", \"copyTo\": \".\"}"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", body.getString("type")); + assertTrue(body.getString("message").startsWith("Invalid body format. Expected body format is a JSON object with the form:")); + } + + /** + * One of "copyTo" or "moveTo" must be specified + */ + @Test + void noPostTypeSpecified() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json") + .setData("{\"items\": [{\"path\": \"top_file.txt\"}]"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", body.getString("type")); + assertTrue(body.getString("message").startsWith("Invalid body format. Expected body format is a JSON object with the form:")); + } + + @Test + void invalidPostType() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json") + .setData("{\"items\": [{\"path\": \"top_file.txt\"}], \"move\": \".\"}"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.POST); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", body.getString("type")); + assertTrue(body.getString("message").startsWith("Invalid body format. Expected body format is a JSON object with the form:")); + } + } + } + + @Nested + class BulkDelete { + private int workspaceId; + + @BeforeEach + void beforeEach() throws IOException { + workspaceId = wsServer.createWorkspace("bulkDeleteWS", parcelId); + + // Prepopulate ws with contents + final List wsContents = List.of( + new BulkPutItem.DirectoryBulkPutItem("top_dir"), + new BulkPutItem.DirectoryBulkPutItem("top_dir/nested_dir"), + new BulkPutItem.DirectoryBulkPutItem("top_dir/other_nested_dir"), + new BulkPutItem.DirectoryBulkPutItem("other_dir"), + new BulkPutItem.DirectoryBulkPutItem("other_dir/nested_dir"), + new BulkPutItem.FileBulkPutItem("top_file.txt", "top level file"), + new BulkPutItem.FileBulkPutItem("top_dir/sub_file.txt", "file within a directory"), + new BulkPutItem.FileBulkPutItem("other_dir/other_file.txt", "another file within a directory"), + new BulkPutItem.FileBulkPutItem("top_dir/nested_dir/nested_file.txt", "file within a nested directory") + ); + + wsServer.bulkPut(ownerToken, workspaceId, wsContents); + } + + @AfterEach + void afterEach() throws IOException { + wsServer.deleteWorkspace(workspaceId); + } + + /** + * Basic successful cases. + * All of these should return a top level status of 207, + * and an array of JSON objects with the same length as the input list. + * Each object in the array should have a status of 200, an 'item' field with the deleted item's name, + * and a 'result' field that either says "directory created" or "file uploaded", + * as appropriate based on the created item's type. + * Additionally, a GET request for the item should succeed after the PUT. + */ + @ParameterizedTest + @MethodSource("bulkDeleteBasicCasesArgs") + void bulkDeleteBasicCases(List inputs) { + final var resp = wsServer.bulkDelete(ownerToken, workspaceId, inputs); + + // Check status code + assertEquals(207, resp.status()); + + // Check details of response + final var respBody = getArrayBody(resp); + assertEquals(inputs.size(), respBody.size()); + + for (int i = 0; i < respBody.size(); ++i) { + final var expected = inputs.get(i); + final var actual = respBody.get(i).asJsonObject(); + + // Check the DELETE response + assertEquals(200, actual.getInt("status")); + assertEquals(expected.toString(), actual.getString("item")); + + + // Simple check that the item was actually deleted -- trying to get it should return a 404 Resource Not Found + final var getResp = wsServer.get(ownerToken, workspaceId, expected); + assertEquals(404, getResp.status()); + } + } + + /** + * Generate arguments to test basic upload cases. + */ + private static Stream bulkDeleteBasicCasesArgs() { + final var topFileInput = Path.of("top_file.txt"); + final var nestedFileInput = Path.of("other_dir/other_file.txt"); + final var topDirInput = Path.of("top_dir"); + final var nestedDirInput = Path.of("other_dir/nested_dir"); + + return Stream.of( + Arguments.arguments(named("Top Level File Single Bulk DELETE", List.of(topFileInput))), + Arguments.arguments(named("Top Level Directory Single Bulk DELETE", List.of(topDirInput))), + Arguments.arguments(named("Nested File Single Bulk DELETE", List.of(nestedFileInput))), + Arguments.arguments(named("Nested Directory Single Bulk DELETE", List.of(nestedDirInput))), + Arguments.arguments(named("Multiple Files Bulk DELETE", List.of(topFileInput, nestedFileInput))), + Arguments.arguments(named("Multiple Directories Bulk DELETE", List.of(topDirInput, nestedDirInput))), + Arguments.arguments(named( + "Mixed Files and Directories Bulk DELETE", + List.of(topFileInput, nestedFileInput, nestedDirInput, topDirInput))) + ); + } + + /** + * When only one item delete fails, the overall status is 207, the successful items have a status of 200, + * and the unsuccessful items have an appropriate error status. + */ + @Test + void mixedResults() { + final var resp = wsServer.bulkDelete( + ownerToken, + workspaceId, + List.of(Path.of("fake_file.seq"), Path.of("top_file.txt"), Path.of("other_dir"))); + + // Check Response + assertEquals(207, resp.status()); + final var respBody = getArrayBody(resp); + assertEquals(3, respBody.size()); + + // First item should be the nonexistant file with a 404 File Not Found + final var fakeFile = respBody.getFirst().asJsonObject(); + assertEquals("fake_file.seq", fakeFile.getString("item")); + assertEquals(404, fakeFile.getInt("status")); + + // Second item should be the file that exists + final var realFile = respBody.get(1).asJsonObject(); + assertEquals("top_file.txt", realFile.getString("item")); + assertEquals(200, realFile.getInt("status")); + assertEquals(404, wsServer.get(ownerToken, workspaceId, Path.of("top_file.txt")).status()); + + // Third item should be the directory that exists + final var otherFile = respBody.getLast().asJsonObject(); + assertEquals("other_dir", otherFile.getString("item")); + assertEquals(200, otherFile.getInt("status")); + assertEquals(404, wsServer.get(ownerToken, workspaceId, Path.of("other_dir")).status()); + } + + @Nested + class MalformedRequest { + private static final String endpoint = "/ws/bulk/%d"; + + @Test + void noBody() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.DELETE); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("JSON_PARSING_EXCEPTION", body.getString("type")); + assertEquals("Invalid body format. Expected body format is an array of paths.", body.getString("message")); + } + + @Test + void nonJsonBody() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "text/plain") + .setData("Delete some file please"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.DELETE); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Body must be type application/json", body.getString("message")); + } + + @Test + void incorrectContentTypeHeader() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/octet-stream") + .setData("[\"top-file.txt\"]"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.DELETE); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Body must be type application/json", body.getString("message")); + } + + @Test + void emptyBodyArray() { + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+ownerToken) + .setHeader("Content-type", "application/json") + .setData("[]"); + + final var resp = wsServer.makeRequest(endpoint.formatted(workspaceId), options, WorkspaceRequests.RequestType.DELETE); + assertEquals(400, resp.status()); + final var body = getBody(resp); + assertEquals("MALFORMED_REQUEST", body.getString("type")); + assertEquals("Cannot process request: at least one item must be specified.", body.getString("message")); + } + } + } + } + /** * Tests for the response of the `authorize` before on `/ws/*` routes. * Uses GET /ws/{workspaceId} for testing diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/BulkPutItem.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/BulkPutItem.java new file mode 100644 index 0000000000..3626648348 --- /dev/null +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/types/workspaces/BulkPutItem.java @@ -0,0 +1,106 @@ +package gov.nasa.jpl.aerie.e2e.types.workspaces; + + +import com.microsoft.playwright.options.FilePayload; + +import javax.json.Json; +import javax.json.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Optional; + +/** + * A well-formatted input for one item to be created via the bulk-put Workspace Server endpoint. + * If testing malformed inputs, generate the request in the test itself. + */ +public sealed interface BulkPutItem { + JsonObject toJson(); + Path getPath(); + + /** + * Input to create a file + * @param filePath Where to upload the file to in the workspace + * @param fileContents What to put in the file + * @param inputFileName If provided, the file contents will be added to the request under this name instead of the file name. + * @param overwrite If provided, what to set the `overwrite` flag to. + */ + record FileBulkPutItem(Path filePath, String fileContents, Optional inputFileName, Optional overwrite) implements BulkPutItem { + public FileBulkPutItem(Path filePath, String contents) { + this(filePath, contents, Optional.empty(), Optional.empty()); + } + + public FileBulkPutItem(String filePath, String contents) { + this(Path.of(filePath), contents, Optional.empty(), Optional.empty()); + } + + public FileBulkPutItem(Path filePath, String contents, String inputFileName) { + this(filePath, contents, Optional.ofNullable(inputFileName), Optional.empty()); + } + + public FileBulkPutItem(Path filePath, String contents, boolean overwrite) { + this(filePath, contents, Optional.empty(), Optional.of(overwrite)); + } + + public FileBulkPutItem(Path filePath, String contents, String inputFileName, boolean overwrite) { + this(filePath, contents, Optional.of(inputFileName), Optional.of(overwrite)); + } + + public FilePayload generateFilePayload() { + if (inputFileName().isPresent()) { + return new FilePayload( + inputFileName.get(), + "text/plain", + fileContents.getBytes(StandardCharsets.UTF_8) + ); + } + return new FilePayload( + filePath.getFileName().toString(), + "text/plain", + fileContents.getBytes(StandardCharsets.UTF_8) + ); + } + + public JsonObject toJson() { + final var obj = Json.createObjectBuilder() + .add("path", filePath.toString()) + .add("type", "file"); + + inputFileName.ifPresent(i -> obj.add("input_file_name", i)); + overwrite.ifPresent(o -> obj.add("overwrite", o)); + + return obj.build(); + } + + public Path getPath() {return filePath;} + } + + /** + * Input to create a directory + * @param dirPath Path to create the folder at + * @param useFolder If true, uses 'folder' as the type instead of 'directory'. Defaults to false. + */ + record DirectoryBulkPutItem(Path dirPath, boolean useFolder) implements BulkPutItem { + public DirectoryBulkPutItem(Path dirPath) { + this(dirPath, false); + } + + public DirectoryBulkPutItem(String dirPath) { + this(Path.of(dirPath), false); + } + + public JsonObject toJson() { + final var obj = Json.createObjectBuilder() + .add("path", dirPath.toString()); + + if(useFolder) { + obj.add("type", "folder"); + } else { + obj.add("type", "directory"); + } + + return obj.build(); + } + + public Path getPath() {return dirPath;} + } +} diff --git a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/WorkspaceRequests.java b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/WorkspaceRequests.java index 3307d72548..583f79e689 100644 --- a/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/WorkspaceRequests.java +++ b/e2e-tests/src/test/java/gov/nasa/jpl/aerie/e2e/utils/WorkspaceRequests.java @@ -7,11 +7,13 @@ import com.microsoft.playwright.options.FilePayload; import com.microsoft.playwright.options.FormData; import com.microsoft.playwright.options.RequestOptions; +import gov.nasa.jpl.aerie.e2e.types.workspaces.BulkPutItem; import javax.json.Json; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -185,12 +187,154 @@ public void deleteWorkspace(int workspaceId) throws IOException { * @return the APIResponse from the Workspace Server */ public APIResponse deleteWorkspace(String authToken, int workspaceId) { - final var options = RequestOptions.create() - .setHeader("Authorization", "Bearer "+authToken); + final var options = RequestOptions.create().setHeader("Authorization", "Bearer "+authToken); return request.delete("/ws/%d".formatted(workspaceId), options); } + /** + * Call the GET endpoint in the Workspace Server + * @param token The JWT token for the user making the request + * @param workspaceId The workspace the item is in + * @param itemPath The Path within the workspace where the item is + * @return The APIResponse from the server + */ + public APIResponse get(String token, int workspaceId, Path itemPath) { + final var options = RequestOptions.create().setHeader("Authorization", "Bearer " + token); + return request.get("/ws/%d/%s".formatted(workspaceId, itemPath.toString()), options); + } + + /** + * Call the 'Bulk PUT' endpoint in the Workspace server. + * @param token The JWT token for the user making the request + * @param workspaceId The workspace to insert the file into + * @param toPut List of things to be placed on the server. If there are file contents, it will be uploaded as file. + * If the Optional is empty, it will be uploaded as a directory. + * @return The APIResponse from the server + */ + public APIResponse bulkPut(String token, int workspaceId, List toPut) { + final var formData = FormData.create(); + final var bodyArray = Json.createArrayBuilder(); + + // Generate the request body + for(final var putItem : toPut) { + bodyArray.add(putItem.toJson()); + if(putItem instanceof BulkPutItem.FileBulkPutItem fileInput) { + formData.append("files", fileInput.generateFilePayload()); + } + } + + // Generate the request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+token) + .setMultipart(formData.set("body", bodyArray.build().toString())); + + return request.put("/ws/bulk/%d".formatted(workspaceId), options); + } + + /** + * Call the 'Bulk POST' endpoint in the Workspace server to move items. + * + * @param token The JWT token for the user making the request + * @param workspaceId The source workspace + * @param paths The list of items to be affected by the request + * @param destination The destination folder to place the items in + * @param destinationWorkspaceId If present, the destination workspace. + * @param overwrite If present, the value of the 'overwrite' flag + * @return The APIResponse from the server + */ + public APIResponse bulkMove( + String token, + int workspaceId, + List paths, + Path destination, + Optional destinationWorkspaceId, + Optional overwrite + ) { + // Generate the request body + final var body = Json.createObjectBuilder().add("moveTo", destination.toString()); + + final var itemsArray = Json.createArrayBuilder(); + paths.forEach(p -> itemsArray.add(Json.createObjectBuilder().add("path", p.toString()))); + body.add("items", itemsArray); + + destinationWorkspaceId.ifPresent(wid -> body.add("toWorkspace", wid)); + + overwrite.ifPresent(o -> body.add("overwrite", o)); + + // Generate request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+token) + .setHeader("Content-type", "application/json") + .setData(body.build().toString()); + + return request.post("/ws/bulk/%d".formatted(workspaceId), options); + } + + /** + * Call the 'Bulk POST' endpoint in the Workspace server to copy items. + * @param token The JWT token for the user making the request + * @param workspaceId The source workspace + * @param paths The list of items to be affected by the request + * @param destination The destination folder to place the items in + * @param destinationWorkspaceId If present, the destination workspace. + * @param overwrite If present, the value of the 'overwrite' flag + * @return The APIResponse from the server + */ + public APIResponse bulkCopy( + String token, + int workspaceId, + List paths, + Path destination, + Optional destinationWorkspaceId, + Optional overwrite + ) { + // Generate the request body + final var body = Json.createObjectBuilder().add("copyTo", destination.toString()); + + final var itemsArray = Json.createArrayBuilder(); + paths.forEach(p -> itemsArray.add(Json.createObjectBuilder().add("path", p.toString()))); + body.add("items", itemsArray); + + destinationWorkspaceId.ifPresent(wid -> body.add("toWorkspace", wid)); + + overwrite.ifPresent(o -> body.add("overwrite", o)); + + // Generate request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+token) + .setHeader("Content-type", "application/json") + .setData(body.build().toString()); + + return request.post("/ws/bulk/%d".formatted(workspaceId), options); + } + + + /** + * Call the 'Bulk DELETE' endpoint in the Workspace server. + * @param token The JWT token for the user making the request + * @param workspaceId The source workspace + * @param paths The list of items to be deleted + * @return The APIResponse from the server + */ + public APIResponse bulkDelete(String token, int workspaceId, List paths) { + // Generate the request body + final var body = Json.createArrayBuilder(); + paths.forEach(p -> body.add(p.toString())); + + // Generate request + final var options = RequestOptions + .create() + .setHeader("Authorization", "Bearer "+token) + .setHeader("Content-type", "application/json") + .setData(body.build().toString()); + + return request.delete("/ws/bulk/%d".formatted(workspaceId), options); + } + @Override public void close() { request.dispose(); diff --git a/workspace-server/build.gradle b/workspace-server/build.gradle index 85ee56668c..7717cf9077 100644 --- a/workspace-server/build.gradle +++ b/workspace-server/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'com.zaxxer:HikariCP:5.0.1' testImplementation 'org.junit.jupiter:junit-jupiter-engine:6.0.1' + testImplementation 'org.junit.jupiter:junit-jupiter-params:6.0.1' testImplementation 'net.jqwik:jqwik:1.6.5' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java index 55502139b1..88d8c22105 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/FormattedError.java @@ -46,6 +46,25 @@ public FormattedError(String message) { this.message = message; } + /** + * For use in the event of an endpoint failing without throwing an exception, but where there's a more detailed cause. + */ + public FormattedError(String message, String cause) { + this.type = "INTERNAL_ERROR"; + this.message = message; + this.cause = Optional.ofNullable(cause); + } + + /** + * For use in the event of an endpoint failing without throwing an exception, + * but "INTERNAL_ERROR" does not make sense as the error type (i.e. the request is malformed) + */ + public FormattedError(String type, String message, Optional cause) { + this.type = type; + this.message = message; + this.cause = cause; + } + /** * Create a FormattedException from a generic Exception object. * @param type the category of exception. Should be in SCREAMING_SNAKE_CASE @@ -135,8 +154,8 @@ public FormattedError(NumberFormatException nfe) { } // IllegalArgumentException - public FormattedError(IllegalArgumentException iae, String message) { - this("ILLEGAL_ARGUMENT", message, iae); + public FormattedError(IllegalArgumentException iae) { + this("ILLEGAL_ARGUMENT", iae); } // JSONException @@ -150,6 +169,16 @@ public FormattedError(ValidationException ve) { this.message = ve.getMessage() != null ? ve.getMessage() : "Invalid request"; trace = Optional.of(generateTrace(ve)); } + + // Null Pointer Exception + public FormattedError(NullPointerException ne, String message) { + this("NULL_POINTER_EXCEPTION", message, ne); + } + + //Security Exception + public FormattedError(SecurityException se) { + this("SECURITY_EXCEPTION", se.getMessage(), se); + } //endregion /** @@ -182,6 +211,11 @@ public JsonObject toJson() { return builder.build(); } + @Override + public String toString() { + return this.toJson().toString(); + } + /** * Internal class so that Javalin serializes the FormattedError class using its `toJson` method. * This avoids needing to call `toJson` every time the FormattedError class is used as an endpoint return. @@ -194,7 +228,7 @@ public void serialize( final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException { - jsonGenerator.writeRaw(formattedError.toJson().toString()); + jsonGenerator.writeRaw(formattedError.toString()); } } } diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java index 9211957731..a6eef3e93b 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java @@ -7,26 +7,38 @@ import gov.nasa.jpl.aerie.permissions.exceptions.PermissionsServiceException; import gov.nasa.jpl.aerie.permissions.gql.WorkspaceId; import gov.nasa.jpl.aerie.workspace.server.postgres.NoSuchWorkspaceException; +import gov.nasa.jpl.aerie.workspace.server.types.BulkPutItem; +import gov.nasa.jpl.aerie.workspace.server.types.PostActions; +import gov.nasa.jpl.aerie.workspace.server.types.ItemType; +import gov.nasa.jpl.aerie.workspace.server.types.PostBody; +import gov.nasa.jpl.aerie.workspace.server.types.BulkPostItem; import io.javalin.Javalin; import io.javalin.apibuilder.ApiBuilder; import io.javalin.http.ContentType; import io.javalin.http.Context; import io.javalin.http.HandlerType; -import io.javalin.http.HttpStatus; +import io.javalin.http.HttpResponseException; import io.javalin.http.UnauthorizedResponse; +import io.javalin.http.UploadedFile; import io.javalin.plugin.Plugin; import io.javalin.validation.ValidationException; import javax.json.Json; +import javax.json.JsonArray; import javax.json.JsonException; -import javax.json.JsonObject; -import javax.json.JsonReader; +import javax.json.JsonString; +import javax.json.JsonValue; import java.io.IOException; import java.io.StringReader; import java.nio.file.Path; import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,7 +66,7 @@ public WorkspaceBindings( private record PathInformation(int workspaceId, Path filePath) { static PathInformation of(Context context) { final var workspaceId = Integer.parseInt(context.pathParam("workspaceId")); - final var filePath = Path.of(context.pathParam("filePath")); + final var filePath = Path.of(context.pathParam("path")); return new PathInformation(workspaceId, filePath); } @@ -77,12 +89,20 @@ public void apply(final Javalin javalin) { // Health check path("/health", () -> ApiBuilder.get(ctx -> ctx.status(200))); + // Bulk CRUD operations for Files and Directories: + // Placed 'bulk' before 'workspaceId' to avoid accidentally matching on the individual File/Directory pattern + path("/ws/bulk/{workspaceId}", () -> { + ApiBuilder.put(this::bulkUpload); + ApiBuilder.post(this::bulkPost); + ApiBuilder.delete(this::bulkDelete); + }); + // CRUD operations for Files and Directories: - path("/ws/{workspaceId}/", + path("/ws/{workspaceId}/", () -> { - ApiBuilder.get(this::get); - ApiBuilder.put(this::put); - ApiBuilder.delete(this::delete); + ApiBuilder.get(this::getFileDirectory); + ApiBuilder.put(this::createFileDirectory); + ApiBuilder.delete(this::deleteFileDirectory); ApiBuilder.post(this::post); }); @@ -97,10 +117,16 @@ public void apply(final Javalin javalin) { // Default exception handlers for common endpoint exceptions javalin.exception(NoSuchWorkspaceException.class, (ex, ctx) -> ctx.status(404).json(new FormattedError(ex))); - javalin.exception(IOException.class, - (ex, ctx) -> ctx.status(500).json(new FormattedError(ex))); - javalin.exception(SQLException.class, - (ex, ctx) -> ctx.status(500).json(new FormattedError(ex))); + javalin.exception(IOException.class, (ex, ctx) -> { + final var fe = new FormattedError(ex); + logger.warn("IO Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception(SQLException.class, (ex, ctx) -> { + final var fe = new FormattedError(ex); + logger.warn("SQL Exception: {}", fe); + ctx.status(500).json(fe); + }); javalin.exception(UnauthorizedResponse.class, (ex, ctx) -> { final var message = ex.getMessage() != null ? ex.getMessage() : "Unauthorized"; logger.warn("401 Unauthorized: {}", message); @@ -108,8 +134,22 @@ public void apply(final Javalin javalin) { }); javalin.exception(NumberFormatException.class, (ex, ctx) -> ctx.status(400).json(new FormattedError(ex))); + javalin.exception(SecurityException.class, (ex, ctx) -> { + final var fe = new FormattedError(ex); + logger.warn("Security Exception: {}", fe); + ctx.status(500).json(fe); + }); + javalin.exception(HttpResponseException.class, (ex, ctx) -> ctx.status(ex.getStatus()).json(new FormattedError("HTTP_RESPONSE_EXCEPTION", ex))); + javalin.exception(Exception.class, (ex, ctx) -> { + // Catch-all for unexpected issues + final var message = ex.getMessage() != null ? ex.getMessage() : "Unknown error."; + final var fe = new FormattedError("UNKNOWN_ERROR", message, ex); + logger.error("Unexpected error processing workspace request {}", fe); + ctx.status(500).json(fe); + }); } + // region Authorization /** * Validate that the request has a valid authorization */ @@ -144,6 +184,41 @@ private JWTService.UserSession authorize(Context context) { } } + /** + * Check that the request meets the permissions to perform the given action on the workspace. + * If it does not, format the context into an appropriate error state. + * @return true, if the user passes the permissions check. false otherwise + */ + private boolean checkPermissions(Context context, int workspaceId, WorkspaceAction action) { + try { + final var user = authorize(context); + permissionsService.check( + action, + user.activeRole(), + user.userId(), + new WorkspaceId(workspaceId)); + return true; + } catch (Forbidden ue) { + context.status(403).json(new FormattedError(ue)); + return false; + } catch (IOException ioe) { + final var fe = new FormattedError(ioe, "Could not check permissions."); + logger.warn("IO Exception: {}", fe); + context.status(500).json(fe); + return false; + } catch (PermissionsServiceException pse) { + final var fe = new FormattedError(pse, "Could not check permissions."); + logger.warn("Permissions Service Exception: {}", fe); + context.status(500).json(new FormattedError(pse, "Could not check permissions.")); + return false; + } catch (gov.nasa.jpl.aerie.permissions.exceptions.NoSuchWorkspaceException nsw) { + context.status(404).json(new FormattedError(nsw, "Could not check permissions on Workspace %d.".formatted(nsw.id.id()))); + return false; + } + } + // endregion + + // region Workspace Level Methods private void createWorkspace(Context context) { // Permissions check try { @@ -153,10 +228,14 @@ private void createWorkspace(Context context) { context.status(403).json(new FormattedError(ue)); return; } catch (IOException ioe) { - context.status(500).json(new FormattedError(ioe, "Could not create workspace.")); + final var fe = new FormattedError(ioe, "Could not create workspace."); + logger.warn("IO Exception: {}", fe); + context.status(500).json(fe); return; } catch (PermissionsServiceException pse) { - context.status(500).json(new FormattedError(pse, "Could not create workspace.")); + final var fe = new FormattedError(pse, "Could not create workspace."); + logger.warn("Permissions Service Exception: {}", fe); + context.status(500).json(fe); return; } @@ -225,6 +304,14 @@ private void createWorkspace(Context context) { if(workspaceId.isPresent()) { context.status(200).result(workspaceId.get().toString()); } else { + logger.warn( + """ + Create Workspace failed for inputs: + \tLocation: {}, + \tName: {}, + \tParcel ID: {}, + \tUser: {} (active role: {}) + """, workspaceLocation, workspaceName, parcelId, user.userId(), user.activeRole()); context.status(500).json(new FormattedError("Unable to create workspace.")); } } @@ -241,12 +328,15 @@ private void deleteWorkspace(Context context) { if (workspaceService.deleteWorkspace(workspaceId)) { context.status(200).result("Workspace deleted."); } else { + logger.warn(errorMsg); context.status(500).json(new FormattedError(errorMsg)); } } catch (NoSuchWorkspaceException ex) { context.status(404).json(new FormattedError(ex, errorMsg)); } catch (SQLException e) { - context.status(500).json(new FormattedError(e, errorMsg)); + final var fe = new FormattedError(e, errorMsg); + logger.warn("SQL Exception: {}", fe); + context.status(500).json(fe); } } @@ -259,13 +349,15 @@ private void listWorkspaceContents(Context context) { listContents(context); } + // endregion + // region Single Item Endpoints private void listContents(Context context) { final var workspaceId = Integer.parseInt(context.pathParam("workspaceId")); final Optional directoryPath; - if(context.pathParamMap().containsKey("filePath")) { - directoryPath = Optional.of(Path.of(context.pathParam("filePath"))); + if(context.pathParamMap().containsKey("path")) { + directoryPath = Optional.of(Path.of(context.pathParam("path"))); } else { directoryPath = Optional.empty(); } @@ -282,15 +374,19 @@ private void listContents(Context context) { } context.status(200).json(fileTree.toJson().toString()); } catch (IOException ioe) { - context.status(500).json(new FormattedError(ioe)); + final var fe = new FormattedError(ioe); + logger.warn("IO Exception: {}", fe); + context.status(500).json(fe); } catch (SQLException se) { - context.status(500).json(new FormattedError(se)); + final var fe = new FormattedError(se); + logger.warn("SQL Exception: {}", fe); + context.status(500).json(fe); } catch (NoSuchWorkspaceException ex) { context.status(404).json(new FormattedError(ex)); } } - private void get(Context context) throws NoSuchWorkspaceException { + private void getFileDirectory(Context context) throws NoSuchWorkspaceException { // Permissions Check final var pathInfo = PathInformation.of(context); if(!checkPermissions(context, pathInfo.workspaceId, WorkspaceAction.read_file_directory)) { @@ -313,335 +409,768 @@ private void get(Context context) throws NoSuchWorkspaceException { context.header("Content-Disposition", "attachment; filename=\"" + pathInfo.fileName() + "\""); context.status(200).result(inputStream); } catch (IOException ioe) { - context.status(500).json(new FormattedError(ioe, "Could not load file " + pathInfo.fileName())); + final var fe = new FormattedError(ioe, "Could not load file " + pathInfo.fileName()); + logger.warn("IO Exception: {}", fe); + context.status(500).json(fe); } catch (SQLException se) { - context.status(500).json(new FormattedError(se, "Could not load file " + pathInfo.fileName())); + final var fe = new FormattedError(se, "Could not load file " + pathInfo.fileName()); + logger.warn("SQL Exception: {}", fe); + context.status(500).json(fe); } } } - private void put(Context context) throws NoSuchWorkspaceException, IOException { + private void createFileDirectory(Context context) { // Permissions Check final var pathInfo = PathInformation.of(context); if(!checkPermissions(context, pathInfo.workspaceId, WorkspaceAction.write_file_directory)) { return; } - final String type; + final ItemType type; final Optional overwrite; // Validate the permitted query parameters on Put requests try { - type = context.queryParamAsClass("type", String.class) - .allowNullable() - .check(Objects::nonNull, "'type' must be provided.") - .check(ts -> "file".equalsIgnoreCase(ts) || "directory".equalsIgnoreCase(ts), - "'type' must be one of 'file' or 'directory'") - .get(); + final var typeParam = context.queryParamAsClass("type", String.class) + .allowNullable() + .check(Objects::nonNull, "'type' must be provided.") + .get(); + type = ItemType.of(typeParam); final var overwriteValidator = context.queryParamAsClass("overwrite", Boolean.class); overwrite = overwriteValidator.hasValue() ? Optional.of(overwriteValidator.get()) : Optional.empty(); } catch (ValidationException ve) { context.status(400).json(new FormattedError(ve)); return; + } catch (IllegalArgumentException iae) { + context.status(400).json(new FormattedError(iae)); + return; } - if ("file".equalsIgnoreCase(type)) { - // Report a "Conflict" status if the file already exists and "overwrite" is false - // "overwrite" defaults to "false" if unspecified - if(workspaceService.checkFileExists(pathInfo.workspaceId, pathInfo.filePath) - && !overwrite.orElse(false)) { - context.status(409).json(new FormattedError(pathInfo.fileName() + " already exists.")); - return; - } - - // Reject the request if the file isn't provided. + final HandlerResult uploadResults; + if (type == ItemType.file) { final var file = context.uploadedFile("file"); + // Reject the request if the file isn't provided. if (file == null || !pathInfo.fileName().equals(file.filename())) { context.status(400).json(new FormattedError("No file provided with the name " + pathInfo.fileName())); return; } - if (workspaceService.saveFile(pathInfo.workspaceId, pathInfo.filePath, file)) { - context.status(200).result("File " + pathInfo.fileName() + " uploaded to " + pathInfo.filePath); - } else { - context.status(500).json(new FormattedError("Could not save file.")); - } - } else if ("directory".equalsIgnoreCase(type)) { + uploadResults = handleFileUpload( + pathInfo.workspaceId, + pathInfo.filePath, + file, + overwrite.orElse(false)); + + } else if (type == ItemType.directory) { // Reject the request if the "overwrite" flag is supplied if(overwrite.isPresent()) { context.status(400).json(new FormattedError("Query parameter 'overwrite' is not permitted when creating a directory.")); return; } - - if (workspaceService.createDirectory(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(200).result("Directory created."); - } else { - context.status(500).json(new FormattedError("Could not create directory.")); - } + uploadResults = handleCreateDirectory(pathInfo.workspaceId(), pathInfo.filePath()); } else { context.status(400).json(new FormattedError("Query param 'type' has invalid value "+type)); + return; + } + + if(uploadResults.response.getValueType() == JsonValue.ValueType.STRING) { + context.status(uploadResults.status).result(((JsonString) uploadResults.response()).getString()); + } else { + context.status(uploadResults.status).json(uploadResults.response); } } - private void post(Context context) { + private void post(Context context) throws NoSuchWorkspaceException { final String helpText = """ Expected JSON body with one of the following formats: - To move a file: + To move an item: { - "moveTo": "", - "toWorkspace": , (optional) + "toWorkspace": 2, // optional. if provided, the item will be moved to the specified workspace. + // defaults to the current workspace. + "moveTo": "path/to/destination", // required. path within the destination workspace to move the item to, ending with the item. + // to rename an item, end the 'moveTo' path with a name that differs from the item's current name. + "overwrite": false // optional. only permitted when moving a file. + // if provided, determines whether the moved file will overwrite an existing file at "moveTo". + // defaults to "false". } - To copy a file: + To copy an item: { - "copyTo": "", - "toWorkspace": , (optional) - } - """; + "toWorkspace": 2, // optional. if provided, the item will be copied to the specified workspace. + // defaults to the current workspace. + "copyTo": "path/to/destination/folder", // required. path within the destination workspace to copy the item to, ending with the item. + "overwrite": false // optional. only permitted when moving a file. + // if provided, determines whether the moved file will overwrite an existing file at "moveTo". + // defaults to "false". + }"""; - try (JsonReader bodyReader = Json.createReader(new StringReader(context.body()))) { - JsonObject bodyJson = bodyReader.readObject(); - final boolean success; + final var pathInfo = PathInformation.of(context); + final var sourceWorkspace = pathInfo.workspaceId; + final PostBody body; - if (bodyJson.containsKey("moveTo")) { - success = handleMove(context, bodyJson); - } else if (bodyJson.containsKey("copyTo")) { - success = handleCopy(context, bodyJson); - } else { - context.status(400).json(new FormattedError("Invalid request. Must include either 'moveTo' or 'copyTo' key.\n\n" + helpText)); - return; - } + // Get body + if(!ContentType.JSON.equals(context.contentType())) { + context.status(400).json(new FormattedError("Body must be type "+ContentType.JSON)); + } + try(final var bodyReader = Json.createReader(new StringReader(context.body()))){ + body = PostBody.fromJson(bodyReader.readObject(), sourceWorkspace); + } catch (JsonException je) { + context.status(400).json(new FormattedError( + je, + "Invalid body format. Expected body format is an array of JSON objects with the form:\n\n"+helpText)); + return; + } - if (success) { - context.status(200).result("Success"); + // Permissions Check and Action Handling + switch (body.action()) { + case PostActions.MOVE -> { + // Moving between workspaces requires "readFile", "deleteFile" on Workspace 1 and "writeFile" on Workspace 2 + // (Permission derived from mv -v, which shows that moving a file is "copy, then delete") + if (!(checkPermissions(context, sourceWorkspace, WorkspaceAction.read_file_directory) + && checkPermissions(context, sourceWorkspace, WorkspaceAction.delete_file_directory) + && checkPermissions(context, body.destinationWorkspaceId(), WorkspaceAction.write_file_directory))) { + return; + } + final var moveResults = handleMove( + pathInfo.filePath(), + body.destinationPath(), + sourceWorkspace, + body.destinationWorkspaceId(), + body.overwrite()); + if(moveResults.response.getValueType() == JsonValue.ValueType.STRING) { + context.status(moveResults.status).result(((JsonString) moveResults.response()).getString()); + } else { + context.status(moveResults.status).json(moveResults.response); + } } - // If the copy or move did not return successfully, but did not set a status code, set the status code to 500 - // Works because `context.status` initializes to HttpStatus.OK - else if (context.status().equals(HttpStatus.OK)) { - context.status(500).json(new FormattedError("Internal Error")); + case PostActions.COPY -> { + // Copying between workspaces requires "readFile" on Workspace 1 and "writeFile" on Workspace 2 + if (!(checkPermissions(context, sourceWorkspace, WorkspaceAction.read_file_directory) + && checkPermissions(context, body.destinationWorkspaceId(), WorkspaceAction.write_file_directory))) { + final var copyResults = handleCopy( + pathInfo.filePath, + body.destinationPath(), + sourceWorkspace, + body.destinationWorkspaceId(), + body.overwrite()); + if (copyResults.response.getValueType() == JsonValue.ValueType.STRING) { + context.status(copyResults.status).result(((JsonString) copyResults.response()).getString()); + } else { + context.status(copyResults.status).json(copyResults.response); + } + } } + default -> context.status(501).json(new FormattedError("Unsupported post action: " + body.action().name()).toJson()); + } + } + private void deleteFileDirectory(Context context) { + final var pathInfo = PathInformation.of(context); + // Permissions Check + if(!checkPermissions(context, pathInfo.workspaceId, WorkspaceAction.delete_file_directory)) { + return; + } - } catch (JsonException je) { - // Malformed JSON in request body - context.status(400).json(new FormattedError(je, "Malformed JSON.\n\n" + helpText)); - } catch (IllegalArgumentException iae) { - // Logical errors or unsupported operations - context.status(400).json(new FormattedError(iae, "Invalid request.\n\n" + helpText)); - } catch (NoSuchWorkspaceException nsw) { - // Workspace not found - context.status(404).json(new FormattedError(nsw)); - } catch (IOException ioe) { - logger.error("Error processing workspace request", ioe); - context.status(500).json(new FormattedError(ioe)); - } catch (SQLException se) { - // Internal server error - logger.error("Error processing workspace request", se); - context.status(500).json(new FormattedError(se)); - } catch (Exception e) { - // Catch-all for unexpected issues - logger.error("Unexpected error processing workspace request", e); - final var message = e.getMessage() != null ? e.getMessage() : "Unknown error.\n\n" + helpText; - context.status(500).json(new FormattedError("UNKNOWN_ERROR", message, e)); + final var deleteResults = handleDelete(pathInfo.workspaceId, pathInfo.filePath); + + if(deleteResults.response.getValueType() == JsonValue.ValueType.STRING) { + context.status(deleteResults.status).result(((JsonString) deleteResults.response()).getString()); + } else { + context.status(deleteResults.status).json(deleteResults.response); } } + // endregion - private record CopyMoveValid(int status, String message){} + // region Single Item Action Handlers + private record HandlerResult(int status, JsonValue response){ + HandlerResult(int status, FormattedError fe) { + this(status, fe.toJson()); + } + } - private CopyMoveValid isCopyOrMoveValid(int sourceWorkspace, Path sourceFile, int targetWorkspace, Path targetFile) { + private HandlerResult handleFileUpload( + int workspaceId, + Path uploadPath, + UploadedFile file, + boolean overwrite + ) { try { - // Return "Resource Not Found" if sourceFile does not exist - if (!workspaceService.checkFileExists(sourceWorkspace, sourceFile)) { - return new CopyMoveValid(404, sourceFile + " does not exist in the source workspace."); + // Report a "Conflict" status if the file already exists and "overwrite" is false + // "overwrite" defaults to "false" if unspecified + if (workspaceService.checkFileExists(workspaceId, uploadPath) && !overwrite) { + return new HandlerResult(409, new FormattedError(uploadPath + " already exists.")); } - } catch (NoSuchWorkspaceException se) { - // Return "Resource Not Found" if source workspace does not exist - return new CopyMoveValid(404, "Source workspace with ID "+sourceWorkspace+" does not exist."); + + if (workspaceService.saveFile(workspaceId, uploadPath, file)) { + return new HandlerResult( + 200, + Json.createValue("File " + uploadPath.getFileName() + " uploaded to " + uploadPath)); + } else { + logger.warn("UPLOAD: Save File failed for path {}", uploadPath); + return new HandlerResult(500, new FormattedError("Could not save file.")); + } + } catch (IOException ioe) { + final var fe = new FormattedError(ioe, "Could not save file."); + logger.warn("UPLOAD: IOException: {}", fe); + return new HandlerResult(500, fe); + } catch (WorkspaceFileOpException wfe) { + final var fe = new FormattedError(wfe, "Could not save file."); + logger.warn("UPLOAD: WorkspaceFileOpException: {}", fe); + return new HandlerResult(500, fe); + } catch (NoSuchWorkspaceException nsw) { + return new HandlerResult(404, new FormattedError(nsw, "Could not create directory.")); } + } + private HandlerResult handleCreateDirectory(int workspaceId, Path destinationPath) { try { - // Return "Conflicted" if destination exists - if (workspaceService.checkFileExists(targetWorkspace, targetFile)) { - return new CopyMoveValid(409, targetFile + " already exists"); + if (workspaceService.createDirectory(workspaceId, destinationPath)) { + return new HandlerResult(200, Json.createValue("Directory created.")); + } else { + logger.warn("UPLOAD: Create Directory failed for path {}", destinationPath); + return new HandlerResult(500, new FormattedError("Could not create directory.")); } + } catch (IOException ioe) { + logger.warn("UPLOAD: IOException: {}", destinationPath); + return new HandlerResult(500, new FormattedError(ioe, "Could not create directory.")); + } catch (WorkspaceFileOpException wfe) { + logger.warn("UPLOAD: WorkspaceFileOpException: {}", destinationPath); + return new HandlerResult(500, new FormattedError(wfe, "Could not create directory.")); + } catch (NoSuchWorkspaceException nsw) { + return new HandlerResult(404, new FormattedError(nsw, "Could not create directory.")); } - catch (NoSuchWorkspaceException se) { - // Return "Resource not found" if target workspace does not exist - return new CopyMoveValid(404, "Target workspace with ID "+targetWorkspace+" does not exist."); - } - - return new CopyMoveValid(200, "Success"); } - private boolean handleMove(Context context, JsonObject bodyJson) - throws IOException, NoSuchWorkspaceException, SQLException + private HandlerResult handleMove( + Path toMove, + Path destinationPath, + int sourceWorkspaceId, + int destinationWorkspaceId, + boolean overwrite + ) throws NoSuchWorkspaceException { - final var pathInfo = PathInformation.of(context); - - final var destination = Path.of(bodyJson.getString("moveTo")); - int sourceWorkspace = pathInfo.workspaceId; - int targetWorkspace = pathInfo.workspaceId; // default to same workspace unless toWorkspace is included - if (bodyJson.containsKey("toWorkspace")) { - targetWorkspace = bodyJson.getInt("toWorkspace"); - } - - // Permissions Check - // Moving between workspaces requires "readFile", "deleteFile" on Workspace 1 and "writeFile" on Workspace 2 - // (Permission derived from mv -v, which shows that moving a file is "copy, then delete") - if (!(checkPermissions(context, sourceWorkspace, WorkspaceAction.read_file_directory) - && checkPermissions(context, sourceWorkspace, WorkspaceAction.delete_file_directory) - && checkPermissions(context, targetWorkspace, WorkspaceAction.write_file_directory))) { - return false; + final var errorMsg = "Unable to move '%s' in Workspace %d to '%s' in Workspace %d." + .formatted(toMove, sourceWorkspaceId, destinationPath, destinationWorkspaceId); + final var successMsg = Json.createValue( + "'%s' in Workspace %d moved to '%s' in Workspace %d" + .formatted(toMove, sourceWorkspaceId, destinationPath, destinationWorkspaceId)); + + if (!workspaceService.checkFileExists(sourceWorkspaceId, toMove)) { + return new HandlerResult( + 404, + new FormattedError(errorMsg, toMove + " does not exist in the source workspace.").toJson()); } - CopyMoveValid validMove = isCopyOrMoveValid(sourceWorkspace, pathInfo.filePath, targetWorkspace, destination); - if (validMove.status != 200) { - context.status(validMove.status).json(new FormattedError(validMove.message)); - return false; + if (workspaceService.checkFileExists(destinationWorkspaceId, destinationPath) && !overwrite) { + return new HandlerResult(409, new FormattedError(errorMsg, destinationPath + " already exists.").toJson()); } - final var errorMsg = "Unable to move '%s' in Workspace %d to '%s' in Workspace %d." - .formatted(pathInfo, sourceWorkspace, destination, targetWorkspace); try { - if (workspaceService.isDirectory(sourceWorkspace, pathInfo.filePath())) { - if (workspaceService.moveDirectory(sourceWorkspace, pathInfo.filePath, targetWorkspace, destination)) { - return true; + if (workspaceService.isDirectory(sourceWorkspaceId, toMove)) { + if (workspaceService.moveDirectory(sourceWorkspaceId, toMove, destinationWorkspaceId, destinationPath)) { + return new HandlerResult(200, successMsg); } else { - context.status(500).json(new FormattedError(errorMsg)); - return false; + return new HandlerResult(500, new FormattedError(errorMsg).toJson()); } } else { - if (workspaceService.moveFile(sourceWorkspace, pathInfo.filePath, targetWorkspace, destination)) { - return true; + if (workspaceService.moveFile(sourceWorkspaceId, toMove, destinationWorkspaceId, destinationPath)) { + return new HandlerResult(200, successMsg); } else { - context.status(500).json(new FormattedError(errorMsg)); - return false; + return new HandlerResult(500, new FormattedError(errorMsg).toJson()); } } - } catch (NoSuchWorkspaceException ex) { - context.status(404).json(new FormattedError(ex, errorMsg)); - return false; } catch (SQLException se) { - context.status(500).json(new FormattedError(se, errorMsg)); - return false; + return new HandlerResult(500, new FormattedError(se, errorMsg).toJson()); + } catch (IOException ioe) { + return new HandlerResult(500, new FormattedError(ioe, errorMsg).toJson()); } catch (WorkspaceFileOpException wfe) { - context.status(500).json(new FormattedError(wfe, errorMsg)); - return false; + return new HandlerResult(500, new FormattedError(wfe, errorMsg).toJson()); } } - private boolean handleCopy(Context context, JsonObject bodyJson) - throws NoSuchWorkspaceException, SQLException + private HandlerResult handleCopy( + Path toCopy, + Path destinationPath, + int sourceWorkspaceId, + int destinationWorkspaceId, + boolean overwrite + ) throws NoSuchWorkspaceException { - final var pathInfo = PathInformation.of(context); - - final var destination = Path.of(bodyJson.getString("copyTo")); - int sourceWorkspace = pathInfo.workspaceId; - int targetWorkspace = pathInfo.workspaceId; // default to same workspace unless toWorkspace is included - if (bodyJson.containsKey("toWorkspace")) { - targetWorkspace = bodyJson.getInt("toWorkspace"); + final var errorMsg = "Unable to copy '%s' in Workspace %d to '%s' in Workspace %d." + .formatted(toCopy, sourceWorkspaceId, destinationPath, destinationWorkspaceId); + final var successMsg = Json.createValue( + "'%s' in Workspace %d copied to '%s' in Workspace %d" + .formatted(toCopy, sourceWorkspaceId, destinationPath, destinationWorkspaceId)); + + if (!workspaceService.checkFileExists(sourceWorkspaceId, toCopy)) { + return new HandlerResult( + 404, + new FormattedError(errorMsg, toCopy + " does not exist in the source workspace.").toJson()); } - // Permissions Check - // Copying between workspaces requires "readFile" on Workspace 1 and "writeFile" on Workspace 2 - if (!(checkPermissions(context, sourceWorkspace, WorkspaceAction.read_file_directory) - && checkPermissions(context, targetWorkspace, WorkspaceAction.write_file_directory))) { - return false; + if (workspaceService.checkFileExists(destinationWorkspaceId, destinationPath) && !overwrite) { + return new HandlerResult(409, new FormattedError(errorMsg, destinationPath + " already exists.").toJson()); } - CopyMoveValid validCopy = isCopyOrMoveValid(sourceWorkspace, pathInfo.filePath, targetWorkspace, destination); - if (validCopy.status != 200) { - context.status(validCopy.status).json(new FormattedError(validCopy.message)); - return false; + try { + if (workspaceService.isDirectory(sourceWorkspaceId, toCopy)) { + if (workspaceService.copyDirectory(sourceWorkspaceId, toCopy, destinationWorkspaceId, destinationPath)) { + return new HandlerResult(200, successMsg); + } else { + return new HandlerResult(500, new FormattedError(errorMsg).toJson()); + } + } else { + if (workspaceService.copyFile(sourceWorkspaceId, toCopy, destinationWorkspaceId, destinationPath)) { + return new HandlerResult(200, successMsg); + } else { + return new HandlerResult(500, new FormattedError(errorMsg).toJson()); + } + } + } catch (SQLException se) { + return new HandlerResult(500, new FormattedError(se, errorMsg).toJson()); + } catch (WorkspaceFileOpException wfe) { + return new HandlerResult(500, new FormattedError(wfe, errorMsg).toJson()); } + } - // Error message to use if the operation fails - final var errorMessage = "Unable to copy '%s' in Workspace %d to '%s' in Workspace %d" - .formatted(pathInfo.filePath, sourceWorkspace, destination, targetWorkspace); + private HandlerResult handleDelete(int workspaceId, Path filePath) { try { - if (workspaceService.isDirectory(sourceWorkspace, pathInfo.filePath())) { - if (workspaceService.copyDirectory(sourceWorkspace, pathInfo.filePath, targetWorkspace, destination)) { - return true; + final var errorMsg = "Could not delete %s.".formatted(filePath); + if (!workspaceService.checkFileExists(workspaceId, filePath)) { + return new HandlerResult(404, new FormattedError(filePath.getFileName() + " does not exist.")); + } + + if (workspaceService.isDirectory(workspaceId, filePath)) { + if (workspaceService.deleteDirectory(workspaceId, filePath)) { + return new HandlerResult(200, Json.createValue("Directory deleted.")); } else { - context.status(500).json(new FormattedError(errorMessage)); - return false; + logger.warn("DELETE: Delete Directory failed for path {}", filePath); + return new HandlerResult(500, new FormattedError(errorMsg)); } } else { - if (workspaceService.copyFile(sourceWorkspace, pathInfo.filePath, targetWorkspace, destination)) { - return true; + + if (workspaceService.deleteFile(workspaceId, filePath)) { + return new HandlerResult(200, Json.createValue("File deleted.")); } else { - context.status(500).json(new FormattedError(errorMessage)); - return false; + logger.warn("DELETE: Delete File failed for path {}", filePath); + return new HandlerResult(500, new FormattedError(errorMsg)); } } - } catch (NoSuchWorkspaceException ex) { - context.status(404).json(new FormattedError(ex)); - return false; - } catch (SQLException ex) { - context.status(500).json(new FormattedError(ex, errorMessage)); - return false; - } catch (WorkspaceFileOpException ex) { - context.status(500).json(new FormattedError(ex, errorMessage)); - return false; + } catch (IOException io) { + final var fe = new FormattedError(io); + logger.warn("DELETE: IOException: {}", fe); + return new HandlerResult(500, fe); + } catch (SQLException se) { + final var fe = new FormattedError(se); + logger.warn("DELETE: SQL Exception: {}", fe); + return new HandlerResult(500, fe); + } catch (NoSuchWorkspaceException nsw) { + return new HandlerResult(404, new FormattedError(nsw)); } } + // endregion - private void delete(Context context) throws NoSuchWorkspaceException, IOException { - final var pathInfo = PathInformation.of(context); - final var errorMsg = "Could not delete %s.".formatted(pathInfo.filePath); + // region Bulk Endpoints + /** + * Create multiple files and/or directories in a workspace. + * + * Input syntax: Multipart form data with two parts: + * body: JSON Array of JSON Objects: + * [ {"path": "path/to/file", "type": file }, + * {"path": "diff/path/to/file", "type": file, "input_file_name": "dupe_file", "overwrite": false }, + * { "path": "path/to/folder/", "type": directory }, ... ] + * files: Attached file contents + * + * "input_file_name" is an optional field such that users can upload multiple files + * with different contents but the same name to different directories + * If "input_file_name" for a file is specified, look for an object called that in the body. + * Else, look for the file's filename. + * Regardless, name the file as per `path` + * + * "overwrite" is permitted on "file"-type objects. + * If "true", will overwrite the contents of the file should it already exist. + * If "false" or not specified, will not upload the file should it already exist. + * + * If there is an issue with the request or permissions, returns an appropriate 4XX status. + * Else, returns a 207 Multi-Status with an individual response per-object + */ + public void bulkUpload(Context context) { + final var workspaceId = Integer.parseInt(context.pathParam("workspaceId")); + final List toUpload; - // Permissions Check - if(!checkPermissions(context, pathInfo.workspaceId, WorkspaceAction.delete_file_directory)) { + final var helpText = """ + For File Upload: + { + "path": "path/to/file.txt" // required. path to where the file should be placed in the workspace, ending with the file name + "type": "file" // required. must be set to "file" for file-type uploads + "input_file_name": "other_file.txt" // optional. if specified, attach the file contents under this name. + // defaults to the filename from the "path" field (in this example, file.txt) + "overwrite": false // optional. if provided, determines whether the uploaded file will overwrite an existing file at "path" + // defaults to "false". + } + + For Directory Creation: + { + "path": "path/to/directory" // required. path to where in the workspace the directory will be created, ending with the directory name + "type": "directory" // required. must be set to either "folder" or "directory" for directory-type uploads + }"""; + + // Get body + if(!context.isMultipartFormData() || context.formParam("body") == null) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Invalid body format.", + Optional.of(""" + Expected body format is a multipart/form with two fields: + "body", which contains the list of JSON objects describing where to put each file and directory + "files", which contains all uploaded file contents"""))); + return; + } + try(final var bodyReader = Json.createReader(new StringReader(context.formParam("body")))){ + toUpload = bodyReader.readArray().getValuesAs(obj -> BulkPutItem.fromJson(obj.asJsonObject())); + } catch (JsonException je) { + context.status(400).json(new FormattedError( + je, + "Invalid body format. Expected body format is an array of JSON objects with the form:\n\n"+helpText)); return; } - if (!workspaceService.checkFileExists(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(404).json(new FormattedError(pathInfo.fileName() + " does not exist.")); + // Ensure that the user has specified at least one file or directory to upload + if(toUpload.isEmpty()) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Cannot process request: at least one item must be specified.", + Optional.empty())); return; } - if (workspaceService.isDirectory(pathInfo.workspaceId, pathInfo.filePath)) { - if (workspaceService.deleteDirectory(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(200).result("Directory deleted."); - } else { - context.status(500).json(new FormattedError(errorMsg)); + // Check permissions + if(!checkPermissions(context, workspaceId, WorkspaceAction.write_file_directory)) { + return; + } + + // Get the files + final var fileList = context.uploadedFiles("files"); + final Map fileMap = new HashMap<>(fileList.size()); + fileList.forEach(file -> fileMap.put(file.filename(), file)); + + // Check that files all had unique upload names: + if(fileList.size() != fileMap.size()) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Cannot process request: multiple files are attached under the same name.", + Optional.of("Attach file contents under unique names.\n\n" +helpText))); + return; + } + + // Check that no two items are trying to be uploaded to the same location + final var destinationSet = toUpload.stream().map(BulkPutItem::path).collect(Collectors.toSet()); + if(destinationSet.size() != toUpload.size()) { + context.status(409).json(new FormattedError( + "MALFORMED_REQUEST", + "Multiple items are attempting to be uploaded to the same location. Please give all items unique names.", + Optional.empty())); + return; + } + + // Create all specified objects + context.status(207).json(handleBulkUpload(toUpload, fileMap, workspaceId).toString()); + } + + private JsonArray handleBulkUpload( + List toUpload, + Map fileMap, + int workspaceId + ) { + final var responseArray = Json.createArrayBuilder(); + + for(final var item : toUpload){ + final HandlerResult uploadResults; + final var response = Json.createObjectBuilder() + .add("item", item.path().toString()); + + if(item.uploadType() == ItemType.file) { + // Do not create the file if the file contents are not provided + final var uploadedFileName = item.inputFileName().orElse(item.path().getFileName().toString()); + final var file = fileMap.getOrDefault(uploadedFileName, null); + if(file == null) { + response.add("status", 400) + .add("response", new FormattedError( + "MALFORMED_REQUEST", + "No file provided with the name "+uploadedFileName, + Optional.of("Attach file contents under the 'files' part of the request.")).toJson()); + responseArray.add(response); + continue; + } + + uploadResults = handleFileUpload( + workspaceId, + item.path(), + file, + item.overwrite() + ); + response.add("status", uploadResults.status) + .add("response", uploadResults.response); } - } else { - if (workspaceService.deleteFile(pathInfo.workspaceId, pathInfo.filePath)) { - context.status(200).result("File deleted."); + else if (item.uploadType() == ItemType.directory) { + uploadResults = handleCreateDirectory(workspaceId, item.path()); + response.add("status", uploadResults.status) + .add("response", uploadResults.response); } else { - context.status(500).json(new FormattedError(errorMsg)); + logger.debug("BULK UPLOAD: Unsupported item upload type: {}", item.uploadType()); + response.add("status", 501) + .add("response", new FormattedError("Unsupported item upload type: "+item.uploadType().name()).toJson()); } + // Add response to array + responseArray.add(response); } + + return responseArray.build(); } /** - * Check that the request meets the permissions to perform the given action on the workspace. - * If it does not, format the context into an appropriate error state. - * @return true, if the user passes the permissions check. false otherwise + * Move or Copy multiple files and/or directories in a workspace. + * + * See help text for Input Syntax + * + * If toWorkspace is provided, move or copy the files to that workspace. + * Otherwise, move or copy the files within the current workspace. + * + * If there is an issue with the request or permissions, returns an appropriate 4XX status. + * Else, returns a 207 Multi-Status with an individual response per-object */ - private boolean checkPermissions(Context context, int workspaceId, WorkspaceAction action) { - try { - final var user = authorize(context); - permissionsService.check( - action, - user.activeRole(), - user.userId(), - new WorkspaceId(workspaceId)); - return true; - } catch (Forbidden ue) { - context.status(403).json(new FormattedError(ue)); - return false; - } catch (IOException ioe) { - context.status(500).json(new FormattedError(ioe, "Could not check permissions.")); - return false; - } catch (PermissionsServiceException pse) { - context.status(500).json(new FormattedError(pse, "Could not check permissions.")); - return false; - }catch (gov.nasa.jpl.aerie.permissions.exceptions.NoSuchWorkspaceException nsw) { - context.status(404).json(new FormattedError(nsw, "Could not check permissions on Workspace %d.".formatted(nsw.id.id()))); - return false; + public void bulkPost(Context context) throws NoSuchWorkspaceException { + final var helpText = """ + To Move Items: + { + "items": [ // required. list of items to be moved + { + "path": "path/to/file1.txt", // required. path to the item within the workspace + "renameTo": "newFileName.txt" // optional. if provided, the new name of the item at the destination + // defaults to the item's current name (in this example "file1.txt") + }, + { "path": "path/to/file2.txt" }, + { + "path": "path/to/folder", + "renameTo": "newFolderName" + }, ... + ], + "toWorkspace": 2, // optional. if provided, items will be moved to the specified workspace. + // defaults to the current workspace. + "moveTo": "path/to/destination/folder", // required. path to the folder within the destination workspace where the items will be moved to + "overwrite": false // optional. if provided, determines whether the moved items will overwrite existing items in the destination folder + // defaults to "false". + } + + To Copy Items: + { + "items": [ // required. list of items to be copied + { + "path": "path/to/file1.txt", // required. path to the item within the workspace + "renameTo": "newFileName.txt" // optional. if provided, the new name of the item at the destination + // defaults to the item's current name (in this example "file1.txt") + }, + { "path": "path/to/file2.txt" }, + { + "path": "path/to/folder", + "renameTo": "newFolderName" + }, ... + ], + "toWorkspace": 2, // optional. if provided, items will be copied to the specified workspace. + // defaults to the current workspace. + "copyTo": "path/to/destination/folder", // required. path to the folder within the destination workspace where the items will be copied to + "overwrite": false // optional. if provided, determines whether the moved items will overwrite existing items in the destination folder + // defaults to "false". + }"""; + + final var sourceWorkspace = Integer.parseInt(context.pathParam("workspaceId")); + final List items; + final PostBody body; + + // Get body + if(!ContentType.JSON.equals(context.contentType())) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Body must be type "+ContentType.JSON, + Optional.empty())); + return; + } + + try(final var bodyReader = Json.createReader(new StringReader(context.body()))){ + final var jsonBody = bodyReader.readObject(); + body = PostBody.fromJson(jsonBody, sourceWorkspace); + items = jsonBody.getJsonArray("items") + .getValuesAs(o -> BulkPostItem.fromJson(o.asJsonObject(), body.destinationPath())); + } catch (JsonException je) { + context.status(400).json(new FormattedError( + je, + "Invalid body format. Expected body format is a JSON object with the form:\n\n"+helpText)); + return; + } + + // Ensure that the user has specified at least one item to alter + if(items.isEmpty()) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Cannot process request: at least one item must be specified.", + Optional.empty())); + return; + } + + // Ensure that no two inputs will try to write to the same location + final var destinationSet = items.stream().map(BulkPostItem::newPath).collect(Collectors.toSet()); + if(destinationSet.size() != items.size()) { + context.status(409).json(new FormattedError( + "MALFORMED_REQUEST", + "Multiple entries in 'item' have the same destination location. Use \"renameTo\" to resolve conflicts.", + Optional.empty())); + return; + } + + // Permissions Check and Action Handling + switch (body.action()) { + case PostActions.MOVE -> { + // Moving between workspaces requires "readFile", "deleteFile" on Workspace 1 and "writeFile" on Workspace 2 + // (Permission derived from mv -v, which shows that moving a file is "copy, then delete") + if (!(checkPermissions(context, sourceWorkspace, WorkspaceAction.read_file_directory) + && checkPermissions(context, sourceWorkspace, WorkspaceAction.delete_file_directory) + && checkPermissions(context, body.destinationWorkspaceId(), WorkspaceAction.write_file_directory))) { + return; + } + final var moveResults = handleBulkMove( + items, + sourceWorkspace, + body.destinationWorkspaceId(), + body.overwrite()); + context.status(207).json(moveResults.toString()); + } + case PostActions.COPY -> { + // Copying between workspaces requires "readFile" on Workspace 1 and "writeFile" on Workspace 2 + if (!(checkPermissions(context, sourceWorkspace, WorkspaceAction.read_file_directory) + && checkPermissions(context, body.destinationWorkspaceId(), WorkspaceAction.write_file_directory))) { + return; + } + final var copyResults = handleBulkCopy( + items, + sourceWorkspace, + body.destinationWorkspaceId(), + body.overwrite()); + context.status(207).json(copyResults.toString()); + } + default -> context.status(501).json(new FormattedError("Unsupported post action: " + body.action().name()).toJson()); + } + } + + private JsonArray handleBulkMove( + List toMove, + int sourceWorkspaceId, + int destinationWorkspaceId, + boolean overwrite + ) throws NoSuchWorkspaceException { + final var responseArray = Json.createArrayBuilder(); + for(final var item : toMove){ + final var results = handleMove( + item.currentLocation(), + item.newPath(), + sourceWorkspaceId, + destinationWorkspaceId, + overwrite); + final var response = Json.createObjectBuilder() + .add("item", item.currentLocation().toString()) + .add("status", results.status) + .add("response", results.response); + responseArray.add(response); + } + return responseArray.build(); + } + + private JsonArray handleBulkCopy( + List toCopy, + int sourceWorkspaceId, + int destinationWorkspaceId, + boolean overwrite + ) throws NoSuchWorkspaceException { + final var responseArray = Json.createArrayBuilder(); + for(final var item : toCopy) { + final var results = handleCopy( + item.currentLocation(), + item.newPath(), + sourceWorkspaceId, + destinationWorkspaceId, + overwrite); + final var response = Json.createObjectBuilder() + .add("item", item.currentLocation().toString()) + .add("status", results.status) + .add("response", results.response); + responseArray.add(response); + } + return responseArray.build(); + } + + /** + * Delete multiple files and/or directories in a workspace + * + * Input syntax: + * [ "path/to/file1", "path/to/folder", ... ] + * + * If there is an issue with the request or permissions, returns an appropriate 4XX status. + * Else, returns a 207 Multi-Status with an individual response per-object + * + * Response syntax: + * [ { "item": "path/to/file1", "status": 200, "response": "File deleted." }, + * { "item": "path/to/folder", "status": 404, "response": "path/to/folder does not exist."}, ... ] + */ + public void bulkDelete(Context context) { + final var workspaceId = Integer.parseInt(context.pathParam("workspaceId")); + final List toDelete; + + // Get body + if(!ContentType.JSON.equals(context.contentType())) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Body must be type "+ContentType.JSON, + Optional.empty())); + return; } + try(final var bodyReader = Json.createReader(new StringReader(context.body()))){ + toDelete = bodyReader.readArray().getValuesAs(JsonString::getString); + } catch (JsonException je) { + context.status(400).json(new FormattedError(je, "Invalid body format. Expected body format is an array of paths.")); + return; + } + // Ensure that the user has specified at least one file or directory to get the contents of + if(toDelete.isEmpty()) { + context.status(400).json(new FormattedError( + "MALFORMED_REQUEST", + "Cannot process request: at least one item must be specified.", + Optional.empty())); + return; + } + + // Permissions Check + if(!checkPermissions(context, workspaceId, WorkspaceAction.delete_file_directory)) { + return; + } + + // Return multipart response + context.status(207).json(handleBulkDelete(workspaceId, toDelete).toString()); + } + + private JsonArray handleBulkDelete(int workspaceId, List toDelete) { + final var responseArray = Json.createArrayBuilder(); + + for(final var item : toDelete) { + final var results = handleDelete(workspaceId, Path.of(item)); + final var response = Json.createObjectBuilder() + .add("item", item) + .add("status", results.status) + .add("response", results.response); + responseArray.add(response); + } + + return responseArray.build(); } + //endregion } diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemService.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemService.java index 44d042de42..5091cc77b1 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemService.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemService.java @@ -12,9 +12,12 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Optional; +import java.util.regex.Pattern; import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,14 +27,31 @@ public class WorkspaceFileSystemService implements WorkspaceService { final WorkspacePostgresRepository postgresRepository; /** - * Resolves a relative path against a workspace root, ensuring the result stays within the root directory. + * Resolve and validate a relative path against a workspace root for the purpose of writing while ensuring the + * result stays within the root directory. * Prevents path traversal attacks by rejecting absolute paths and any resolved path that escape the specified root. * @param rootPath the workspace root path * @param filePath the untrusted path to resolve against the root * @return the resolved and normalized path, guaranteed to be within the root * @throws SecurityException if the resolved path escapes the root or if the input is absolute + * @throws WorkspaceFileOpException if the resolved path is invalid (ie, contains illegal characters) */ - Path resolveSubPath(final Path rootPath, final Path filePath) { + Path resolveWritingPath(final Path rootPath, final Path filePath) throws WorkspaceFileOpException { + final var resolvedPath = resolveReadingPath(rootPath, filePath); + validatePath(resolvedPath); + return resolvedPath; + } + + /** + * Resolves a relative path against a workspace root for reading or otherwise fetching a File while ensuring the + * result stays within the root directory. + * Prevents path traversal attacks by rejecting absolute paths and any resolved path that escape the specified root. + * @param rootPath the workspace root path + * @param filePath the untrusted path to resolve against the root + * @return the resolved and normalized path, guaranteed to be within the root + * @throws SecurityException if the resolved path escapes the root or if the input is absolute + */ + Path resolveReadingPath(final Path rootPath, final Path filePath) { // disallow absolute file paths, since Path.of("/foo").resolve(Path.of("/etc/passwd")) -> "/etc/passwd" if (filePath.isAbsolute()) { throw new SecurityException("Absolute file paths not allowed"); @@ -44,6 +64,65 @@ Path resolveSubPath(final Path rootPath, final Path filePath) { return resolvedPath; } + /** + * Validates that the path does not contain any invalid characters. + * + * Forbidden Characters for File and Folder names: + * < (less than), > (greater than), : (colon), " (double quote), / (forward slash), \ (backslash), + * | (vertical bar or pipe), ? (question mark), * (asterisk), + * % (percent sign - causes issues with URL path resolution as it is not automatically encoded), + * # (pound sign - causes issues with URL path resolution as it is not automatically encoded), + * Unicode Control Characters (0-31, 127-159), + * trailing . + * trailing space + * + * While / (forward slash) is a forbidden characters in filenames, it's interpreted by Java's Path class as a + * folder delineator, meaning that it will not appear as a path segment. + * The character is still checked for just in case. + * + * Reserved Filenames (these are not permitted on Windows even if they have an extension): + * CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9, + * LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9 + * + * @param path the Path to validate + */ + void validatePath(final Path path) throws WorkspaceFileOpException { + final String[] reservedFilenames = {"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", + "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"}; + final var controlCharacters = Pattern.compile("([\u0000-\u001F]|[\u007F-\u009F])+", Pattern.UNICODE_CHARACTER_CLASS); + final var forbiddenCharacters = Pattern.compile("([|<>:/\"?*%#\\\\])+", Pattern.UNICODE_CHARACTER_CLASS); + + + for(final var pathSegment : path) { + final var segment = pathSegment.toString(); + // Check for trailing period or space + if(segment.endsWith(" ")) { + throw new WorkspaceFileOpException("Path segment '"+ segment+ "' cannot end in a space."); + } + if(segment.endsWith(".")) { + throw new WorkspaceFileOpException("Path segment '"+ segment+ "' cannot end in a period."); + } + + // Check for control characters + final var controlMatcher = controlCharacters.matcher(segment); + if(controlMatcher.find()){ + throw new WorkspaceFileOpException("Path segment '"+ segment+ "' has illegal characters: "+controlMatcher.group()); + } + + // Check for forbidden characters + final var forbiddenMatcher = forbiddenCharacters.matcher(segment); + if(forbiddenMatcher.find()){ + throw new WorkspaceFileOpException("Path segment '"+ segment+ "' has illegal characters: "+forbiddenMatcher.group()); + } + + // Check that the segment is not a reserved filenames: + final var name = segment.split("\\.")[0]; + if(Arrays.asList(reservedFilenames).contains(name)){ + throw new WorkspaceFileOpException("Path segment '"+ segment+ "' contains reserved name: "+name); + } + } + } + public WorkspaceFileSystemService(final WorkspacePostgresRepository postgresRepository) { this.postgresRepository = postgresRepository; } @@ -120,14 +199,14 @@ public boolean deleteWorkspace(final int workspaceId) throws NoSuchWorkspaceExce @Override public boolean checkFileExists(final int workspaceId, final Path filePath) throws NoSuchWorkspaceException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, filePath); + final var path = resolveReadingPath(repoPath, filePath); return path.toFile().exists(); } @Override public boolean isDirectory(final int workspaceId, final Path filePath) throws NoSuchWorkspaceException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, filePath); + final var path = resolveReadingPath(repoPath, filePath); return path.toFile().isDirectory(); } @@ -140,7 +219,7 @@ public RenderType getFileType(final Path filePath) throws SQLException { @Override public FileStream loadFile(final int workspaceId, final Path filePath) throws IOException, NoSuchWorkspaceException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, filePath); + final var path = resolveReadingPath(repoPath, filePath); final var file = path.toFile(); return new FileStream(new FileInputStream(file), file.getName(), Files.size(file.toPath())); @@ -148,9 +227,9 @@ public FileStream loadFile(final int workspaceId, final Path filePath) throws IO @Override public boolean saveFile(final int workspaceId, final Path filePath, final UploadedFile file) - throws NoSuchWorkspaceException { + throws NoSuchWorkspaceException, WorkspaceFileOpException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, filePath); + final var path = resolveWritingPath(repoPath, filePath); if(path.toFile().isDirectory()) return false; @@ -163,14 +242,11 @@ public boolean moveFile(final int oldWorkspaceId, final Path oldFilePath, final throws NoSuchWorkspaceException, SQLException, WorkspaceFileOpException { final var oldRepoPath = postgresRepository.workspaceRootPath(oldWorkspaceId); - final var oldPath = resolveSubPath(oldRepoPath, oldFilePath); + final var oldPath = resolveReadingPath(oldRepoPath, oldFilePath); final var newRepoPath = (oldWorkspaceId == newWorkspaceId) ? oldRepoPath : postgresRepository.workspaceRootPath(newWorkspaceId); - final var newPath = resolveSubPath(newRepoPath, newFilePath); + final var newPath = resolveWritingPath(newRepoPath, newFilePath); boolean success = true; - // Do not move the file if the destination already exists - if(newPath.toFile().exists()) throw new WorkspaceFileOpException("Destination file \"%s\" in workspace %d already exists.".formatted(newFilePath, newWorkspaceId)); - // Find hidden metadata files, if they exist, and move them final var metadataExtensions = postgresRepository.getMetadataExtensions(); for(final var extension : metadataExtensions) { @@ -189,22 +265,19 @@ public boolean copyFile(final int sourceWorkspaceId, final Path sourceFilePath, throws NoSuchWorkspaceException, SQLException, WorkspaceFileOpException { final var sourceRepoPath = postgresRepository.workspaceRootPath(sourceWorkspaceId); - final var sourcePath = resolveSubPath(sourceRepoPath, sourceFilePath); + final var sourcePath = resolveReadingPath(sourceRepoPath, sourceFilePath); final var destRepoPath = (sourceWorkspaceId == destWorkspaceId) ? sourceRepoPath : postgresRepository.workspaceRootPath(destWorkspaceId); - final var destPath = resolveSubPath(destRepoPath, destFilePath); + final var destPath = resolveWritingPath(destRepoPath, destFilePath); try { // Do not copy the file if the source file does not exist if(!sourcePath.toFile().exists()) throw new WorkspaceFileOpException("Source file \"%s\" in workspace %d does not exist.".formatted(sourceFilePath, sourceWorkspaceId)); - // Do not copy the file if the destination already exists - if(destPath.toFile().exists()) throw new WorkspaceFileOpException("Destination file \"%s\" in workspace %d already exists.".formatted(destFilePath, destWorkspaceId)); - // Create any parent directories that don't already exist Files.createDirectories(destPath.getParent()); // Copy the main file - Files.copy(sourcePath, destPath); + Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING); // Find and copy hidden metadata files final var metadataExtensions = postgresRepository.getMetadataExtensions(); @@ -229,17 +302,14 @@ public boolean copyDirectory(final int sourceWorkspaceId, final Path sourceFileP throws NoSuchWorkspaceException, WorkspaceFileOpException { final var sourceRepoPath = postgresRepository.workspaceRootPath(sourceWorkspaceId); - final var sourcePath = resolveSubPath(sourceRepoPath, sourceFilePath); + final var sourcePath = resolveReadingPath(sourceRepoPath, sourceFilePath); final var destRepoPath = (sourceWorkspaceId == destWorkspaceId) ? sourceRepoPath : postgresRepository.workspaceRootPath(destWorkspaceId); - final var destPath = resolveSubPath(destRepoPath, destFilePath); + final var destPath = resolveWritingPath(destRepoPath, destFilePath); try { // Validate source exists and is a directory if (!Files.exists(sourcePath)) throw new WorkspaceFileOpException("Source directory \"%s\" in workspace %d does not exist.".formatted(sourceFilePath, sourceWorkspaceId)); - if (!Files.isDirectory(sourcePath)) throw new WorkspaceFileOpException("Source directory \"%s\" in workspace %d is not actually a directory.".formatted(sourceFilePath, sourceWorkspaceId)); - - // Do not copy if destination already exists - if (Files.exists(destPath)) throw new WorkspaceFileOpException("Destination directory \"%s\" in workspace %d already exists.".formatted(destFilePath, destWorkspaceId)); + if (!Files.isDirectory(sourcePath)) throw new WorkspaceFileOpException("Source directory \"%s\" in workspace %d is not a directory.".formatted(sourceFilePath, sourceWorkspaceId)); // Do not try to copy a directory into itself if(sourceWorkspaceId == destWorkspaceId && destPath.startsWith(sourcePath)){ @@ -256,7 +326,7 @@ public boolean copyDirectory(final int sourceWorkspaceId, final Path sourceFileP if (Files.isDirectory(source)) { Files.createDirectories(target); } else { - Files.copy(source, target); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); } } catch (IOException e) { throw new UncheckedIOException(e); @@ -273,18 +343,30 @@ public boolean copyDirectory(final int sourceWorkspaceId, final Path sourceFileP @Override - public boolean deleteFile(final int workspaceId, final Path filePath) throws NoSuchWorkspaceException { + public boolean deleteFile(final int workspaceId, final Path filePath) throws NoSuchWorkspaceException, SQLException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, filePath); + final var path = resolveReadingPath(repoPath, filePath); final var file = path.toFile(); - return file.delete(); + + boolean success = true; + + // Find hidden metadata files, if they exist, and delete them + final var metadataExtensions = postgresRepository.getMetadataExtensions(); + for(final var extension : metadataExtensions) { + final File oldFile = path.resolveSibling(path.getFileName() + extension).toFile(); + if(oldFile.exists()) { + success = rm(file) && success; // Do not fast-fail + } + } + + return rm(file) && success; } @Override public DirectoryTree listFiles(final int workspaceId, final Optional directoryPath, final int depth) throws SQLException, NoSuchWorkspaceException, IOException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, directoryPath.orElse(Path.of(""))); + final var path = resolveReadingPath(repoPath, directoryPath.orElse(Path.of(""))); if(!path.toFile().isDirectory()) { return null; @@ -300,9 +382,10 @@ public DirectoryTree listFiles(final int workspaceId, final Optional direc } @Override - public boolean createDirectory(final int workspaceId, final Path directoryPath) throws IOException, NoSuchWorkspaceException { + public boolean createDirectory(final int workspaceId, final Path directoryPath) + throws IOException, NoSuchWorkspaceException, WorkspaceFileOpException { final var repoPath = postgresRepository.workspaceRootPath(workspaceId); - final var path = resolveSubPath(repoPath, directoryPath); + final var path = resolveWritingPath(repoPath, directoryPath); Files.createDirectories(path); return true; } @@ -313,9 +396,9 @@ public boolean moveDirectory(final int oldWorkspaceId, final Path oldDirectoryPa throws NoSuchWorkspaceException, IOException, WorkspaceFileOpException { final var oldRepoPath = postgresRepository.workspaceRootPath(oldWorkspaceId).normalize(); - final var oldPath = resolveSubPath(oldRepoPath, oldDirectoryPath); + final var oldPath = resolveReadingPath(oldRepoPath, oldDirectoryPath); final var newRepoPath = (oldWorkspaceId == newWorkspaceId) ? oldRepoPath : postgresRepository.workspaceRootPath(newWorkspaceId).normalize(); - final var newPath = resolveSubPath(newRepoPath, newDirectoryPath); + final var newPath = resolveWritingPath(newRepoPath, newDirectoryPath); // Do not permit the source workspace's root directory to be moved if(Files.isSameFile(oldPath, oldRepoPath)) throw new WorkspaceFileOpException("Cannot move the workspace root directory."); @@ -334,7 +417,11 @@ public boolean moveDirectory(final int oldWorkspaceId, final Path oldDirectoryPa } @Override - public boolean deleteDirectory(final int workspaceId, final Path directoryPath) throws NoSuchWorkspaceException { - return deleteFile(workspaceId, directoryPath); + public boolean deleteDirectory(final int workspaceId, final Path directoryPath) + throws NoSuchWorkspaceException + { + final var repoPath = postgresRepository.workspaceRootPath(workspaceId); + final var path = resolveReadingPath(repoPath, directoryPath); + return rmDirectory(path.toFile()); } } diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceService.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceService.java index f4976a7539..f868e5f5f5 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceService.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceService.java @@ -46,7 +46,7 @@ record FileStream(InputStream readingStream, String fileName, long fileSize){} * @return true if the file was saved, false otherwise */ boolean saveFile(final int workspaceId, final Path filePath, final UploadedFile file) - throws IOException, NoSuchWorkspaceException; + throws IOException, NoSuchWorkspaceException, WorkspaceFileOpException; /** * Copy a file within a workspace or between workspaces. @@ -78,12 +78,14 @@ boolean moveFile(final int oldWorkspaceId, final Path oldFilePath, final int new * @param filePath the path, relative to the workspace's root, to the file to be deleted * @return true if the file was deleted, false otherwise */ - boolean deleteFile(final int workspaceId, final Path filePath) throws IOException, NoSuchWorkspaceException; + boolean deleteFile(final int workspaceId, final Path filePath) + throws IOException, NoSuchWorkspaceException, SQLException; DirectoryTree listFiles(final int workspaceId, final Optional directoryPath, final int depth) throws SQLException, NoSuchWorkspaceException, IOException; - boolean createDirectory(final int workspaceId, final Path directoryPath) throws IOException, NoSuchWorkspaceException; + boolean createDirectory(final int workspaceId, final Path directoryPath) + throws IOException, NoSuchWorkspaceException, WorkspaceFileOpException; /** * Move a directory within a workspace or between workspaces. * @param oldWorkspaceId the id of the source workspace diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/BulkPostItem.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/BulkPostItem.java new file mode 100644 index 0000000000..596806be5b --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/BulkPostItem.java @@ -0,0 +1,12 @@ +package gov.nasa.jpl.aerie.workspace.server.types; + +import javax.json.JsonObject; +import java.nio.file.Path; + +public record BulkPostItem(Path currentLocation, Path newPath) { + public static BulkPostItem fromJson(JsonObject item, Path destinationPath) { + final Path curLoc = Path.of(item.getString("path")); + final String newName = item.getString("renameTo", curLoc.getFileName().toString()); + return new BulkPostItem(curLoc, destinationPath.resolve(newName)); + } +} diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/BulkPutItem.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/BulkPutItem.java new file mode 100644 index 0000000000..54aba8b5ac --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/BulkPutItem.java @@ -0,0 +1,46 @@ +package gov.nasa.jpl.aerie.workspace.server.types; + +import javax.json.JsonException; +import javax.json.JsonObject; +import java.nio.file.Path; +import java.util.Optional; + +public record BulkPutItem( + Path path, + ItemType uploadType, + Optional inputFileName, + boolean overwrite +) { + + public static BulkPutItem fromJson(JsonObject item) throws JsonException { + final Path path; + final ItemType type; + final Optional inputFileName; + final boolean overwrite; + + if(!item.containsKey("path")) { + throw new JsonException("Missing required parameter for input object: path"); + } + path = Path.of(item.getString("path")); + + if(!item.containsKey("type")) { + throw new JsonException("Missing required parameter for input object: type"); + } + type = ItemType.of(item.getString("type")); + + if(type == ItemType.directory) { + if(item.containsKey("input_file_name")) { + throw new JsonException("Unsupported key 'input_file_name' provided in 'directory'-type upload"); + } + if(item.containsKey("overwrite")) { + throw new JsonException("Unsupported key 'overwrite' provided in 'directory'-type upload"); + } + return new BulkPutItem(path, type, Optional.empty(), false); + } + + inputFileName = Optional.ofNullable(item.getString("input_file_name", null)); + overwrite = item.getBoolean("overwrite", false); + + return new BulkPutItem(path, type, inputFileName, overwrite); + } +} diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/ItemType.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/ItemType.java new file mode 100644 index 0000000000..a1cab9a71f --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/ItemType.java @@ -0,0 +1,15 @@ +package gov.nasa.jpl.aerie.workspace.server.types; + +public enum ItemType { + file, directory; + + public static ItemType of(String type) { + return switch (type) { + case "file" -> file; + case "directory", "folder" -> directory; + case null -> throw new IllegalArgumentException("'type' must be provided and be one of 'file', 'folder', or 'directory'."); + default -> throw new IllegalArgumentException("Invalid type provided: " + type + + ". 'type' must be one of 'file', 'folder', or 'directory'"); + }; + } +} diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/PostActions.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/PostActions.java new file mode 100644 index 0000000000..e9844d847f --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/PostActions.java @@ -0,0 +1,6 @@ +package gov.nasa.jpl.aerie.workspace.server.types; + + +public enum PostActions { + MOVE, COPY +} diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/PostBody.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/PostBody.java new file mode 100644 index 0000000000..0069f0f837 --- /dev/null +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/types/PostBody.java @@ -0,0 +1,37 @@ +package gov.nasa.jpl.aerie.workspace.server.types; + +import javax.json.JsonException; +import javax.json.JsonObject; +import java.nio.file.Path; + +public record PostBody( + int sourceWorkspaceId, + int destinationWorkspaceId, + Path destinationPath, + PostActions action, + boolean overwrite) { + + public static PostBody fromJson(JsonObject body, int sourceWorkspaceId) throws JsonException { + final int destinationWorkspaceId; + final Path destinationPath; + final PostActions action; + final boolean overwrite; + + if (body.containsKey("moveTo") && body.containsKey("copyTo")) { + throw new JsonException("Too many actions specified for a single request."); + } else if (body.containsKey("moveTo")) { + action = PostActions.MOVE; + destinationPath = Path.of(body.getString("moveTo")); + } else if (body.containsKey("copyTo")) { + action = PostActions.COPY; + destinationPath = Path.of(body.getString("copyTo")); + } else { + throw new JsonException("No action supplied for request."); + } + + destinationWorkspaceId = body.getInt("toWorkspace", sourceWorkspaceId); + overwrite = body.getBoolean("overwrite", false); + + return new PostBody(sourceWorkspaceId, destinationWorkspaceId, destinationPath, action, overwrite); + } +} diff --git a/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemServiceTest.java b/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemServiceTest.java index a2b44c9ab2..5913dd6042 100644 --- a/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemServiceTest.java +++ b/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceFileSystemServiceTest.java @@ -3,6 +3,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; import java.nio.file.Path; @@ -25,36 +27,132 @@ void validPaths() { var root = Path.of("/workspace/123"); assertEquals(Path.of("/workspace/123"), - service.resolveSubPath(root, Path.of(""))); + service.resolveReadingPath(root, Path.of(""))); assertEquals(Path.of("/workspace/123"), - service.resolveSubPath(root, Path.of("."))); + service.resolveReadingPath(root, Path.of("."))); assertEquals(Path.of("/workspace/123/file.txt"), - service.resolveSubPath(root, Path.of("file.txt"))); + service.resolveReadingPath(root, Path.of("file.txt"))); assertEquals(Path.of("/workspace/123/folder/subfolder/file.txt"), - service.resolveSubPath(root, Path.of("folder/subfolder/file.txt"))); + service.resolveReadingPath(root, Path.of("folder/subfolder/file.txt"))); assertEquals(Path.of("/workspace/123/my/dir"), - service.resolveSubPath(root, Path.of("my/dir"))); + service.resolveReadingPath(root, Path.of("my/dir"))); // ".." in path is technically allowed as long as it resolves inside root assertEquals(Path.of("/workspace/123/my/file.txt"), - service.resolveSubPath(root, Path.of("my/dir/../file.txt"))); + service.resolveReadingPath(root, Path.of("my/dir/../file.txt"))); } @Test void absolutePathThrowsSecurityException() { // disallow resolving absolute subpath assertThrows(SecurityException.class, () -> - service.resolveSubPath(Path.of("/workspace/123"), Path.of("/etc/passwd"))); + service.resolveReadingPath(Path.of("/workspace/123"), Path.of("/etc/passwd"))); } @Test void pathTraversalThrowsSecurityException() { // disallow resolving subpaths outside of root assertThrows(SecurityException.class, () -> - service.resolveSubPath(Path.of("/workspace/123"), Path.of("../../../etc/passwd"))); + service.resolveReadingPath(Path.of("/workspace/123"), Path.of("../../../etc/passwd"))); assertThrows(SecurityException.class, () -> - service.resolveSubPath(Path.of("/workspace/123"), Path.of("folder/../..//../etc/passwd"))); + service.resolveReadingPath(Path.of("/workspace/123"), Path.of("folder/../..//../etc/passwd"))); assertThrows(SecurityException.class, () -> - service.resolveSubPath(Path.of("/workspace/123"), Path.of("folder/../../workspace/123/../456/file.txt"))); + service.resolveReadingPath(Path.of("/workspace/123"), Path.of("folder/../../workspace/123/../456/file.txt"))); + } + } + + @Nested + class ValidatePathTests { + final static String[] reservedFileNames = { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + final static String[] forbiddenChars = { + // Normal forbidden characters + "<", ">", ":", "\"", "\\", "|", "?", "*", "%", "#", + // Unicode/ASCII 0-31 control characters + // "\u0000" is not tested as it isn't permitted in a Path, meaning it cannot be passed to validatePath + "\u0001", "\u0002", "\u0003", "\u0004", "\u0005", "\u0006", "\u0007", "\u0008", "\t", + "\n", "\u000b", "\u000c", "\r", "\u000e", "\u000f", + "\u0010", "\u0011", "\u0012", "\u0013", "\u0014", "\u0015", "\u0016", "\u0017", "\u0018", "\u0019", + "\u001A", "\u001b", "\u001c", "\u001d", "\u001e", "\u001f", + // Unicode 127-159 control characters + "\u007F", "\u0080", "\u0081", "\u0082", "\u0083", "\u0084", "\u0085", "\u0086", "\u0087", "\u0088", "\u0089", + "\u008A", "\u008B", "\u008C", "\u008D", "\u008E", "\u008F", + "\u0090", "\u0091", "\u0092", "\u0093", "\u0094", "\u0095", "\u0096", "\u0097", "\u0098", "\u0099", + "\u009A", "\u009B", "\u009C", "\u009D", "\u009E", "\u009F", + }; + final static String[] trailingChars = {"foo ", "foo.", " ", "."}; + + @ParameterizedTest + @FieldSource("forbiddenChars") + void validatePathForbiddenChars(String forbidden) { + // These characters are not allowed on their own + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden))); + // These characters are not allowed as part of a longer file name + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("foobar" + forbidden))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("file"+forbidden+".txt"))); + // There characters are not allowed as the last part of the path + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder", forbidden))); + // There characters are not allowed as part of a folder name + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden, "file"))); + } + + @ParameterizedTest + @FieldSource("trailingChars") + void validatePathTrailingChars(String forbidden) { + // Trailing characters are not permitted at the end of a file + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("foobar" + forbidden))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder", forbidden))); + + // Trailing characters are permitted before an extension (as the extension makes them non-trailing) + assertDoesNotThrow(() -> service.validatePath(Path.of("file"+forbidden+".txt"))); + + // Trailing characters are not permitted in folder names + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(forbidden, "file"))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder"+forbidden, "file"))); + } + + @ParameterizedTest + @FieldSource("reservedFileNames") + void validatePathReservedFilenames(String reserved) { + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(reserved))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(reserved+".txt"))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder/"+reserved))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of("folder/"+reserved+".txt"))); + assertThrows(WorkspaceFileOpException.class, () -> service.validatePath(Path.of(reserved, "file.txt"))); + + // They are allowed to be PART of the file name + assertDoesNotThrow(() -> service.validatePath(Path.of("myFile_"+reserved))); + assertDoesNotThrow(() -> service.validatePath(Path.of("myFile_"+reserved+".txt"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("myFolder_"+reserved, "myFile.txt"))); + } + + /** + * Forward slashes are considered a path delineator and will not throw + */ + @Test + void forwardSlash() { + assertDoesNotThrow(() -> service.validatePath(Path.of("/"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("foobar/"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("file/.txt"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("folder", "/"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("folder/"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("/", "file.txt"))); + } + + /** + * Spaces and periods are allowed in the path so long as they are not trailing + */ + @Test + void validatePathPermitsSpacePeriod() { + assertDoesNotThrow(() -> service.validatePath(Path.of("my file"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("my folder","my file"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("my folder", " my file"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("my.file"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("my.folder","my.file"))); + assertDoesNotThrow(() -> service.validatePath(Path.of("my.folder",".my.file"))); } } }