From 21b41691306523c57c8251074f3ac8ad5c1ea8af Mon Sep 17 00:00:00 2001 From: Oracle Date: Tue, 21 Apr 2026 18:00:31 +0200 Subject: [PATCH] Add tests --- .gitignore | 3 +- pom.xml | 20 +- src/main/java/ai/nomyo/Constants.java | 4 - .../java/ai/nomyo/SecureCompletionClient.java | 51 +- .../nomyo/SecureCompletionClientE2ETest.java | 381 +++++++++++++++ .../ai/nomyo/SecureCompletionClientTest.java | 436 ++++++++++++++++++ src/test/resources/junit-platform.properties | 2 + 7 files changed, 879 insertions(+), 18 deletions(-) create mode 100644 src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java create mode 100644 src/test/java/ai/nomyo/SecureCompletionClientTest.java create mode 100644 src/test/resources/junit-platform.properties diff --git a/.gitignore b/.gitignore index 480bdf5..acfa420 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ build/ .vscode/ ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store +client_keys \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2fae3b5..11d2d24 100644 --- a/pom.xml +++ b/pom.xml @@ -19,18 +19,32 @@ org.projectlombok lombok 1.18.44 - compile + provided org.junit.jupiter - junit-jupiter-engine - 6.0.3 + junit-jupiter + 5.12.1 test + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.projectlombok + lombok + 1.18.44 + + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/src/main/java/ai/nomyo/Constants.java b/src/main/java/ai/nomyo/Constants.java index 831f5c0..25e249d 100644 --- a/src/main/java/ai/nomyo/Constants.java +++ b/src/main/java/ai/nomyo/Constants.java @@ -12,10 +12,6 @@ import java.util.Set; */ public final class Constants { - private Constants() { - // Utility class — prevents instantiation - } - // ── Protocol Constants ────────────────────────────────────────── /** diff --git a/src/main/java/ai/nomyo/SecureCompletionClient.java b/src/main/java/ai/nomyo/SecureCompletionClient.java index 4eee37e..66913a4 100644 --- a/src/main/java/ai/nomyo/SecureCompletionClient.java +++ b/src/main/java/ai/nomyo/SecureCompletionClient.java @@ -16,7 +16,10 @@ import java.nio.file.attribute.PosixFilePermissions; import java.security.*; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.RSAKeyGenParameterSpec; +import java.io.File; import java.io.UnsupportedEncodingException; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.security.spec.RSAPrivateKeySpec; import java.util.Arrays; import java.util.Map; import java.util.Scanner; @@ -238,22 +241,31 @@ public class SecureCompletionClient { * @param password optional password for the encrypted private key */ public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) { + File keyFile = new File(privateKeyPath); + if (!keyFile.exists()) { + throw new RuntimeException("Private key file not found: " + privateKeyPath); + } + + String keyContent; if (password != null && !password.isEmpty()) { - String cipherText = getEncryptedPrivateKeyFromFile(privateKeyPath); + keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath); try { - cipherText = Pass2Key.decrypt("AES/GCM/NoPadding", cipherText, password); + keyContent = Pass2Key.decrypt("AES/GCM/NoPadding", keyContent, password); } catch (NoSuchPaddingException | NoSuchAlgorithmException | BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException | InvalidKeyException e) { System.out.println("Wrong password!"); + return; } + } else { + keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath); + } - try { - this.privateKey = Pass2Key.convertStringToPrivateKey(cipherText); - } catch (Exception e) { - throw new RuntimeException(e); - } + try { + this.privateKey = Pass2Key.convertStringToPrivateKey(keyContent); + } catch (Exception e) { + throw new RuntimeException("Failed to load private key: " + e.getMessage(), e); } } @@ -451,9 +463,7 @@ public class SecureCompletionClient { if (key == null) { throw new SecurityError("RSA key is null"); } - int keySize = key.getEncoded() != null ? key.getEncoded().length * 8 : 0; - - System.out.println("Keysize: " + keySize); + int keySize = extractKeySize(key); if (keySize < Constants.MIN_RSA_KEY_SIZE) { throw new SecurityError( @@ -462,6 +472,27 @@ public class SecureCompletionClient { } } + private int extractKeySize(PrivateKey key) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.spec.RSAPrivateCrtKeySpec crtSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateCrtKeySpec.class); + return crtSpec.getModulus().bitLength(); + } catch (Exception ignored) { + // Try RSAPrivateKeySpec + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.spec.RSAPrivateKeySpec privSpec = kf.getKeySpec(key, java.security.spec.RSAPrivateKeySpec.class); + return privSpec.getModulus().bitLength(); + } catch (Exception ignored2) { + // Fall back to encoded length + if (key.getEncoded() != null) { + return key.getEncoded().length * 8; + } + } + } + return 0; + } + // ── HTTP Status → Exception Mapping ───────────────────────────── /** diff --git a/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java new file mode 100644 index 0000000..44085ae --- /dev/null +++ b/src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java @@ -0,0 +1,381 @@ +package ai.nomyo; + +import ai.nomyo.errors.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SecureCompletionClientE2ETest { + + private static final String TEST_PASSWORD = "e2e-test-password-456"; + private static final String BASE_URL = "https://api.nomyo.ai"; + + @TempDir + Path tempDir; + + private File keyDir; + + @BeforeEach + void setUp() { + keyDir = tempDir.resolve("e2e_keys").toFile(); + assertTrue(keyDir.mkdirs(), "Key directory should be created"); + } + + @AfterEach + void tearDown() { + // Cleanup is handled by @TempDir + } + + // ── Full Lifecycle E2E Tests ────────────────────────────────────── + + @Test + @Order(1) + @DisplayName("E2E: Generate keys, save to disk, load in new client, validate") + void e2e_fullLifecycle_generateSaveLoadValidate() throws Exception { + // Step 1: Generate keys and save to disk + SecureCompletionClient generateClient = new SecureCompletionClient(BASE_URL, false, true, 2); + generateClient.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + + PrivateKey generatedKey = generateClient.getPrivateKey(); + String generatedPublicPem = generateClient.getPublicPemKey(); + + assertNotNull(generatedKey, "Generated private key should not be null"); + assertNotNull(generatedPublicPem, "Generated public PEM should not be null"); + + // Step 2: Verify key files exist on disk + File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); + File publicKeyFile = new File(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE); + + assertTrue(privateKeyFile.exists(), "Private key file should exist on disk"); + assertTrue(publicKeyFile.exists(), "Public key file should exist on disk"); + + // Step 3: Load keys into a new client instance + SecureCompletionClient loadClient = new SecureCompletionClient(BASE_URL, false, true, 2); + loadClient.loadKeys( + privateKeyFile.getAbsolutePath(), + null, + TEST_PASSWORD + ); + + PrivateKey loadedKey = loadClient.getPrivateKey(); + assertNotNull(loadedKey, "Loaded private key should not be null"); + + // Step 4: Validate the loaded key + assertDoesNotThrow(() -> loadClient.validateRsaKey(loadedKey), + "Loaded key should pass validation"); + + // Step 5: Verify the loaded key matches the original + assertArrayEquals(generatedKey.getEncoded(), loadedKey.getEncoded(), + "Loaded key bytes should match original key bytes"); + } + + @Test + @Order(2) + @DisplayName("E2E: Generate plaintext keys, load, and validate") + void e2e_plaintextKeys_generateLoadValidate() throws Exception { + // Generate plaintext keys (no password) + SecureCompletionClient client = new SecureCompletionClient(); + client.generateKeys(true, keyDir.getAbsolutePath(), null); + + PrivateKey originalKey = client.getPrivateKey(); + + // Load into new client + SecureCompletionClient loadClient = new SecureCompletionClient(); + loadClient.loadKeys( + new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null + ); + + PrivateKey loadedKey = loadClient.getPrivateKey(); + + // Validate + assertDoesNotThrow(() -> loadClient.validateRsaKey(loadedKey)); + assertArrayEquals(originalKey.getEncoded(), loadedKey.getEncoded()); + } + + @Test + @Order(3) + @DisplayName("E2E: Key validation with various key sizes") + void e2e_keyValidation_variousSizes() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + // Test with 1024-bit key (should fail) + KeyPairGenerator gen1024 = KeyPairGenerator.getInstance("RSA"); + gen1024.initialize(1024); + PrivateKey smallKey = gen1024.generateKeyPair().getPrivate(); + + SecurityError smallError = assertThrows(SecurityError.class, + () -> client.validateRsaKey(smallKey)); + assertTrue(smallError.getMessage().contains("below minimum")); + + // Test with 2048-bit key (should pass - minimum) + KeyPairGenerator gen2048 = KeyPairGenerator.getInstance("RSA"); + gen2048.initialize(2048); + PrivateKey minKey = gen2048.generateKeyPair().getPrivate(); + + assertDoesNotThrow(() -> client.validateRsaKey(minKey)); + + // Test with 4096-bit key (should pass) + KeyPairGenerator gen4096 = KeyPairGenerator.getInstance("RSA"); + gen4096.initialize(4096); + PrivateKey largeKey = gen4096.generateKeyPair().getPrivate(); + + assertDoesNotThrow(() -> client.validateRsaKey(largeKey)); + } + + @Test + @Order(4) + @DisplayName("E2E: HTTP status mapping covers all documented cases") + void e2e_httpStatusMapping_allCases() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + // 200 - success (null exception) + assertNull(client.mapHttpStatus(200, "OK")); + + // 400 - InvalidRequestError + Exception e400 = client.mapHttpStatus(400, "Bad request"); + assertInstanceOf(InvalidRequestError.class, e400); + + // 401 - AuthenticationError + Exception e401 = client.mapHttpStatus(401, "Unauthorized"); + assertInstanceOf(AuthenticationError.class, e401); + + // 403 - ForbiddenError + Exception e403 = client.mapHttpStatus(403, "Forbidden"); + assertInstanceOf(ForbiddenError.class, e403); + + // 404 - APIError + Exception e404 = client.mapHttpStatus(404, "Not found"); + assertInstanceOf(APIError.class, e404); + + // 429 - RateLimitError + Exception e429 = client.mapHttpStatus(429, "Too many requests"); + assertInstanceOf(RateLimitError.class, e429); + + // 500 - ServerError + Exception e500 = client.mapHttpStatus(500, "Internal error"); + assertInstanceOf(ServerError.class, e500); + + // 502 - APIError (retryable) + Exception e502 = client.mapHttpStatus(502, "Bad gateway"); + assertInstanceOf(APIError.class, e502); + + // 503 - ServiceUnavailableError + Exception e503 = client.mapHttpStatus(503, "Service unavailable"); + assertInstanceOf(ServiceUnavailableError.class, e503); + + // 504 - APIError (retryable) + Exception e504 = client.mapHttpStatus(504, "Gateway timeout"); + assertInstanceOf(APIError.class, e504); + + // 599 - APIError (unknown) + Exception e599 = client.mapHttpStatus(599, "Unknown error"); + assertInstanceOf(APIError.class, e599); + } + + @Test + @Order(5) + @DisplayName("E2E: Retryable status codes match Constants.RETRYABLE_STATUS_CODES") + void e2e_retryableStatusCodes_matchConstants() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + for (int code : Constants.RETRYABLE_STATUS_CODES) { + Exception exception = client.mapHttpStatus(code, "test"); + // All retryable codes should return an Exception (not null) + assertNotNull(exception, "Retryable status " + code + " should return an exception"); + } + } + + @Test + @Order(6) + @DisplayName("E2E: URL encoding of public key PEM") + void e2e_urlEncoding_publicKey() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" + + "IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBI" + + "kAMBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB" + + "IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBIjAN\n-----END PUBLIC KEY-----"; + + String encoded = client.urlEncodePublicKey(pemKey); + + assertNotNull(encoded); + assertFalse(encoded.contains("\n"), "Encoded key should not contain newlines"); + assertFalse(encoded.contains(" "), "Encoded key should not contain spaces"); + + // Decode and verify roundtrip + String decoded = java.net.URLDecoder.decode(encoded, java.nio.charset.StandardCharsets.UTF_8); + assertEquals(pemKey, decoded, "URL decoding should restore original PEM"); + } + + @Test + @Order(7) + @DisplayName("E2E: Multiple clients can independently generate and load keys") + void e2e_multipleClients_independentOperations() throws Exception { + File dir1 = tempDir.resolve("dir1").toFile(); + File dir2 = tempDir.resolve("dir2").toFile(); + dir1.mkdirs(); + dir2.mkdirs(); + + // Client 1 + SecureCompletionClient client1 = new SecureCompletionClient(); + client1.generateKeys(true, dir1.getAbsolutePath(), "pass1"); + PrivateKey key1 = client1.getPrivateKey(); + + // Client 2 + SecureCompletionClient client2 = new SecureCompletionClient(); + client2.generateKeys(true, dir2.getAbsolutePath(), "pass2"); + PrivateKey key2 = client2.getPrivateKey(); + + // Verify keys are different (different modulus values) + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.spec.RSAPrivateCrtKeySpec spec1 = kf.getKeySpec(key1, java.security.spec.RSAPrivateCrtKeySpec.class); + java.security.spec.RSAPrivateCrtKeySpec spec2 = kf.getKeySpec(key2, java.security.spec.RSAPrivateCrtKeySpec.class); + assertNotEquals(spec1.getModulus(), spec2.getModulus(), + "Different clients should generate different keys"); + + // Load keys independently + SecureCompletionClient load1 = new SecureCompletionClient(); + load1.loadKeys( + new File(dir1, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null, "pass1" + ); + + SecureCompletionClient load2 = new SecureCompletionClient(); + load2.loadKeys( + new File(dir2, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null, "pass2" + ); + + // Verify each loaded key matches its original + assertArrayEquals(key1.getEncoded(), load1.getPrivateKey().getEncoded()); + assertArrayEquals(key2.getEncoded(), load2.getPrivateKey().getEncoded()); + } + + @Test + @Order(8) + @DisplayName("E2E: Client constructor parameters are correctly set") + void e2e_clientConstructor_parametersSetCorrectly() throws Exception { + SecureCompletionClient client = new SecureCompletionClient( + "https://custom.api.com", + true, + false, + 5 + ); + + assertEquals("https://custom.api.com", client.getRouterUrl()); + assertTrue(client.isAllowHttp()); + assertFalse(client.isUseSecureMemory()); + assertEquals(5, client.getMaxRetries()); + } + + @Test + @Order(9) + @DisplayName("E2E: Client strips trailing slashes from routerUrl") + void e2e_clientConstructor_stripsTrailingSlashes() throws Exception { + SecureCompletionClient client = new SecureCompletionClient( + "https://api.example.com///", + false, true, 1 + ); + + assertEquals("https://api.example.com", client.getRouterUrl()); + } + + @Test + @Order(10) + @DisplayName("E2E: Client uses default values when constructed with no args") + void e2e_clientConstructor_defaultValues() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + assertEquals(Constants.DEFAULT_BASE_URL, client.getRouterUrl()); + assertFalse(client.isAllowHttp()); + assertTrue(client.isUseSecureMemory()); + assertEquals(Constants.DEFAULT_MAX_RETRIES, client.getMaxRetries()); + } + + @Test + @Order(11) + @DisplayName("E2E: Encrypted key file is unreadable without password") + void e2e_encryptedKey_unreadableWithoutPassword() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + + File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); + String encryptedContent = Files.readString(privateKeyFile.toPath()); + + // Verify the file content is encrypted (no PEM header) + assertFalse(encryptedContent.contains("BEGIN PRIVATE KEY"), + "Encrypted file should not contain PEM header"); + + // Try loading with wrong password - should not throw + SecureCompletionClient loadClient = new SecureCompletionClient(); + assertDoesNotThrow(() -> + loadClient.loadKeys(privateKeyFile.getAbsolutePath(), null, "wrong-password"), + "Wrong password should be handled gracefully" + ); + } + + @Test + @Order(12) + @DisplayName("E2E: Generate keys without saving produces in-memory keys") + void e2e_generateKeys_noSave_producesInMemoryKeys() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + client.generateKeys(false); + + PrivateKey privateKey = client.getPrivateKey(); + String publicPem = client.getPublicPemKey(); + + assertNotNull(privateKey, "Private key should be in memory"); + assertNotNull(publicPem, "Public PEM should be in memory"); + assertTrue(privateKey.getAlgorithm().equals("RSA")); + } + + @Test + @Order(13) + @DisplayName("E2E: SecurityError is thrown for null key validation") + void e2e_nullKeyValidation_throwsSecurityError() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + SecurityError error = assertThrows(SecurityError.class, + () -> client.validateRsaKey(null)); + + assertNotNull(error.getMessage()); + assertTrue(error.getMessage().contains("null")); + } + + @Test + @Order(14) + @DisplayName("E2E: mapHttpStatus returns null for 200 status") + void e2e_mapHttpStatus_200_returnsNull() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + Exception result = client.mapHttpStatus(200, "Success"); + + assertNull(result, "HTTP 200 should return null (no error)"); + } + + @Test + @Order(15) + @DisplayName("E2E: mapHttpStatus includes response body in error message") + void e2e_mapHttpStatus_includesResponseBody() throws Exception { + SecureCompletionClient client = new SecureCompletionClient(); + + Exception e400 = client.mapHttpStatus(400, "Invalid parameter: email"); + assertTrue(e400.getMessage().contains("Invalid parameter: email"), + "Error message should include response body"); + + Exception e500 = client.mapHttpStatus(500, "Database connection failed"); + assertTrue(e500.getMessage().contains("Database connection failed"), + "Error message should include response body"); + } +} diff --git a/src/test/java/ai/nomyo/SecureCompletionClientTest.java b/src/test/java/ai/nomyo/SecureCompletionClientTest.java new file mode 100644 index 0000000..aba8f05 --- /dev/null +++ b/src/test/java/ai/nomyo/SecureCompletionClientTest.java @@ -0,0 +1,436 @@ +package ai.nomyo; + +import ai.nomyo.errors.SecurityError; +import ai.nomyo.util.Pass2Key; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.*; +import java.security.spec.RSAKeyGenParameterSpec; +import java.security.spec.RSAPrivateCrtKeySpec; + +import static org.junit.jupiter.api.Assertions.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class SecureCompletionClientTest { + + private static final String TEST_PASSWORD = "test-password-123"; + private static final String PLAINTEXT_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7o4qne60TBb\n-----END PRIVATE KEY-----"; + + private SecureCompletionClient client; + + @BeforeEach + void setUp() { + client = new SecureCompletionClient(); + } + + @AfterEach + void tearDown() { + client = null; + } + + // ── Key Generation Tests ────────────────────────────────────────── + + @Test + @Order(1) + @DisplayName("generateKeys should create 4096-bit RSA key pair") + void generateKeys_shouldCreateValidKeyPair() { + client.generateKeys(false); + + PrivateKey privateKey = client.getPrivateKey(); + String publicPemKey = client.getPublicPemKey(); + + assertNotNull(privateKey, "Private key should not be null"); + assertNotNull(publicPemKey, "Public PEM key should not be null"); + assertTrue(privateKey.getAlgorithm().equals("RSA"), "Key algorithm should be RSA"); + assertTrue(publicPemKey.contains("BEGIN PUBLIC KEY"), "Public key should be valid PEM"); + } + + @Test + @Order(2) + @DisplayName("generateKeys should produce keys with correct bit size") + void generateKeys_shouldProduceCorrectKeySize() throws Exception { + client.generateKeys(false); + + PrivateKey privateKey = client.getPrivateKey(); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.spec.RSAPrivateCrtKeySpec spec = kf.getKeySpec(privateKey, java.security.spec.RSAPrivateCrtKeySpec.class); + int keySizeBits = spec.getModulus().bitLength(); + + assertEquals(Constants.RSA_KEY_SIZE, keySizeBits, + "RSA key should be " + Constants.RSA_KEY_SIZE + " bits"); + } + + @Test + @Order(3) + @DisplayName("generateKeys should create unique keys on each call") + void generateKeys_shouldProduceUniqueKeys() { + client.generateKeys(false); + PrivateKey firstKey = client.getPrivateKey(); + + SecureCompletionClient client2 = new SecureCompletionClient(); + client2.generateKeys(false); + PrivateKey secondKey = client2.getPrivateKey(); + + assertNotEquals(firstKey.getEncoded().length, secondKey.getEncoded().length, + "Different keys should have different encoded lengths"); + } + + // ── Key Generation with File Save Tests ─────────────────────────── + + @Test + @Order(4) + @DisplayName("generateKeys with saveToFile=true should create key files") + void generateKeys_withSaveToFile_shouldCreateKeyFiles(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + + client.generateKeys(true, keyDir.getAbsolutePath(), null); + + File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); + File publicKeyFile = new File(keyDir, Constants.DEFAULT_PUBLIC_KEY_FILE); + + assertTrue(privateKeyFile.exists(), "Private key file should be created"); + assertTrue(publicKeyFile.exists(), "Public key file should be created"); + assertTrue(privateKeyFile.length() > 0, "Private key file should not be empty"); + assertTrue(publicKeyFile.length() > 0, "Public key file should not be empty"); + } + + @Test + @Order(5) + @DisplayName("generateKeys with password should encrypt private key file") + void generateKeys_withPassword_shouldEncryptPrivateKey(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + + client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + + File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); + String content = Files.readString(privateKeyFile.toPath()); + + assertFalse(content.contains("BEGIN PRIVATE KEY"), + "Encrypted private key should not contain PEM header"); + assertNotEquals(PLAINTEXT_PRIVATE_KEY, content, + "Encrypted key should differ from plaintext"); + } + + @Test + @Order(6) + @DisplayName("generateKeys should not overwrite existing key files") + void generateKeys_shouldNotOverwriteExistingKeys(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + + client.generateKeys(true, keyDir.getAbsolutePath(), null); + File privateKeyFile = new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE); + long firstSize = privateKeyFile.length(); + + client.generateKeys(true, keyDir.getAbsolutePath(), null); + long secondSize = privateKeyFile.length(); + + assertEquals(firstSize, secondSize, + "Existing key files should not be overwritten"); + } + + // ── Key Loading Tests ───────────────────────────────────────────── + + @Test + @Order(7) + @DisplayName("loadKeys should load plaintext private key from file") + void loadKeys_plaintext_shouldLoadPrivateKey(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + client.generateKeys(true, keyDir.getAbsolutePath(), null); + + PrivateKey originalKey = client.getPrivateKey(); + + SecureCompletionClient loadClient = new SecureCompletionClient(); + loadClient.loadKeys( + new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null + ); + + PrivateKey loadedKey = loadClient.getPrivateKey(); + assertNotNull(loadedKey, "Loaded private key should not be null"); + assertEquals(originalKey.getEncoded().length, loadedKey.getEncoded().length, + "Loaded key should have same size as original"); + } + + @Test + @Order(8) + @DisplayName("loadKeys should load encrypted private key with correct password") + void loadKeys_encrypted_correctPassword_shouldLoadPrivateKey(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + + PrivateKey originalKey = client.getPrivateKey(); + + SecureCompletionClient loadClient = new SecureCompletionClient(); + loadClient.loadKeys( + new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null, + TEST_PASSWORD + ); + + PrivateKey loadedKey = loadClient.getPrivateKey(); + assertNotNull(loadedKey, "Loaded private key should not be null"); + assertEquals(originalKey.getEncoded().length, loadedKey.getEncoded().length, + "Loaded key should have same size as original"); + } + + @Test + @Order(9) + @DisplayName("loadKeys should handle wrong password gracefully") + void loadKeys_encrypted_wrongPassword_shouldHandleGracefully(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + + SecureCompletionClient loadClient = new SecureCompletionClient(); + + assertDoesNotThrow(() -> + loadClient.loadKeys( + new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null, + "wrong-password" + ), + "Wrong password should not throw exception" + ); + } + + @Test + @Order(10) + @DisplayName("loadKeys should throw exception for non-existent file") + void loadKeys_nonExistentFile_shouldThrowException() { + SecureCompletionClient loadClient = new SecureCompletionClient(); + + RuntimeException error = assertThrows(RuntimeException.class, () -> + loadClient.loadKeys("/non/existent/path/private_key.pem", null, null)); + + assertTrue(error.getMessage().contains("not found"), + "Error message should mention file not found"); + } + + // ── Key Validation Tests ────────────────────────────────────────── + + @Test + @Order(11) + @DisplayName("validateRsaKey should accept valid 4096-bit key") + void validateRsaKey_validKey_shouldPass() throws Exception { + client.generateKeys(false); + PrivateKey key = client.getPrivateKey(); + + assertDoesNotThrow(() -> client.validateRsaKey(key), + "Valid 4096-bit key should pass validation"); + } + + @Test + @Order(12) + @DisplayName("validateRsaKey should reject null key") + void validateRsaKey_nullKey_shouldThrowSecurityError() throws Exception { + SecurityError error = assertThrows(SecurityError.class, () -> + client.validateRsaKey(null)); + + assertTrue(error.getMessage().contains("null"), + "Error message should mention null key"); + } + + @Test + @Order(13) + @DisplayName("validateRsaKey should reject keys below minimum size") + void validateRsaKey_tooSmallKey_shouldThrowSecurityError() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(1024); + KeyPair pair = generator.generateKeyPair(); + PrivateKey smallKey = pair.getPrivate(); + + SecurityError error = assertThrows(SecurityError.class, () -> + client.validateRsaKey(smallKey)); + + assertTrue(error.getMessage().contains("below minimum"), + "Error message should mention minimum size requirement"); + } + + @Test + @Order(14) + @DisplayName("validateRsaKey should accept minimum size key (2048 bits)") + void validateRsaKey_minimumSizeKey_shouldPass() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(new RSAKeyGenParameterSpec(2048, BigInteger.valueOf(65537))); + KeyPair pair = generator.generateKeyPair(); + PrivateKey minKey = pair.getPrivate(); + + assertDoesNotThrow(() -> client.validateRsaKey(minKey), + "2048-bit key should pass validation (minimum)"); + } + + // ── Key Roundtrip Tests ─────────────────────────────────────────── + + @Test + @Order(15) + @DisplayName("Full roundtrip: generate, save, load should produce same key") + void roundtrip_generateSaveLoad_shouldProduceSameKey(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + + // Generate and save + client.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + PrivateKey originalKey = client.getPrivateKey(); + + // Load into new client + SecureCompletionClient loadClient = new SecureCompletionClient(); + loadClient.loadKeys( + new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null, + TEST_PASSWORD + ); + PrivateKey loadedKey = loadClient.getPrivateKey(); + + // Verify keys match + assertNotNull(loadedKey, "Loaded key should not be null"); + assertArrayEquals(originalKey.getEncoded(), loadedKey.getEncoded(), + "Loaded key should match original key bytes"); + } + + @Test + @Order(16) + @DisplayName("Multiple generate/load cycles should work correctly") + void multipleCycles_shouldWorkCorrectly(@TempDir Path tempDir) throws Exception { + File keyDir = tempDir.toFile(); + + for (int i = 0; i < 3; i++) { + SecureCompletionClient cycleClient = new SecureCompletionClient(); + cycleClient.generateKeys(true, keyDir.getAbsolutePath(), TEST_PASSWORD); + PrivateKey generatedKey = cycleClient.getPrivateKey(); + + SecureCompletionClient loadClient = new SecureCompletionClient(); + loadClient.loadKeys( + new File(keyDir, Constants.DEFAULT_PRIVATE_KEY_FILE).getAbsolutePath(), + null, + TEST_PASSWORD + ); + PrivateKey loadedKey = loadClient.getPrivateKey(); + + assertNotNull(loadedKey, "Cycle " + i + ": loaded key should not be null"); + KeyFactory kf = KeyFactory.getInstance("RSA"); + int generatedBits = kf.getKeySpec(generatedKey, RSAPrivateCrtKeySpec.class).getModulus().bitLength(); + int loadedBits = kf.getKeySpec(loadedKey, RSAPrivateCrtKeySpec.class).getModulus().bitLength(); + assertEquals(generatedBits, loadedBits, + "Cycle " + i + ": loaded key should have same modulus bit length as generated key"); + } + } + + // ── Utility Method Tests ────────────────────────────────────────── + + @Test + @Order(17) + @DisplayName("urlEncodePublicKey should properly encode PEM keys") + void urlEncodePublicKey_shouldEncodeCorrectly() { + String pemKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A\n-----END PUBLIC KEY-----"; + + String encoded = client.urlEncodePublicKey(pemKey); + + assertNotNull(encoded, "Encoded key should not be null"); + assertTrue(encoded.contains("%0A"), "Encoded key should contain URL-encoded newlines"); + assertTrue(encoded.contains("BEGIN+PUBLIC+KEY"), "Encoded key should contain encoded header"); + } + + @Test + @Order(18) + @DisplayName("urlEncodePublicKey should handle empty string") + void urlEncodePublicKey_emptyString_shouldReturnEmpty() { + String encoded = client.urlEncodePublicKey(""); + assertEquals("", encoded, "Empty string should encode to empty string"); + } + + // ── Pass2Key Encryption/Decryption Tests ────────────────────────── + + @Test + @Order(19) + @DisplayName("Pass2Key encrypt/decrypt should preserve plaintext") + void pass2Key_encryptDecrypt_shouldPreservePlaintext() throws Exception { + String plaintext = "Test plaintext content for encryption"; + String password = "my-secret-password"; + + String encrypted = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, password); + + assertNotNull(encrypted, "Encrypted text should not be null"); + assertNotEquals(plaintext, encrypted, "Encrypted text should differ from plaintext"); + + String decrypted = Pass2Key.decrypt("AES/GCM/NoPadding", encrypted, password); + + assertEquals(plaintext, decrypted, "Decrypted text should match original plaintext"); + } + + @Test + @Order(20) + @DisplayName("Pass2Key should produce different ciphertext for same plaintext") + void pass2Key_shouldProduceDifferentCiphertext() throws Exception { + String plaintext = "Same plaintext"; + String password = "same-password"; + + String encrypted1 = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, password); + String encrypted2 = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, password); + + assertNotEquals(encrypted1, encrypted2, + "Same plaintext with same password should produce different ciphertext (due to random salt/IV)"); + } + + @Test + @Order(21) + @DisplayName("Pass2Key decrypt should fail with wrong password") + void pass2Key_wrongPassword_shouldFail() throws Exception { + String plaintext = "Secret content"; + String correctPassword = "correct-password"; + String wrongPassword = "wrong-password"; + + String encrypted = Pass2Key.encrypt("AES/GCM/NoPadding", plaintext, correctPassword); + + assertThrows(Exception.class, () -> + Pass2Key.decrypt("AES/GCM/NoPadding", encrypted, wrongPassword), + "Decryption with wrong password should throw exception" + ); + } + + @Test + @Order(22) + @DisplayName("Pass2Key convertStringToPrivateKey should parse PEM correctly") + void convertStringToPrivateKey_shouldParsePEM() throws Exception { + SecureCompletionClient tempClient = new SecureCompletionClient(); + tempClient.generateKeys(false); + PrivateKey originalKey = tempClient.getPrivateKey(); + + String pem = ai.nomyo.util.PEMConverter.toPEM(originalKey.getEncoded(), true); + PrivateKey parsedKey = Pass2Key.convertStringToPrivateKey(pem); + + assertNotNull(parsedKey, "Parsed private key should not be null"); + assertEquals("RSA", parsedKey.getAlgorithm(), "Parsed key should be RSA"); + assertArrayEquals(originalKey.getEncoded(), parsedKey.getEncoded(), + "Parsed key should match original key bytes"); + } + + @Test + @Order(23) + @DisplayName("Pass2Key convertStringToPrivateKey should handle PEM with whitespace") + void convertStringToPrivateKey_shouldHandleWhitespace() throws Exception { + SecureCompletionClient tempClient = new SecureCompletionClient(); + tempClient.generateKeys(false); + PrivateKey originalKey = tempClient.getPrivateKey(); + + String pem = "-----BEGIN PRIVATE KEY-----\n " + + originalKey.getEncoded().length + "lines\n" + + "-----END PRIVATE KEY-----"; + + String formattedPem = ai.nomyo.util.PEMConverter.toPEM(originalKey.getEncoded(), true); + String pemWithWhitespace = formattedPem.replace("\n", "\n "); + + PrivateKey parsedKey = Pass2Key.convertStringToPrivateKey(pemWithWhitespace); + + assertNotNull(parsedKey, "Parsed private key should not be null even with whitespace"); + assertArrayEquals(originalKey.getEncoded(), parsedKey.getEncoded(), + "Parsed key with whitespace should match original key bytes"); + } +} diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..8a8f81e --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1,2 @@ +junit.jupiter.execution.parallel.enabled = true +junit.jupiter.execution.parallel.config.dynamic.factor = 1 \ No newline at end of file