From bd267c7a3da8f0ecfc8a95d1b05a0d805e9abc97 Mon Sep 17 00:00:00 2001 From: jesselanger2 Date: Wed, 20 May 2026 16:51:27 -0400 Subject: [PATCH] Add GET functionality to .env and return values --- docs/api.md | 19 +++-- .../service/secret/EnvFileService.java | 66 +++++++++++++---- .../service/secret/EnvFileServiceTest.java | 72 ++++++++++++++++--- 3 files changed, 131 insertions(+), 26 deletions(-) diff --git a/docs/api.md b/docs/api.md index fd5566f..b3e6712 100644 --- a/docs/api.md +++ b/docs/api.md @@ -114,7 +114,7 @@ Broadcasts a delete command to permanently remove all shards of a secret from th --- ### 6. Process a `.env` File -Processes a client-supplied `.env` file containing create, update, and delete operations. +Processes a client-supplied `.env` file containing create, update, retrieve, and delete operations. **POST** `/api/v1/secrets/env` @@ -123,11 +123,18 @@ Each non-empty line must use: ```env Key1=new:val Key2=update:val -Key3=delete +Key3=get +Key4=get:2 +Key5=delete ``` -The action must be `new`, `update`, or `delete`. `new` and `update` require a value after `:`; -`delete` only needs the key name and action. Keys must be unique within the file; duplicate keys fail the request before any write is attempted. +The action must be `new`, `update`, `get`, or `delete`. `new` and `update` require a value after `:`. +`get` retrieves the latest value; `get:version` retrieves a specific version. Retrieving all versions is not supported +through `.env` files. `delete` only needs the key name and action. Keys must be unique within the file; duplicate keys +fail the request before any write is attempted. + +Successful responses return one `KEY=value` line for each `new`, `update`, and `get` operation, in request order. +`delete` operations return no line. Supported request formats: @@ -136,8 +143,8 @@ Supported request formats: - `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. +- `200 OK`: The file was processed. Returns resolved `KEY=value` lines. - `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. +- `404 Not Found`: An `update`, `get`, 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/service/secret/EnvFileService.java b/src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java index 8934ac7..bcc6e15 100644 --- a/src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java +++ b/src/main/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileService.java @@ -1,9 +1,11 @@ package edu.yu.capstone.DistributedSecretsVault.service.secret; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import org.slf4j.Logger; @@ -26,36 +28,47 @@ public class EnvFileService { private final PostSecretService postSecretService; private final PutSecretService putSecretService; private final DeleteSecretService deleteSecretService; + private final GetSecretService getSecretService; private final SecretPartRepository secretPartRepository; public EnvFileService(PostSecretService postSecretService, PutSecretService putSecretService, DeleteSecretService deleteSecretService, + GetSecretService getSecretService, SecretPartRepository secretPartRepository) { this.postSecretService = postSecretService; this.putSecretService = putSecretService; this.deleteSecretService = deleteSecretService; + this.getSecretService = getSecretService; this.secretPartRepository = secretPartRepository; } public ResponseEntity execute(String user, String envFileContent) { validateUser(user); List operations = parseOperations(envFileContent); - validateOperationPreconditions(user, operations); + Map getResults = validateOperationPreconditions(user, operations); int created = 0; int updated = 0; + int retrieved = 0; int deleted = 0; + List resultLines = new ArrayList<>(); for (EnvSecretOperation operation : operations) { switch (operation.action()) { case NEW -> { postSecretService.execute(new PostSecretRequest(operation.key(), operation.value(), user)); + resultLines.add(formatResultLine(operation.key(), operation.value())); created++; } case UPDATE -> { putSecretService.execute(new PutSecretRequest(operation.key(), operation.value(), user)); + resultLines.add(formatResultLine(operation.key(), operation.value())); updated++; } + case GET -> { + resultLines.add(formatResultLine(operation.key(), getResults.get(operation.key()))); + retrieved++; + } case DELETE -> { deleteSecretService.execute(new DeleteSecretRequest(operation.key(), user)); deleted++; @@ -63,10 +76,9 @@ public ResponseEntity execute(String user, String envFileContent) { } } - 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"); + log.info("Processed .env file for user={}: created={}, updated={}, retrieved={}, deleted={}", + user, created, updated, retrieved, deleted); + return ResponseEntity.ok(String.join("\n", resultLines)); } private void validateUser(String user) { @@ -126,49 +138,79 @@ private EnvSecretOperation parseOperationLine(String line, int lineNumber) { EnvAction action = switch (actionText) { case "new" -> EnvAction.NEW; case "update" -> EnvAction.UPDATE; + case "get" -> EnvAction.GET; case "delete" -> EnvAction.DELETE; default -> throw new IllegalArgumentException("Invalid .env action on line " + lineNumber - + ": expected new, update, or delete"); + + ": expected new, update, get, or delete"); }; - if (colonIndex < 0 && action != EnvAction.DELETE) { + if (colonIndex < 0 && action != EnvAction.GET && action != EnvAction.DELETE) { throw invalidLine(lineNumber); } String value = colonIndex < 0 ? "" : actionAndValue.substring(colonIndex + 1); - return new EnvSecretOperation(key, action, value); + Long version = action == EnvAction.GET && colonIndex >= 0 + ? parseGetVersion(value, lineNumber) + : null; + return new EnvSecretOperation(key, action, value, version); + } + + private Long parseGetVersion(String value, int lineNumber) { + String trimmedValue = value.trim(); + if (trimmedValue.isEmpty()) { + throw invalidLine(lineNumber); + } + try { + long version = Long.parseLong(trimmedValue); + if (version <= 0) { + throw invalidLine(lineNumber); + } + return version; + } catch (NumberFormatException e) { + throw invalidLine(lineNumber); + } } private IllegalArgumentException invalidLine(int lineNumber) { return new IllegalArgumentException("Invalid .env entry on line " + lineNumber - + ": expected KEY=new:value, KEY=update:value, or KEY=delete"); + + ": expected KEY=new:value, KEY=update:value, KEY=get, KEY=get:version, or KEY=delete"); } - private void validateOperationPreconditions(String user, List operations) { + private Map validateOperationPreconditions(String user, List operations) { + Map getResults = new HashMap<>(); for (EnvSecretOperation operation : operations) { SecretKey key = new SecretKey(user, operation.key()); - boolean exists = secretPartRepository.exists(key); switch (operation.action()) { case NEW -> { + boolean exists = secretPartRepository.exists(key); if (exists) { throw new DuplicateSecretException("Secret already exists: " + operation.key()); } } case UPDATE, DELETE -> { + boolean exists = secretPartRepository.exists(key); if (!exists) { throw new SecretNotFoundException("Secret not found: " + operation.key()); } } + case GET -> getResults.put(operation.key(), + getSecretService.getVersion(user, operation.key(), operation.version()).getBody()); } } + return getResults; + } + + private String formatResultLine(String key, String value) { + return key + "=" + (value == null ? "" : value); } private enum EnvAction { NEW, UPDATE, + GET, DELETE } - private record EnvSecretOperation(String key, EnvAction action, String value) { + private record EnvSecretOperation(String key, EnvAction action, String value, Long version) { } } 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 index 78a3e88..7afd715 100644 --- a/src/test/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileServiceTest.java +++ b/src/test/java/edu/yu/capstone/DistributedSecretsVault/service/secret/EnvFileServiceTest.java @@ -36,6 +36,9 @@ public class EnvFileServiceTest { @Mock private DeleteSecretService deleteSecretService; + @Mock + private GetSecretService getSecretService; + @Mock private SecretPartRepository secretPartRepository; @@ -47,15 +50,17 @@ 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); + when(getSecretService.getVersion("user1", "Key4", null)).thenReturn(ResponseEntity.ok("fetched")); ResponseEntity response = envFileService.execute("user1", """ Key1=new:val Key2=update:next:with:colons + Key4=get Key3=delete """); assertEquals(HttpStatus.OK, response.getStatusCode()); - assertEquals("Processed .env file: 1 created, 1 updated, 1 deleted", response.getBody()); + assertEquals("Key1=val\nKey2=next:with:colons\nKey4=fetched", response.getBody()); ArgumentCaptor postCaptor = ArgumentCaptor.forClass(PostSecretRequest.class); verify(postSecretService).execute(postCaptor.capture()); @@ -73,6 +78,29 @@ void testExecuteProcessesEnvFileOperations() { verify(deleteSecretService).execute(deleteCaptor.capture()); assertEquals("Key3", deleteCaptor.getValue().getDeleteName()); assertEquals("user1", deleteCaptor.getValue().getUser()); + + verify(getSecretService).getVersion("user1", "Key4", null); + } + + @Test + void testExecuteSupportsSpecificVersionGet() { + when(getSecretService.getVersion("user1", "Key1", 2L)).thenReturn(ResponseEntity.ok("version-two")); + + ResponseEntity response = envFileService.execute("user1", "Key1=get:2"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Key1=version-two", response.getBody()); + verify(getSecretService).getVersion("user1", "Key1", 2L); + } + + @Test + void testExecuteReturnsEmptyBodyForDeleteOnlyFile() { + when(secretPartRepository.exists(new SecretKey("user1", "Key1"))).thenReturn(true); + + ResponseEntity response = envFileService.execute("user1", "Key1=delete"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("", response.getBody()); } @Test @@ -84,7 +112,8 @@ void testExecuteRejectsDuplicateKeysBeforeWrites() { """)); assertEquals("Duplicate key in .env file: Key1", exception.getMessage()); - verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService, + getSecretService); } @Test @@ -92,8 +121,9 @@ 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); + assertEquals("Invalid .env action on line 1: expected new, update, get, or delete", exception.getMessage()); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService, + getSecretService); } @Test @@ -101,9 +131,21 @@ void testExecuteRejectsNewWithoutValueDelimiter() { IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> envFileService.execute("user1", "Key1=new")); - assertEquals("Invalid .env entry on line 1: expected KEY=new:value, KEY=update:value, or KEY=delete", + assertEquals("Invalid .env entry on line 1: expected KEY=new:value, KEY=update:value, KEY=get, KEY=get:version, or KEY=delete", + exception.getMessage()); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService, + getSecretService); + } + + @Test + void testExecuteRejectsGetAll() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> envFileService.execute("user1", "Key1=get:all")); + + assertEquals("Invalid .env entry on line 1: expected KEY=new:value, KEY=update:value, KEY=get, KEY=get:version, or KEY=delete", exception.getMessage()); - verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService, + getSecretService); } @Test @@ -114,7 +156,7 @@ void testExecuteRejectsExistingSecretForNewOperation() { () -> envFileService.execute("user1", "Key1=new:val")); assertEquals("Secret already exists: Key1", exception.getMessage()); - verifyNoInteractions(postSecretService, putSecretService, deleteSecretService); + verifyNoInteractions(postSecretService, putSecretService, deleteSecretService, getSecretService); } @Test @@ -125,12 +167,26 @@ void testExecuteRejectsMissingSecretForUpdateOperation() { () -> envFileService.execute("user1", "Key1=update:val")); assertEquals("Secret not found: Key1", exception.getMessage()); + verifyNoInteractions(postSecretService, putSecretService, deleteSecretService, getSecretService); + } + + @Test + void testExecuteRejectsMissingSecretForGetOperation() { + when(getSecretService.getVersion("user1", "Key1", null)) + .thenThrow(new SecretNotFoundException("Secret not found: Key1")); + + SecretNotFoundException exception = assertThrows(SecretNotFoundException.class, + () -> envFileService.execute("user1", "Key1=get")); + + assertEquals("Secret not found: Key1", exception.getMessage()); + verify(getSecretService).getVersion("user1", "Key1", null); verifyNoInteractions(postSecretService, putSecretService, deleteSecretService); } @Test void testExecuteRequiresUser() { assertThrows(IllegalArgumentException.class, () -> envFileService.execute(" ", "Key1=new:val")); - verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService); + verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService, + getSecretService); } }