Repository: floci-io/floci (2026-07-23T14:34:56Z)

Issue:
[BUG] DescribeKeyPairs — empty list / HTTP 200 instead of InvalidKeyPair.NotFound
## Service

EC2

## AWS API Action

DescribeKeyPairs

## AWS Documentation

https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeKeyPairs.html

https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html

If `KeyName` does not exist → `InvalidKeyPair.NotFound` (the specified key pair name does not exist).

## Expected behavior

```bash
aws ec2 describe-key-pairs --key-names does-not-exist
```

→ error `InvalidKeyPair.NotFound`, non-zero CLI exit code.

## Actual behavior

Floci returns HTTP 200 / CLI exit 0 with empty (or without the requested name):

```json
{
  "KeyPairs": []
}
```

## Impact

Idempotent callers that treat `describe-key-pairs --key-names X` exit 0 as “present” skip `ImportKeyPair` / create and leave the account without the key.

## Reproduction

```bash
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
export AWS_ENDPOINT_URL=http://localhost:4566

# Fresh / empty account: no key pairs
aws ec2 describe-key-pairs --output json

# Missing name must fail like AWS (InvalidKeyPair.NotFound)
aws ec2 describe-key-pairs \
  --key-names fintree-production \
  --output json
echo "exit=$?"
```

## Environment

- Floci version / image tag: nightly
- Running: Docker (port 4566)

## Related

- Many EC2 errors listed in https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html are still missing in Floci; the matching describe-by-name/id calls often return HTTP 200 / CLI exit 0 (empty list) instead of the documented fault — same class of bug as this one, not limited to `DescribeKeyPairs`.



Discussion:

Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java
index a80c0d0547..4f58a124c6 100644
--- a/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java
+++ b/src/main/java/io/github/hectorvent/floci/services/ec2/Ec2Service.java
@@ -1583,8 +1583,27 @@ public KeyPair createKeyPair(String region, String keyName) {
 
     public List<KeyPair> describeKeyPairs(String region, List<String> keyNames, List<String> keyPairIds) {
         ensureDefaultResources(region);
-        return keyPairs.scan(k -> true).stream()
+        List<KeyPair> regionKeyPairs = keyPairs.scan(k -> true).stream()
                 .filter(k -> k.getRegion().equals(region))
+                .collect(Collectors.toList());
+
+        // A named/id lookup for a key pair that does not exist is an error in real
+        // AWS (InvalidKeyPair.NotFound), not an empty result — otherwise idempotent
+        // callers that treat exit 0 as "present" skip creating the key.
+        for (String keyName : keyNames) {
+            if (regionKeyPairs.stream().noneMatch(k -> keyName.equals(k.getKeyName()))) {
+                throw new AwsException("InvalidKeyPair.NotFound",
+                        "The key pair '" + keyName + "' does not exist", 400);
+            }
+        }
+        for (String keyPairId : keyPairIds) {
+            if (regionKeyPairs.stream().noneMatch(k -> keyPairId.equals(k.getKeyPairId()))) {
+                throw new AwsException("InvalidKeyPair.NotFound",
+                        "The key pair ID '" + keyPairId + "' does not exist", 400);
+            }
+        }
+
+        return regionKeyPairs.stream()
                 .filter(k -> keyNames.isEmpty() || keyNames.contains(k.getKeyName()))
                 .filter(k -> keyPairIds.isEmpty() || keyPairIds.contains(k.getKeyPairId()))
                 .collect(Collectors.toList());


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java
index 65d42f44ce..636c1cc427 100644
--- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2IntegrationTest.java
@@ -879,6 +879,20 @@ void describeKeyPairs() {
             .body("DescribeKeyPairsResponse.keySet.item.keyName", equalTo("test-key"));
     }
 
+    @Test
+    @Order(41)
+    void describeKeyPairsRejectsMissingName() {
+        given()
+            .formParam("Action", "DescribeKeyPairs")
+            .formParam("KeyName.1", "does-not-exist")
+            .header("Authorization", AUTH_HEADER)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(400)
+            .body("Response.Errors.Error.Code", equalTo("InvalidKeyPair.NotFound"));
+    }
+
     @Test
     @Order(39)
     void importKeyPair() {
diff --git a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java
index 16b96fa515..eed059623a 100644
--- a/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/ec2/Ec2ServiceTest.java
@@ -297,6 +297,50 @@ void importKeyPairRejectsNameAlreadyUsedByCreateKeyPair() {
         assertEquals("InvalidKeyPair.Duplicate", error.getErrorCode());
     }
 
+    @Test
+    void describeKeyPairsThrowsNotFoundForMissingName() {
+        Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
+                mock(Ec2PortForwardManager.class),
+                mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class), new Ec2InstanceTypeCatalog(),
+                new InMemoryStorageFactory());
+
+        AwsException error = assertThrows(AwsException.class,
+                () -> service.describeKeyPairs("us-east-1", List.of("does-not-exist"), List.of()));
+        assertEquals("InvalidKeyPair.NotFound", error.getErrorCode());
+        assertEquals(400, error.getHttpStatus());
+    }
+
+    @Test
+    void describeKeyPairsThrowsNotFoundForMissingId() {
+        Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
+                mock(Ec2PortForwardManager.class),
+                mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class), new Ec2InstanceTypeCatalog(),
+                new InMemoryStorageFactory());
+
+        AwsException error = assertThrows(AwsException.class,
+                () -> service.describeKeyPairs("us-east-1", List.of(), List.of("key-missing")));
+        assertEquals("InvalidKeyPair.NotFound", error.getErrorCode());
+    }
+
+    @Test
+    void describeKeyPairsReturnsRequestedKeyAndAllowsEmptyUnfilteredList() {
+        Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),
+                mock(Ec2PortForwardManager.class),
+                mock(AmiImageResolver.class), mock(Ec2ImageCatalog.class), new Ec2InstanceTypeCatalog(),
+                new InMemoryStorageFactory());
+
+        // Unfiltered describe on an empty account is not an error.
+        assertTrue(service.describeKeyPairs("us-east-1", List.of(), List.of()).isEmpty());
+
+        service.createKeyPair("us-east-1", "present-key");
+        assertEquals(1, service.describeKeyPairs("us-east-1", List.of("present-key"), List.of()).size());
+
+        // A missing name is not masked by a present one in the same request.
+        AwsException error = assertThrows(AwsException.class,
+                () -> service.describeKeyPairs("us-east-1", List.of("present-key", "absent-key"), List.of()));
+        assertEquals("InvalidKeyPair.NotFound", error.getErrorCode());
+    }
+
     @Test
     void registerImageReusingSnapshotDoesNotOverwriteSnapshotMetadata() {
         Ec2Service service = new Ec2Service(mockConfig(true), mock(Ec2ContainerManager.class),