diff --git a/.gitignore b/.gitignore index 0bdd89b..a57f5e4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,10 @@ build/ target/ # Dependencies -node_modules/ -venv/ -.venv/ -__pycache__/ -.mypy_cache/ -.pytest_cache/ -.gradle/ +lib/ +libs/ +dependency/ +dependencies/ # Logs and temp files *.log @@ -29,37 +26,16 @@ __pycache__/ # Editors .vscode/ .idea/ +*.iml +*.ipr +*.iws -# System files +# OS .DS_Store Thumbs.db -# Coverage +# Test coverage coverage/ htmlcov/ .coverage - -# Compressed files -*.zip -*.gz -*.tar -*.tgz -*.bz2 -*.xz -*.7z -*.rar -*.zst -*.lz4 -*.lzh -*.cab -*.arj -*.rpm -*.deb -*.Z -*.lz -*.lzo -*.tar.gz -*.tar.bz2 -*.tar.xz -*.tar.zst ``` \ No newline at end of file diff --git a/src/main/java/com/licify/core/AutoUpdateService.java b/src/main/java/com/licify/core/AutoUpdateService.java new file mode 100644 index 0000000..6e05203 --- /dev/null +++ b/src/main/java/com/licify/core/AutoUpdateService.java @@ -0,0 +1,359 @@ +package com.licify.core; + +import java.io.*; +import java.net.*; +import java.nio.file.*; +import java.security.MessageDigest; +import java.util.function.Supplier; + +/** + * Automatic update service with secure version checking and download. + * Supports version comparison, integrity validation, and callback notifications. + */ +public class AutoUpdateService { + + private static final String DEFAULT_UPDATE_DIR = "updates"; + private final String currentVersion; + private final String updateServerUrl; + private final Path updateDirectory; + private final MessageDigest digest; + + /** + * Update information returned by the server. + */ + public static class UpdateInfo { + private final String version; + private final String downloadUrl; + private final String checksum; + private final long fileSize; + private final String releaseNotes; + private final boolean critical; + + public UpdateInfo(String version, String downloadUrl, String checksum, + long fileSize, String releaseNotes, boolean critical) { + this.version = version; + this.downloadUrl = downloadUrl; + this.checksum = checksum; + this.fileSize = fileSize; + this.releaseNotes = releaseNotes; + this.critical = critical; + } + + public String getVersion() { return version; } + public String getDownloadUrl() { return downloadUrl; } + public String getChecksum() { return checksum; } + public long getFileSize() { return fileSize; } + public String getReleaseNotes() { return releaseNotes; } + public boolean isCritical() { return critical; } + } + + /** + * Progress listener for download operations. + */ + public interface ProgressListener { + void onDownloadStarted(); + void onProgress(long bytesDownloaded, long totalBytes, int percentage); + void onComplete(Path downloadedFile); + void onError(String error); + } + + /** + * Update callback for notification events. + */ + public interface UpdateCallback { + void onUpdateAvailable(UpdateInfo info); + void onDownloadStarted(); + void onDownloadComplete(Path file); + void onInstallationStarted(); + void onInstallationComplete(boolean success); + void onError(String error); + } + + public AutoUpdateService(String currentVersion, String updateServerUrl) { + this(currentVersion, updateServerUrl, Paths.get(DEFAULT_UPDATE_DIR)); + } + + public AutoUpdateService(String currentVersion, String updateServerUrl, Path updateDirectory) { + this.currentVersion = currentVersion; + this.updateServerUrl = updateServerUrl.endsWith("/") ? + updateServerUrl.substring(0, updateServerUrl.length() - 1) : updateServerUrl; + this.updateDirectory = updateDirectory; + + try { + this.digest = MessageDigest.getInstance("SHA-256"); + } catch (Exception e) { + throw new RuntimeException("Failed to initialize SHA-256 digest", e); + } + + // Ensure update directory exists + if (!Files.exists(updateDirectory)) { + try { + Files.createDirectories(updateDirectory); + } catch (IOException e) { + System.err.println("Failed to create update directory: " + e.getMessage()); + } + } + } + + /** + * Checks if a new version is available. + * @return UpdateInfo if update available, null otherwise + */ + public UpdateInfo checkForUpdates() { + try { + URL url = new URL(updateServerUrl + "/version.json"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(10000); + conn.setReadTimeout(10000); + + int responseCode = conn.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + System.err.println("Failed to check updates: HTTP " + responseCode); + return null; + } + + String response = readResponse(conn.getInputStream()); + UpdateInfo info = parseUpdateInfo(response); + + if (info != null && isNewerVersion(info.getVersion())) { + return info; + } + + return null; + } catch (IOException e) { + System.err.println("Error checking for updates: " + e.getMessage()); + return null; + } + } + + /** + * Downloads the update file with progress tracking. + * @param info Update information + * @param listener Progress listener + * @return Downloaded file path + */ + public Path downloadUpdate(UpdateInfo info, ProgressListener listener) { + try { + if (listener != null) { + listener.onDownloadStarted(); + } + + URL url = new URL(info.getDownloadUrl()); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(30000); + conn.setReadTimeout(30000); + + int responseCode = conn.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new IOException("Download failed: HTTP " + responseCode); + } + + long contentLength = conn.getContentLengthLong(); + Path tempFile = updateDirectory.resolve("update_" + info.getVersion() + ".tmp"); + + try (InputStream in = conn.getInputStream(); + OutputStream out = Files.newOutputStream(tempFile)) { + + byte[] buffer = new byte[8192]; + long totalBytes = 0; + int bytesRead; + + while ((bytesRead = in.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + + if (listener != null && contentLength > 0) { + int percentage = (int)((totalBytes * 100) / contentLength); + listener.onProgress(totalBytes, contentLength, percentage); + } + } + } + + // Rename to final file + Path finalFile = updateDirectory.resolve("update_" + info.getVersion() + ".jar"); + Files.move(tempFile, finalFile, StandardCopyOption.REPLACE_EXISTING); + + // Verify checksum + if (!verifyChecksum(finalFile, info.getChecksum())) { + Files.deleteIfExists(finalFile); + throw new IOException("Checksum verification failed"); + } + + if (listener != null) { + listener.onComplete(finalFile); + } + + return finalFile; + + } catch (IOException e) { + if (listener != null) { + listener.onError(e.getMessage()); + } + throw new RuntimeException("Download failed: " + e.getMessage(), e); + } + } + + /** + * Verifies file checksum. + * @param file File to verify + * @param expectedChecksum Expected SHA-256 checksum (hex) + * @return true if checksum matches + */ + public boolean verifyChecksum(Path file, String expectedChecksum) { + try { + byte[] fileBytes = Files.readAllBytes(file); + byte[] hash = digest.digest(fileBytes); + String actualChecksum = bytesToHex(hash); + return actualChecksum.equalsIgnoreCase(expectedChecksum); + } catch (IOException e) { + System.err.println("Failed to verify checksum: " + e.getMessage()); + return false; + } + } + + /** + * Installs the update by running the installer callback. + * @param updateFile Update file path + * @param installer Installer callback + * @return true if installation successful + */ + public boolean installUpdate(Path updateFile, Supplier installer) { + try { + if (!Files.exists(updateFile)) { + System.err.println("Update file not found: " + updateFile); + return false; + } + + return installer.get(); + + } catch (Exception e) { + System.err.println("Installation failed: " + e.getMessage()); + return false; + } + } + + /** + * Gets the current version. + * @return Current version string + */ + public String getCurrentVersion() { + return currentVersion; + } + + /** + * Compares two version strings. + * @param v1 First version + * @param v2 Second version + * @return negative if v1 < v2, 0 if equal, positive if v1 > v2 + */ + public static int compareVersions(String v1, String v2) { + String[] parts1 = v1.replaceAll("[^0-9.]", "").split("\\."); + String[] parts2 = v2.replaceAll("[^0-9.]", "").split("\\."); + + int maxLen = Math.max(parts1.length, parts2.length); + + for (int i = 0; i < maxLen; i++) { + int num1 = i < parts1.length ? Integer.parseInt(parts1[i]) : 0; + int num2 = i < parts2.length ? Integer.parseInt(parts2[i]) : 0; + + if (num1 != num2) { + return Integer.compare(num1, num2); + } + } + + return 0; + } + + /** + * Checks if provided version is newer than current. + * @param newVersion Version to check + * @return true if newVersion is newer + */ + private boolean isNewerVersion(String newVersion) { + return compareVersions(newVersion, currentVersion) > 0; + } + + /** + * Parses update info from JSON response. + * @param json JSON string + * @return UpdateInfo object + */ + private UpdateInfo parseUpdateInfo(String json) { + try { + // Simple JSON parsing (in production, use Jackson or Gson) + String version = extractJsonValue(json, "version"); + String downloadUrl = extractJsonValue(json, "downloadUrl"); + String checksum = extractJsonValue(json, "checksum"); + String releaseNotes = extractJsonValue(json, "releaseNotes"); + boolean critical = json.contains("\"critical\":true"); + + long fileSize = 0; + int fileSizeStart = json.indexOf("\"fileSize\":"); + if (fileSizeStart != -1) { + int valueStart = fileSizeStart + 11; + int valueEnd = json.indexOf(",", valueStart); + if (valueEnd == -1) valueEnd = json.indexOf("}", valueStart); + if (valueEnd != -1) { + fileSize = Long.parseLong(json.substring(valueStart, valueEnd).trim()); + } + } + + return new UpdateInfo(version, downloadUrl, checksum, fileSize, + releaseNotes, critical); + } catch (Exception e) { + System.err.println("Failed to parse update info: " + e.getMessage()); + return null; + } + } + + /** + * Extracts a string value from JSON. + * @param json JSON string + * @param key Key to extract + * @return Value string + */ + private String extractJsonValue(String json, String key) { + String searchKey = "\"" + key + "\":"; + int keyIndex = json.indexOf(searchKey); + if (keyIndex == -1) return ""; + + int valueStart = json.indexOf("\"", keyIndex + searchKey.length()) + 1; + if (valueStart == 0) return ""; + + int valueEnd = json.indexOf("\"", valueStart); + if (valueEnd == -1) return ""; + + return json.substring(valueStart, valueEnd); + } + + /** + * Reads response from input stream. + * @param stream Input stream + * @return Response string + */ + private String readResponse(InputStream stream) throws IOException { + StringBuilder response = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + } + return response.toString(); + } + + /** + * Converts byte array to hex string. + * @param bytes Byte array + * @return Hex string + */ + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/licify/core/FloatingLicenseManager.java b/src/main/java/com/licify/core/FloatingLicenseManager.java new file mode 100644 index 0000000..17cfc2f --- /dev/null +++ b/src/main/java/com/licify/core/FloatingLicenseManager.java @@ -0,0 +1,362 @@ +package com.licify.core; + +import java.io.*; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.locks.*; +import java.util.stream.Collectors; + +/** + * Manages floating (network) licenses with concurrent user limits. + * Provides session tracking, activation/deactivation, and automatic cleanup. + */ +public class FloatingLicenseManager { + + private static final String DEFAULT_SESSIONS_FILE = "floating-sessions.json"; + private final ConcurrentMap activeSessions; + private final ReadWriteLock lock; + private final Path sessionsFilePath; + private final ScheduledExecutorService cleanupScheduler; + private volatile boolean schedulerRunning; + + /** + * Represents an active license session. + */ + public static class SessionInfo { + private final String sessionId; + private final String licenseId; + private final String clientId; + private final String clientHost; + private final LocalDateTime activatedAt; + private LocalDateTime lastHeartbeat; + private final int maxUsers; + + public SessionInfo(String sessionId, String licenseId, String clientId, + String clientHost, int maxUsers) { + this.sessionId = sessionId; + this.licenseId = licenseId; + this.clientId = clientId; + this.clientHost = clientHost; + this.activatedAt = LocalDateTime.now(); + this.lastHeartbeat = LocalDateTime.now(); + this.maxUsers = maxUsers; + } + + public void updateHeartbeat() { + this.lastHeartbeat = LocalDateTime.now(); + } + + public String getSessionId() { return sessionId; } + public String getLicenseId() { return licenseId; } + public String getClientId() { return clientId; } + public String getClientHost() { return clientHost; } + public LocalDateTime getActivatedAt() { return activatedAt; } + public LocalDateTime getLastHeartbeat() { return lastHeartbeat; } + public int getMaxUsers() { return maxUsers; } + + public long getIdleTimeMinutes() { + return java.time.Duration.between(lastHeartbeat, LocalDateTime.now()).toMinutes(); + } + } + + /** + * Result of a license activation attempt. + */ + public static class ActivationResult { + private final boolean success; + private final String sessionId; + private final String message; + private final int currentUsers; + private final int maxUsers; + + public ActivationResult(boolean success, String sessionId, String message, + int currentUsers, int maxUsers) { + this.success = success; + this.sessionId = sessionId; + this.message = message; + this.currentUsers = currentUsers; + this.maxUsers = maxUsers; + } + + public boolean isSuccess() { return success; } + public String getSessionId() { return sessionId; } + public String getMessage() { return message; } + public int getCurrentUsers() { return currentUsers; } + public int getMaxUsers() { return maxUsers; } + public int getAvailableSlots() { return Math.max(0, maxUsers - currentUsers); } + } + + public FloatingLicenseManager() { + this(Paths.get(DEFAULT_SESSIONS_FILE)); + } + + public FloatingLicenseManager(Path sessionsFilePath) { + this.sessionsFilePath = sessionsFilePath; + this.activeSessions = new ConcurrentHashMap<>(); + this.lock = new ReentrantReadWriteLock(); + this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "FloatingLicense-Cleanup"); + t.setDaemon(true); + return t; + }); + this.schedulerRunning = false; + loadSessions(); + } + + /** + * Starts the automatic cleanup scheduler. + * @param heartbeatTimeoutMinutes Sessions without heartbeat for this duration are removed + */ + public void startCleanupScheduler(int heartbeatTimeoutMinutes) { + if (schedulerRunning) { + return; + } + + schedulerRunning = true; + cleanupScheduler.scheduleAtFixedRate(() -> { + cleanupExpiredSessions(heartbeatTimeoutMinutes); + }, heartbeatTimeoutMinutes, heartbeatTimeoutMinutes, TimeUnit.MINUTES); + } + + /** + * Stops the cleanup scheduler. + */ + public void stopCleanupScheduler() { + schedulerRunning = false; + cleanupScheduler.shutdown(); + try { + if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) { + cleanupScheduler.shutdownNow(); + } + } catch (InterruptedException e) { + cleanupScheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + /** + * Activates a floating license session. + * @param licenseId Unique license identifier + * @param clientId Client identifier requesting the license + * @param clientHost Client hostname or IP + * @param maxUsers Maximum concurrent users allowed + * @return ActivationResult with success status and session info + */ + public ActivationResult activateSession(String licenseId, String clientId, + String clientHost, int maxUsers) { + lock.readLock().lock(); + try { + // Count active sessions for this license + long currentUsers = activeSessions.values().stream() + .filter(s -> s.getLicenseId().equals(licenseId)) + .count(); + + if (currentUsers >= maxUsers) { + return new ActivationResult(false, null, + "License limit reached: " + currentUsers + "/" + maxUsers + " users active", + (int)currentUsers, maxUsers); + } + + // Create new session + String sessionId = UUID.randomUUID().toString(); + SessionInfo session = new SessionInfo(sessionId, licenseId, clientId, + clientHost, maxUsers); + activeSessions.put(sessionId, session); + saveSessions(); + + return new ActivationResult(true, sessionId, "Session activated successfully", + (int)currentUsers + 1, maxUsers); + } finally { + lock.readLock().unlock(); + } + } + + /** + * Deactivates a license session. + * @param sessionId Session identifier to deactivate + * @return true if session was found and removed, false otherwise + */ + public boolean deactivateSession(String sessionId) { + lock.writeLock().lock(); + try { + SessionInfo removed = activeSessions.remove(sessionId); + if (removed != null) { + saveSessions(); + return true; + } + return false; + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Updates the heartbeat for a session. + * @param sessionId Session identifier + * @return true if session exists and heartbeat was updated + */ + public boolean updateHeartbeat(String sessionId) { + SessionInfo session = activeSessions.get(sessionId); + if (session != null) { + session.updateHeartbeat(); + return true; + } + return false; + } + + /** + * Gets all active sessions for a license. + * @param licenseId License identifier + * @return List of active session info + */ + public List getActiveSessions(String licenseId) { + lock.readLock().lock(); + try { + return activeSessions.values().stream() + .filter(s -> s.getLicenseId().equals(licenseId)) + .collect(Collectors.toList()); + } finally { + lock.readLock().unlock(); + } + } + + /** + * Gets all active sessions across all licenses. + * @return Collection of all active sessions + */ + public Collection getAllActiveSessions() { + return Collections.unmodifiableCollection(activeSessions.values()); + } + + /** + * Gets the count of active sessions for a license. + * @param licenseId License identifier + * @return Number of active sessions + */ + public int getActiveSessionCount(String licenseId) { + return (int) activeSessions.values().stream() + .filter(s -> s.getLicenseId().equals(licenseId)) + .count(); + } + + /** + * Gets available slots for a license. + * @param licenseId License identifier + * @param maxUsers Maximum allowed users + * @return Number of available slots + */ + public int getAvailableSlots(String licenseId, int maxUsers) { + int activeCount = getActiveSessionCount(licenseId); + return Math.max(0, maxUsers - activeCount); + } + + /** + * Cleans up expired sessions based on heartbeat timeout. + * @param heartbeatTimeoutMinutes Timeout in minutes + * @return Number of sessions removed + */ + public int cleanupExpiredSessions(int heartbeatTimeoutMinutes) { + lock.writeLock().lock(); + try { + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(heartbeatTimeoutMinutes); + List expiredSessions = activeSessions.values().stream() + .filter(s -> s.getLastHeartbeat().isBefore(cutoff)) + .map(SessionInfo::getSessionId) + .collect(Collectors.toList()); + + for (String sessionId : expiredSessions) { + activeSessions.remove(sessionId); + } + + if (!expiredSessions.isEmpty()) { + saveSessions(); + } + + return expiredSessions.size(); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Clears all active sessions. + */ + public void clearAllSessions() { + lock.writeLock().lock(); + try { + activeSessions.clear(); + saveSessions(); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Saves sessions to JSON file. + */ + private void saveSessions() { + try { + StringBuilder json = new StringBuilder("[\n"); + Iterator iterator = activeSessions.values().iterator(); + while (iterator.hasNext()) { + SessionInfo session = iterator.next(); + json.append(" {\n"); + json.append(" \"sessionId\": \"").append(session.getSessionId()).append("\",\n"); + json.append(" \"licenseId\": \"").append(session.getLicenseId()).append("\",\n"); + json.append(" \"clientId\": \"").append(session.getClientId()).append("\",\n"); + json.append(" \"clientHost\": \"").append(session.getClientHost()).append("\",\n"); + json.append(" \"activatedAt\": \"").append(session.getActivatedAt()).append("\",\n"); + json.append(" \"lastHeartbeat\": \"").append(session.getLastHeartbeat()).append("\",\n"); + json.append(" \"maxUsers\": ").append(session.getMaxUsers()).append("\n"); + json.append(" }"); + if (iterator.hasNext()) { + json.append(","); + } + json.append("\n"); + } + json.append("]"); + + Files.writeString(sessionsFilePath, json.toString()); + } catch (IOException e) { + System.err.println("Failed to save sessions: " + e.getMessage()); + } + } + + /** + * Loads sessions from JSON file. + */ + private void loadSessions() { + if (!Files.exists(sessionsFilePath)) { + return; + } + + try { + String content = Files.readString(sessionsFilePath); + // Simple JSON parsing (in production, use Jackson or Gson) + activeSessions.clear(); + + // Extract session objects using regex + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( + "\\{[^}]*\"sessionId\"\\s*:\\s*\"([^\"]+)\"[^}]*\"licenseId\"\\s*:\\s*\"([^\"]+)\"[^}]*\"clientId\"\\s*:\\s*\"([^\"]+)\"[^}]*\"clientHost\"\\s*:\\s*\"([^\"]+)\"[^}]*\"maxUsers\"\\s*:\\s*(\\d+)[^}]*\\}", + java.util.regex.Pattern.DOTALL + ); + + java.util.regex.Matcher matcher = pattern.matcher(content); + while (matcher.find()) { + String sessionId = matcher.group(1); + String licenseId = matcher.group(2); + String clientId = matcher.group(3); + String clientHost = matcher.group(4); + int maxUsers = Integer.parseInt(matcher.group(5)); + + SessionInfo session = new SessionInfo(sessionId, licenseId, clientId, + clientHost, maxUsers); + activeSessions.put(sessionId, session); + } + } catch (IOException e) { + System.err.println("Failed to load sessions: " + e.getMessage()); + } + } +} diff --git a/src/test/java/com/licify/AutoUpdateServiceTest.java b/src/test/java/com/licify/AutoUpdateServiceTest.java new file mode 100644 index 0000000..8ba8dbb --- /dev/null +++ b/src/test/java/com/licify/AutoUpdateServiceTest.java @@ -0,0 +1,172 @@ +package com.licify; + +import com.licify.core.AutoUpdateService; +import com.licify.core.AutoUpdateService.UpdateInfo; +import com.licify.core.AutoUpdateService.ProgressListener; +import org.junit.jupiter.api.*; +import java.nio.file.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for AutoUpdateService. + */ +public class AutoUpdateServiceTest { + + private AutoUpdateService updateService; + private Path testUpdateDir; + + @BeforeEach + public void setUp() throws Exception { + testUpdateDir = Files.createTempDirectory("updates-test"); + updateService = new AutoUpdateService("1.0.0", "https://example.com/api", testUpdateDir); + } + + @AfterEach + public void tearDown() throws Exception { + if (testUpdateDir != null && Files.exists(testUpdateDir)) { + deleteDirectory(testUpdateDir); + } + } + + @Test + @DisplayName("Should initialize with correct version") + public void testGetCurrentVersion() { + assertEquals("1.0.0", updateService.getCurrentVersion()); + } + + @Test + @DisplayName("Should compare versions correctly") + public void testCompareVersions() { + assertEquals(0, AutoUpdateService.compareVersions("1.0.0", "1.0.0")); + assertTrue(AutoUpdateService.compareVersions("2.0.0", "1.0.0") > 0); + assertTrue(AutoUpdateService.compareVersions("1.0.0", "2.0.0") < 0); + assertTrue(AutoUpdateService.compareVersions("1.2.0", "1.0.0") > 0); + assertTrue(AutoUpdateService.compareVersions("1.0.1", "1.0.0") > 0); + assertTrue(AutoUpdateService.compareVersions("1.10.0", "1.9.0") > 0); + } + + @Test + @DisplayName("Should handle version strings with prefixes") + public void testCompareVersionsWithPrefixes() { + assertEquals(0, AutoUpdateService.compareVersions("v1.0.0", "1.0.0")); + assertTrue(AutoUpdateService.compareVersions("v2.0.0", "v1.0.0") > 0); + assertTrue(AutoUpdateService.compareVersions("release-1.0.0", "1.0.0") == 0); + } + + @Test + @DisplayName("Should create update directory if not exists") + public void testUpdateDirectoryCreation() throws Exception { + Path newDir = Files.createTempDirectory("updates-new").resolve("subdir"); + Files.deleteIfExists(newDir); + + AutoUpdateService service = new AutoUpdateService("1.0.0", + "https://example.com/api", + newDir); + + assertTrue(Files.exists(newDir)); + + // Cleanup + deleteDirectory(newDir.getParent()); + } + + @Test + @DisplayName("Should verify checksum correctly") + public void testVerifyChecksum() throws Exception { + // Create a test file + Path testFile = testUpdateDir.resolve("test.jar"); + String content = "test content"; + Files.writeString(testFile, content); + + // Calculate expected checksum manually (SHA-256 of "test content") + String expectedChecksum = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"; + + boolean valid = updateService.verifyChecksum(testFile, expectedChecksum); + assertTrue(valid); + } + + @Test + @DisplayName("Should return false for invalid checksum") + public void testVerifyChecksum_Invalid() throws Exception { + Path testFile = testUpdateDir.resolve("test.jar"); + Files.writeString(testFile, "test content"); + + boolean valid = updateService.verifyChecksum(testFile, "invalid-checksum"); + assertFalse(valid); + } + + @Test + @DisplayName("Should handle non-existent file in checksum verification") + public void testVerifyChecksum_FileNotFound() { + Path nonExistentFile = testUpdateDir.resolve("nonexistent.jar"); + boolean valid = updateService.verifyChecksum(nonExistentFile, "any-checksum"); + assertFalse(valid); + } + + @Test + @DisplayName("Should detect newer version") + public void testIsNewerVersion() { + AutoUpdateService service = new AutoUpdateService("1.0.0", + "https://example.com/api"); + // Test indirectly through compareVersions + assertTrue(AutoUpdateService.compareVersions("2.0.0", "1.0.0") > 0); + } + + @Test + @DisplayName("Should handle equal versions") + public void testEqualVersions() { + assertEquals(0, AutoUpdateService.compareVersions("1.0.0", "1.0.0")); + assertEquals(0, AutoUpdateService.compareVersions("2.5.3", "2.5.3")); + } + + @Test + @DisplayName("Should handle multi-digit version numbers") + public void testMultiDigitVersions() { + assertTrue(AutoUpdateService.compareVersions("10.0.0", "9.0.0") > 0); + assertTrue(AutoUpdateService.compareVersions("1.10.0", "1.9.0") > 0); + assertTrue(AutoUpdateService.compareVersions("1.0.10", "1.0.9") > 0); + } + + @Test + @DisplayName("Should handle versions with different segment counts") + public void testDifferentSegmentCounts() { + assertTrue(AutoUpdateService.compareVersions("2.0", "1.9.9") > 0); + assertTrue(AutoUpdateService.compareVersions("1.0.0.1", "1.0.0") > 0); + assertEquals(0, AutoUpdateService.compareVersions("1.0", "1.0.0")); + } + + @Test + @DisplayName("Should create service with default update directory") + public void testDefaultConstructor() { + AutoUpdateService service = new AutoUpdateService("1.0.0", + "https://example.com/api"); + assertNotNull(service); + assertEquals("1.0.0", service.getCurrentVersion()); + } + + @Test + @DisplayName("Should handle URL with trailing slash") + public void testUrlWithTrailingSlash() { + AutoUpdateService service1 = new AutoUpdateService("1.0.0", + "https://example.com/api/"); + AutoUpdateService service2 = new AutoUpdateService("1.0.0", + "https://example.com/api"); + + assertNotNull(service1); + assertNotNull(service2); + } + + private void deleteDirectory(Path dir) throws Exception { + if (!Files.exists(dir)) return; + + Files.walk(dir) + .sorted((a, b) -> b.compareTo(a)) + .forEach(path -> { + try { + Files.delete(path); + } catch (Exception e) { + // Ignore + } + }); + } +} diff --git a/src/test/java/com/licify/FloatingLicenseManagerTest.java b/src/test/java/com/licify/FloatingLicenseManagerTest.java new file mode 100644 index 0000000..da9a5b2 --- /dev/null +++ b/src/test/java/com/licify/FloatingLicenseManagerTest.java @@ -0,0 +1,194 @@ +package com.licify; + +import com.licify.core.FloatingLicenseManager; +import com.licify.core.FloatingLicenseManager.ActivationResult; +import com.licify.core.FloatingLicenseManager.SessionInfo; +import org.junit.jupiter.api.*; +import java.nio.file.*; +import java.util.List; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for FloatingLicenseManager. + */ +public class FloatingLicenseManagerTest { + + private FloatingLicenseManager manager; + private Path testSessionsFile; + + @BeforeEach + public void setUp() throws Exception { + testSessionsFile = Files.createTempFile("floating-sessions", ".json"); + manager = new FloatingLicenseManager(testSessionsFile); + } + + @AfterEach + public void tearDown() throws Exception { + if (manager != null) { + manager.stopCleanupScheduler(); + } + if (testSessionsFile != null && Files.exists(testSessionsFile)) { + Files.deleteIfExists(testSessionsFile); + } + } + + @Test + @DisplayName("Should activate session when under limit") + public void testActivateSession_Success() { + ActivationResult result = manager.activateSession("LICENSE-001", "client-1", + "192.168.1.100", 5); + + assertTrue(result.isSuccess()); + assertNotNull(result.getSessionId()); + assertEquals("Session activated successfully", result.getMessage()); + assertEquals(1, result.getCurrentUsers()); + assertEquals(5, result.getMaxUsers()); + assertEquals(4, result.getAvailableSlots()); + } + + @Test + @DisplayName("Should reject activation when limit reached") + public void testActivateSession_LimitReached() { + // Activate 3 sessions + manager.activateSession("LICENSE-001", "client-1", "192.168.1.100", 3); + manager.activateSession("LICENSE-001", "client-2", "192.168.1.101", 3); + manager.activateSession("LICENSE-001", "client-3", "192.168.1.102", 3); + + // Try to activate 4th session + ActivationResult result = manager.activateSession("LICENSE-001", "client-4", + "192.168.1.103", 3); + + assertFalse(result.isSuccess()); + assertNull(result.getSessionId()); + assertTrue(result.getMessage().contains("License limit reached")); + assertEquals(3, result.getCurrentUsers()); + assertEquals(3, result.getMaxUsers()); + assertEquals(0, result.getAvailableSlots()); + } + + @Test + @DisplayName("Should deactivate session successfully") + public void testDeactivateSession_Success() { + ActivationResult activateResult = manager.activateSession("LICENSE-001", "client-1", + "192.168.1.100", 5); + String sessionId = activateResult.getSessionId(); + + boolean deactivated = manager.deactivateSession(sessionId); + + assertTrue(deactivated); + assertEquals(0, manager.getActiveSessionCount("LICENSE-001")); + } + + @Test + @DisplayName("Should return false when deactivating non-existent session") + public void testDeactivateSession_NotFound() { + boolean deactivated = manager.deactivateSession("non-existent-session"); + assertFalse(deactivated); + } + + @Test + @DisplayName("Should update heartbeat successfully") + public void testUpdateHeartbeat_Success() throws InterruptedException { + ActivationResult activateResult = manager.activateSession("LICENSE-001", "client-1", + "192.168.1.100", 5); + String sessionId = activateResult.getSessionId(); + + Thread.sleep(10); // Small delay + + boolean updated = manager.updateHeartbeat(sessionId); + + assertTrue(updated); + + List sessions = manager.getActiveSessions("LICENSE-001"); + assertEquals(1, sessions.size()); + assertTrue(sessions.get(0).getLastHeartbeat().isAfter(sessions.get(0).getActivatedAt())); + } + + @Test + @DisplayName("Should return false when updating heartbeat for non-existent session") + public void testUpdateHeartbeat_NotFound() { + boolean updated = manager.updateHeartbeat("non-existent-session"); + assertFalse(updated); + } + + @Test + @DisplayName("Should get active sessions for license") + public void testGetActiveSessions() { + manager.activateSession("LICENSE-001", "client-1", "192.168.1.100", 5); + manager.activateSession("LICENSE-001", "client-2", "192.168.1.101", 5); + manager.activateSession("LICENSE-002", "client-3", "192.168.1.102", 5); + + List sessions1 = manager.getActiveSessions("LICENSE-001"); + List sessions2 = manager.getActiveSessions("LICENSE-002"); + + assertEquals(2, sessions1.size()); + assertEquals(1, sessions2.size()); + } + + @Test + @DisplayName("Should get all active sessions") + public void testGetAllActiveSessions() { + manager.activateSession("LICENSE-001", "client-1", "192.168.1.100", 5); + manager.activateSession("LICENSE-002", "client-2", "192.168.1.101", 5); + + Collection allSessions = manager.getAllActiveSessions(); + + assertEquals(2, allSessions.size()); + } + + @Test + @DisplayName("Should cleanup expired sessions") + public void testCleanupExpiredSessions() throws InterruptedException { + manager.activateSession("LICENSE-001", "client-1", "192.168.1.100", 5); + manager.activateSession("LICENSE-001", "client-2", "192.168.1.101", 5); + + // Wait and manually cleanup with 0 minutes timeout + Thread.sleep(10); + + int cleaned = manager.cleanupExpiredSessions(0); + + assertEquals(2, cleaned); + assertEquals(0, manager.getActiveSessionCount("LICENSE-001")); + } + + @Test + @DisplayName("Should clear all sessions") + public void testClearAllSessions() { + manager.activateSession("LICENSE-001", "client-1", "192.168.1.100", 5); + manager.activateSession("LICENSE-002", "client-2", "192.168.1.101", 5); + + manager.clearAllSessions(); + + assertEquals(0, manager.getAllActiveSessions().size()); + } + + @Test + @DisplayName("Should persist and load sessions") + public void testSessionPersistence() { + manager.activateSession("LICENSE-001", "client-1", "192.168.1.100", 5); + manager.activateSession("LICENSE-002", "client-2", "192.168.1.101", 3); + + // Create new manager with same file + FloatingLicenseManager manager2 = new FloatingLicenseManager(testSessionsFile); + + assertEquals(2, manager2.getAllActiveSessions().size()); + assertEquals(1, manager2.getActiveSessionCount("LICENSE-001")); + assertEquals(1, manager2.getActiveSessionCount("LICENSE-002")); + } + + @Test + @DisplayName("Should handle multiple licenses independently") + public void testMultipleLicenses() { + ActivationResult r1 = manager.activateSession("LICENSE-A", "client-1", "192.168.1.100", 2); + ActivationResult r2 = manager.activateSession("LICENSE-B", "client-2", "192.168.1.101", 2); + + assertTrue(r1.isSuccess()); + assertTrue(r2.isSuccess()); + + assertEquals(1, manager.getActiveSessionCount("LICENSE-A")); + assertEquals(1, manager.getActiveSessionCount("LICENSE-B")); + assertEquals(2, manager.getAllActiveSessions().size()); + } +}