From b2cb060c02ec4c5424d1c9ed58ee913675505e16 Mon Sep 17 00:00:00 2001 From: jesselanger2 Date: Tue, 19 May 2026 23:27:37 -0400 Subject: [PATCH] Add support for processing .env files with create, update, and delete operations --- docs/api.md | 30 ++++ .../controller/SecretController.java | 34 +++- .../dto/secret/EnvFileRequest.java | 17 ++ .../service/secret/EnvFileService.java | 168 ++++++++++++++++++ .../controller/SecretControllerTest.java | 46 +++++ .../service/secret/EnvFileServiceTest.java | 126 +++++++++++++ 6 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/main/java/edu/yu/capstone/DistributedSecretsVault/dto/secret/EnvFileRequest.java create mode 100644 src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java create mode 100644 src/test/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileServiceTest.java diff --git a/docs/api.md b/docs/api.md index 0d9eae8..c5ce581 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,3 +110,33 @@ Broadcasts a delete command to permanently remove all shards of a secret from th **Responses:** - `204 No Content`: The secret shards were successfully deleted from at least `m-k+1` nodes. - `404 Not Found`: The secret does not exist. + +--- + +### 6. Process a `.env` File +Processes a client-supplied `.env` file containing create, update, and delete operations. + +**POST** `/api/v1/secrets/env` + +Each non-empty line must use: + +```env +Key1=new:val +Key2=update:val +Key3=delete:val +``` + +The action must be `new`, `update`, or `delete`. Keys must be unique within the file; duplicate keys fail the request before any write is attempted. + +Supported request formats: + +- `text/plain` body with `user` as a query parameter. +- `multipart/form-data` with `user` and a `file` part. +- `application/json` with `user` and `envFileContent` fields. The content field also accepts aliases `content`, `env`, or `envFile`. + +**Responses:** +- `200 OK`: The file was processed. Returns operation counts. +- `400 Bad Request`: The file is malformed, has duplicate keys, or is missing a user. +- `404 Not Found`: An `update` or `delete` key does not exist. +- `409 Conflict`: A `new` key already exists. +- `503 Service Unavailable`: Failed to reach quorum during one of the write phases. diff --git a/src/main/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretController.java b/src/main/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretController.java index 36496cd..3746d96 100644 --- a/src/main/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretController.java +++ b/src/main/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretController.java @@ -4,13 +4,16 @@ import org.springframework.web.bind.annotation.RestController; import edu.yu.capstone.DistributedSecretsVault.dto.secret.DeleteSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.EnvFileRequest; import edu.yu.capstone.DistributedSecretsVault.dto.secret.PostSecretRequest; import edu.yu.capstone.DistributedSecretsVault.dto.secret.PutSecretRequest; import edu.yu.capstone.DistributedSecretsVault.service.secret.DeleteSecretService; +import edu.yu.capstone.DistributedSecretsVault.service.secret.EnvFileService; import edu.yu.capstone.DistributedSecretsVault.service.secret.GetSecretService; import edu.yu.capstone.DistributedSecretsVault.service.secret.PostSecretService; import edu.yu.capstone.DistributedSecretsVault.service.secret.PutSecretService; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -19,7 +22,10 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Map; @RestController @@ -30,13 +36,16 @@ public class SecretController { private final PostSecretService postSecretService; private final PutSecretService putSecretService; private final DeleteSecretService deleteSecretService; + private final EnvFileService envFileService; public SecretController(GetSecretService getSecretService, PostSecretService postSecretService, - PutSecretService putSecretService, DeleteSecretService deleteSecretService) { + PutSecretService putSecretService, DeleteSecretService deleteSecretService, + EnvFileService envFileService) { this.getSecretService = getSecretService; this.postSecretService = postSecretService; this.putSecretService = putSecretService; this.deleteSecretService = deleteSecretService; + this.envFileService = envFileService; } @GetMapping("/{id}") @@ -55,6 +64,29 @@ public ResponseEntity postSecret(@RequestBody PostSecretRequest request) return postSecretService.execute(request); } + @PostMapping(value = "/env", consumes = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity postEnvFile(@RequestParam("user") String user, @RequestBody String envFileContent) { + return envFileService.execute(user, envFileContent); + } + + @PostMapping(value = "/env", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity postEnvFile(@RequestParam("user") String user, + @RequestParam("file") MultipartFile file) { + try { + return envFileService.execute(user, new String(file.getBytes(), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to read .env file", e); + } + } + + @PostMapping(value = "/env", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity postEnvFile(@RequestBody EnvFileRequest request) { + if (request == null) { + throw new IllegalArgumentException("Request is required"); + } + return envFileService.execute(request.getUser(), request.getEnvFileContent()); + } + @PutMapping public ResponseEntity updateSecret(@RequestBody PutSecretRequest request) { return putSecretService.execute(request); diff --git a/src/main/java/edu/yu/capstone/DistributedSecretsVault/dto/secret/EnvFileRequest.java b/src/main/java/edu/yu/capstone/DistributedSecretsVault/dto/secret/EnvFileRequest.java new file mode 100644 index 0000000..96a3c88 --- /dev/null +++ b/src/main/java/edu/yu/capstone/DistributedSecretsVault/dto/secret/EnvFileRequest.java @@ -0,0 +1,17 @@ +package edu.yu.capstone.DistributedSecretsVault.dto.secret; + +import com.fasterxml.jackson.annotation.JsonAlias; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EnvFileRequest { + private String user; + + @JsonAlias({ "content", "env", "envFile" }) + private String envFileContent; +} diff --git a/src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java b/src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java new file mode 100644 index 0000000..c2a2b16 --- /dev/null +++ b/src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java @@ -0,0 +1,168 @@ +package edu.yu.capstone.DistributedSecretsVault.service.secret; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import edu.yu.capstone.DistributedSecretsVault.domain.model.SecretKey; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.DeleteSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.PostSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.PutSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.exceptions.DuplicateSecretException; +import edu.yu.capstone.DistributedSecretsVault.exceptions.SecretNotFoundException; +import edu.yu.capstone.DistributedSecretsVault.repository.SecretPartRepository; + +@Service +public class EnvFileService { + private static final Logger log = LoggerFactory.getLogger(EnvFileService.class); + + private final PostSecretService postSecretService; + private final PutSecretService putSecretService; + private final DeleteSecretService deleteSecretService; + private final SecretPartRepository secretPartRepository; + + public EnvFileService(PostSecretService postSecretService, + PutSecretService putSecretService, + DeleteSecretService deleteSecretService, + SecretPartRepository secretPartRepository) { + this.postSecretService = postSecretService; + this.putSecretService = putSecretService; + this.deleteSecretService = deleteSecretService; + this.secretPartRepository = secretPartRepository; + } + + public ResponseEntity execute(String user, String envFileContent) { + validateUser(user); + List operations = parseOperations(envFileContent); + validateOperationPreconditions(user, operations); + + int created = 0; + int updated = 0; + int deleted = 0; + for (EnvSecretOperation operation : operations) { + switch (operation.action()) { + case NEW -> { + postSecretService.execute(new PostSecretRequest(operation.key(), operation.value(), user)); + created++; + } + case UPDATE -> { + putSecretService.execute(new PutSecretRequest(operation.key(), operation.value(), user)); + updated++; + } + case DELETE -> { + deleteSecretService.execute(new DeleteSecretRequest(operation.key(), user)); + deleted++; + } + } + } + + log.info("Processed .env file for user={}: created={}, updated={}, deleted={}", + user, created, updated, deleted); + return ResponseEntity.ok("Processed .env file: " + created + " created, " + + updated + " updated, " + deleted + " deleted"); + } + + private void validateUser(String user) { + if (user == null || user.isBlank()) { + throw new IllegalArgumentException("User is required"); + } + } + + private List parseOperations(String envFileContent) { + if (envFileContent == null || envFileContent.isBlank()) { + throw new IllegalArgumentException(".env file content is required"); + } + + List operations = new ArrayList<>(); + Set seenKeys = new HashSet<>(); + String[] lines = envFileContent.split("\\R", -1); + for (int index = 0; index < lines.length; index++) { + String rawLine = lines[index]; + String trimmedLine = rawLine.trim(); + if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) { + continue; + } + + EnvSecretOperation operation = parseOperationLine(trimmedLine, index + 1); + if (!seenKeys.add(operation.key())) { + throw new IllegalArgumentException("Duplicate key in .env file: " + operation.key()); + } + operations.add(operation); + } + + if (operations.isEmpty()) { + throw new IllegalArgumentException(".env file must include at least one operation"); + } + return operations; + } + + private EnvSecretOperation parseOperationLine(String line, int lineNumber) { + int equalsIndex = line.indexOf('='); + if (equalsIndex <= 0) { + throw invalidLine(lineNumber); + } + + String key = line.substring(0, equalsIndex).trim(); + if (key.isBlank()) { + throw invalidLine(lineNumber); + } + + String actionAndValue = line.substring(equalsIndex + 1); + int colonIndex = actionAndValue.indexOf(':'); + if (colonIndex < 0) { + throw invalidLine(lineNumber); + } + + String actionText = actionAndValue.substring(0, colonIndex).trim().toLowerCase(Locale.ROOT); + EnvAction action = switch (actionText) { + case "new" -> EnvAction.NEW; + case "update" -> EnvAction.UPDATE; + case "delete" -> EnvAction.DELETE; + default -> throw new IllegalArgumentException("Invalid .env action on line " + lineNumber + + ": expected new, update, or delete"); + }; + + String value = actionAndValue.substring(colonIndex + 1); + return new EnvSecretOperation(key, action, value); + } + + private IllegalArgumentException invalidLine(int lineNumber) { + return new IllegalArgumentException("Invalid .env entry on line " + lineNumber + + ": expected KEY=action:value"); + } + + private void validateOperationPreconditions(String user, List operations) { + for (EnvSecretOperation operation : operations) { + SecretKey key = new SecretKey(user, operation.key()); + boolean exists = secretPartRepository.exists(key); + switch (operation.action()) { + case NEW -> { + if (exists) { + throw new DuplicateSecretException("Secret already exists: " + operation.key()); + } + } + case UPDATE, DELETE -> { + if (!exists) { + throw new SecretNotFoundException("Secret not found: " + operation.key()); + } + } + } + } + } + + private enum EnvAction { + NEW, + UPDATE, + DELETE + } + + private record EnvSecretOperation(String key, EnvAction action, String value) { + } +} diff --git a/src/test/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretControllerTest.java b/src/test/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretControllerTest.java index 1e04b65..6df70fb 100644 --- a/src/test/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretControllerTest.java +++ b/src/test/java/edu/yu/capstone/DistributedSecretsVault/controller/SecretControllerTest.java @@ -9,10 +9,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.http.ResponseEntity; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import java.nio.charset.StandardCharsets; import java.util.Map; import static org.mockito.ArgumentMatchers.any; @@ -40,6 +42,9 @@ public class SecretControllerTest { @MockitoBean private DeleteSecretService deleteSecretService; + @MockitoBean + private EnvFileService envFileService; + @Test public void testGetSecret() throws Exception { when(getSecretService.getVersion(eq("user1"), eq("sec1"), eq(1L))) @@ -87,6 +92,47 @@ public void testPostSecret() throws Exception { .andExpect(content().string("created-id")); } + @Test + public void testPostEnvFileAsPlainText() throws Exception { + String envFile = "Key1=new:val\nKey2=update:other\nKey3=delete:ignored\n"; + when(envFileService.execute(eq("user1"), eq(envFile))) + .thenReturn(ResponseEntity.ok("processed")); + + mockMvc.perform(post("/api/v1/secrets/env") + .param("user", "user1") + .contentType(MediaType.TEXT_PLAIN) + .content(envFile)) + .andExpect(status().isOk()) + .andExpect(content().string("processed")); + } + + @Test + public void testPostEnvFileAsMultipart() throws Exception { + String envFile = "Key1=new:val\n"; + MockMultipartFile file = new MockMultipartFile("file", "secrets.env", + MediaType.TEXT_PLAIN_VALUE, envFile.getBytes(StandardCharsets.UTF_8)); + when(envFileService.execute(eq("user1"), eq(envFile))) + .thenReturn(ResponseEntity.ok("processed")); + + mockMvc.perform(multipart("/api/v1/secrets/env") + .file(file) + .param("user", "user1")) + .andExpect(status().isOk()) + .andExpect(content().string("processed")); + } + + @Test + public void testPostEnvFileAsJson() throws Exception { + when(envFileService.execute(eq("user1"), eq("Key1=new:val"))) + .thenReturn(ResponseEntity.ok("processed")); + + mockMvc.perform(post("/api/v1/secrets/env") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"user\":\"user1\",\"content\":\"Key1=new:val\"}")) + .andExpect(status().isOk()) + .andExpect(content().string("processed")); + } + @Test public void testPutSecret() throws Exception { when(putSecretService.execute(any(PutSecretRequest.class))) diff --git a/src/test/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileServiceTest.java b/src/test/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileServiceTest.java new file mode 100644 index 0000000..c95cc7e --- /dev/null +++ b/src/test/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileServiceTest.java @@ -0,0 +1,126 @@ +package edu.yu.capstone.DistributedSecretsVault.service.secret; + +import edu.yu.capstone.DistributedSecretsVault.domain.model.SecretKey; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.DeleteSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.PostSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.dto.secret.PutSecretRequest; +import edu.yu.capstone.DistributedSecretsVault.exceptions.DuplicateSecretException; +import edu.yu.capstone.DistributedSecretsVault.exceptions.SecretNotFoundException; +import edu.yu.capstone.DistributedSecretsVault.repository.SecretPartRepository; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@Tag("unit") +public class EnvFileServiceTest { + + @Mock + private PostSecretService postSecretService; + + @Mock + private PutSecretService putSecretService; + + @Mock + private DeleteSecretService deleteSecretService; + + @Mock + private SecretPartRepository secretPartRepository; + + @InjectMocks + private EnvFileService envFileService; + + @Test + void testExecuteProcessesEnvFileOperations() { + when(secretPartRepository.exists(new SecretKey("user1", "Key1"))).thenReturn(false); + when(secretPartRepository.exists(new SecretKey("user1", "Key2"))).thenReturn(true); + when(secretPartRepository.exists(new SecretKey("user1", "Key3"))).thenReturn(true); + + ResponseEntity response = envFileService.execute("user1", """ + Key1=new:val + Key2=update:next:with:colons + Key3=delete:ignored + """); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Processed .env file: 1 created, 1 updated, 1 deleted", response.getBody()); + + ArgumentCaptor postCaptor = ArgumentCaptor.forClass(PostSecretRequest.class); + verify(postSecretService).execute(postCaptor.capture()); + assertEquals("Key1", postCaptor.getValue().getSecretName()); + assertEquals("val", postCaptor.getValue().getSecretValue()); + assertEquals("user1", postCaptor.getValue().getUser()); + + ArgumentCaptor putCaptor = ArgumentCaptor.forClass(PutSecretRequest.class); + verify(putSecretService).execute(putCaptor.capture()); + assertEquals("Key2", putCaptor.getValue().getSecretCurrentName()); + assertEquals("next:with:colons", putCaptor.getValue().getSecretUpdatedValue()); + assertEquals("user1", putCaptor.getValue().getUser()); + + ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(DeleteSecretRequest.class); + verify(deleteSecretService).execute(deleteCaptor.capture()); + assertEquals("Key3", deleteCaptor.getValue().getDeleteName()); + assertEquals("user1", deleteCaptor.getValue().getUser()); + } + + @Test + void testExecuteRejectsDuplicateKeysBeforeWrites() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> envFileService.execute("user1", """ + Key1=new:val + Key1=update:other + """)); + + assertEquals("Duplicate key in .env file: Key1", exception.getMessage()); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService); + } + + @Test + void testExecuteRejectsInvalidAction() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> envFileService.execute("user1", "Key1=replace:val")); + + assertEquals("Invalid .env action on line 1: expected new, update, or delete", exception.getMessage()); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService); + } + + @Test + void testExecuteRejectsExistingSecretForNewOperation() { + when(secretPartRepository.exists(new SecretKey("user1", "Key1"))).thenReturn(true); + + DuplicateSecretException exception = assertThrows(DuplicateSecretException.class, + () -> envFileService.execute("user1", "Key1=new:val")); + + assertEquals("Secret already exists: Key1", exception.getMessage()); + verifyNoInteractions(postSecretService, putSecretService, deleteSecretService); + } + + @Test + void testExecuteRejectsMissingSecretForUpdateOperation() { + when(secretPartRepository.exists(new SecretKey("user1", "Key1"))).thenReturn(false); + + SecretNotFoundException exception = assertThrows(SecretNotFoundException.class, + () -> envFileService.execute("user1", "Key1=update:val")); + + assertEquals("Secret not found: Key1", exception.getMessage()); + verifyNoInteractions(postSecretService, putSecretService, deleteSecretService); + } + + @Test + void testExecuteRequiresUser() { + assertThrows(IllegalArgumentException.class, () -> envFileService.execute(" ", "Key1=new:val")); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService); + } +}