Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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:

Expand All @@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,47 +28,57 @@ 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<String> execute(String user, String envFileContent) {
validateUser(user);
List<EnvSecretOperation> operations = parseOperations(envFileContent);
validateOperationPreconditions(user, operations);
Map<String, String> getResults = validateOperationPreconditions(user, operations);

int created = 0;
int updated = 0;
int retrieved = 0;
int deleted = 0;
List<String> 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++;
}
}
}

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) {
Expand Down Expand Up @@ -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<EnvSecretOperation> operations) {
private Map<String, String> validateOperationPreconditions(String user, List<EnvSecretOperation> operations) {
Map<String, String> 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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public class EnvFileServiceTest {
@Mock
private DeleteSecretService deleteSecretService;

@Mock
private GetSecretService getSecretService;

@Mock
private SecretPartRepository secretPartRepository;

Expand All @@ -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<String> 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<PostSecretRequest> postCaptor = ArgumentCaptor.forClass(PostSecretRequest.class);
verify(postSecretService).execute(postCaptor.capture());
Expand All @@ -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<String> 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<String> response = envFileService.execute("user1", "Key1=delete");

assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("", response.getBody());
}

@Test
Expand All @@ -84,26 +112,40 @@ void testExecuteRejectsDuplicateKeysBeforeWrites() {
"""));

assertEquals("Duplicate key in .env file: Key1", exception.getMessage());
verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService);
verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService,
getSecretService);
}

@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);
assertEquals("Invalid .env action on line 1: expected new, update, get, or delete", exception.getMessage());
verifyNoInteractions(secretPartRepository, postSecretService, putSecretService, deleteSecretService,
getSecretService);
}

@Test
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
Expand All @@ -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
Expand All @@ -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);
}
}
Loading