Add tests
This commit is contained in:
parent
8acf584d28
commit
21b4169130
7 changed files with 879 additions and 18 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -36,4 +36,5 @@ build/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
client_keys
|
||||||
20
pom.xml
20
pom.xml
|
|
@ -19,18 +19,32 @@
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.44</version>
|
<version>1.18.44</version>
|
||||||
<scope>compile</scope>
|
<scope>provided</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter-engine</artifactId>
|
<artifactId>junit-jupiter</artifactId>
|
||||||
<version>6.0.3</version>
|
<version>5.12.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.44</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,6 @@ import java.util.Set;
|
||||||
*/
|
*/
|
||||||
public final class Constants {
|
public final class Constants {
|
||||||
|
|
||||||
private Constants() {
|
|
||||||
// Utility class — prevents instantiation
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Protocol Constants ──────────────────────────────────────────
|
// ── Protocol Constants ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ import java.nio.file.attribute.PosixFilePermissions;
|
||||||
import java.security.*;
|
import java.security.*;
|
||||||
import java.security.spec.PKCS8EncodedKeySpec;
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
import java.security.spec.RSAKeyGenParameterSpec;
|
import java.security.spec.RSAKeyGenParameterSpec;
|
||||||
|
import java.io.File;
|
||||||
import java.io.UnsupportedEncodingException;
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||||
|
import java.security.spec.RSAPrivateKeySpec;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Scanner;
|
import java.util.Scanner;
|
||||||
|
|
@ -238,22 +241,31 @@ public class SecureCompletionClient {
|
||||||
* @param password optional password for the encrypted private key
|
* @param password optional password for the encrypted private key
|
||||||
*/
|
*/
|
||||||
public void loadKeys(String privateKeyPath, String publicPemKeyPath, String password) {
|
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()) {
|
if (password != null && !password.isEmpty()) {
|
||||||
String cipherText = getEncryptedPrivateKeyFromFile(privateKeyPath);
|
keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cipherText = Pass2Key.decrypt("AES/GCM/NoPadding", cipherText, password);
|
keyContent = Pass2Key.decrypt("AES/GCM/NoPadding", keyContent, password);
|
||||||
} catch (NoSuchPaddingException | NoSuchAlgorithmException
|
} catch (NoSuchPaddingException | NoSuchAlgorithmException
|
||||||
| BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException |
|
| BadPaddingException | IllegalBlockSizeException | InvalidAlgorithmParameterException |
|
||||||
InvalidKeyException e) {
|
InvalidKeyException e) {
|
||||||
System.out.println("Wrong password!");
|
System.out.println("Wrong password!");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
keyContent = getEncryptedPrivateKeyFromFile(privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.privateKey = Pass2Key.convertStringToPrivateKey(cipherText);
|
this.privateKey = Pass2Key.convertStringToPrivateKey(keyContent);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException("Failed to load private key: " + e.getMessage(), e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -451,9 +463,7 @@ public class SecureCompletionClient {
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
throw new SecurityError("RSA key is null");
|
throw new SecurityError("RSA key is null");
|
||||||
}
|
}
|
||||||
int keySize = key.getEncoded() != null ? key.getEncoded().length * 8 : 0;
|
int keySize = extractKeySize(key);
|
||||||
|
|
||||||
System.out.println("Keysize: " + keySize);
|
|
||||||
|
|
||||||
if (keySize < Constants.MIN_RSA_KEY_SIZE) {
|
if (keySize < Constants.MIN_RSA_KEY_SIZE) {
|
||||||
throw new SecurityError(
|
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 ─────────────────────────────
|
// ── HTTP Status → Exception Mapping ─────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
381
src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java
Normal file
381
src/test/java/ai/nomyo/SecureCompletionClientE2ETest.java
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
436
src/test/java/ai/nomyo/SecureCompletionClientTest.java
Normal file
436
src/test/java/ai/nomyo/SecureCompletionClientTest.java
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/test/resources/junit-platform.properties
Normal file
2
src/test/resources/junit-platform.properties
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
junit.jupiter.execution.parallel.enabled = true
|
||||||
|
junit.jupiter.execution.parallel.config.dynamic.factor = 1
|
||||||
Loading…
Add table
Add a link
Reference in a new issue