add ssl certificates and tests

This commit is contained in:
Krrish Ghimire
2026-07-06 14:49:44 +05:45
parent a498d55e8c
commit a6a9ac6e7e
23 changed files with 2041 additions and 40 deletions

View File

@@ -26,5 +26,19 @@ public class SchemaMigration {
} catch (Exception e) {
log.warn("Could not drop encrypted_ssh_key column: {}", e.getMessage());
}
try {
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_enabled BOOLEAN DEFAULT FALSE");
jdbcTemplate.execute("UPDATE self_hosted_configs SET ssl_enabled = FALSE WHERE ssl_enabled IS NULL");
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ALTER COLUMN ssl_enabled SET NOT NULL");
log.info("Added column ssl_enabled to self_hosted_configs");
} catch (Exception e) {
log.warn("Could not add ssl_enabled column: {}", e.getMessage());
}
try {
jdbcTemplate.execute("ALTER TABLE self_hosted_configs ADD COLUMN IF NOT EXISTS ssl_email VARCHAR(255)");
log.info("Added column ssl_email to self_hosted_configs");
} catch (Exception e) {
log.warn("Could not add ssl_email column: {}", e.getMessage());
}
}
}

View File

@@ -59,8 +59,10 @@ public class SelfHostedController {
@Valid @RequestBody SelfHostedConfigRequest request) {
Site site = findSiteForUser(id, principal.id());
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing ->
selfHostedConfigRepository.delete(existing));
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> {
selfHostedConfigRepository.delete(existing);
selfHostedConfigRepository.flush();
});
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
? request.getDeployPath()
@@ -78,6 +80,8 @@ public class SelfHostedController {
.sshUser(request.getSshUser())
.deployPath(deployPath)
.nginxSitesPath(nginxSitesPath)
.sslEnabled(request.isSslEnabled())
.sslEmail(request.getSslEmail())
.build();
config = selfHostedConfigRepository.save(config);

View File

@@ -20,4 +20,8 @@ public class SelfHostedConfigRequest {
private String deployPath;
private String nginxSitesPath;
private boolean sslEnabled;
private String sslEmail;
}

View File

@@ -21,6 +21,8 @@ public class SelfHostedConfigResponse {
private String sshUser;
private String deployPath;
private String nginxSitesPath;
private boolean sslEnabled;
private String sslEmail;
private String publicKey;
private LocalDateTime deployedAt;
private LocalDateTime createdAt;
@@ -36,6 +38,8 @@ public class SelfHostedConfigResponse {
.sshUser(config.getSshUser())
.deployPath(config.getDeployPath())
.nginxSitesPath(config.getNginxSitesPath())
.sslEnabled(config.isSslEnabled())
.sslEmail(config.getSslEmail())
.publicKey(publicKey)
.deployedAt(config.getDeployedAt())
.createdAt(config.getCreatedAt())

View File

@@ -18,29 +18,30 @@ import java.util.UUID;
public class GitRemote {
@Id
@Column(name = "id")
private UUID id;
@Column(nullable = false)
@Column(name = "site_id", nullable = false)
private UUID siteId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(name = "provider", nullable = false)
private GitProvider provider;
@Column(nullable = false)
@Column(name = "remote_url", nullable = false)
private String remoteUrl;
@Column(nullable = false)
@Column(name = "encrypted_token", nullable = false)
private String encryptedToken;
@Column(nullable = false)
@Column(name = "default_branch", nullable = false)
@Builder.Default
private String defaultBranch = "main";
@Column(nullable = false, updatable = false)
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist

View File

@@ -15,38 +15,47 @@ import java.util.UUID;
public class SelfHostedConfig {
@Id
@Column(name = "id")
private UUID id;
@Column(nullable = false, unique = true)
@Column(name = "site_id", nullable = false, unique = true)
private UUID siteId;
@Column(nullable = false)
@Column(name = "domain", nullable = false)
private String domain;
@Column(nullable = false)
@Column(name = "ssh_host", nullable = false)
private String sshHost;
@Column(nullable = false)
@Column(name = "ssh_port", nullable = false)
@Builder.Default
private int sshPort = 22;
@Column(nullable = false)
@Column(name = "ssh_user", nullable = false)
private String sshUser;
@Column(nullable = false)
@Column(name = "deploy_path", nullable = false)
@Builder.Default
private String deployPath = "/var/www/{domain}/public";
@Column(nullable = false)
@Column(name = "nginx_sites_path", nullable = false)
@Builder.Default
private String nginxSitesPath = "/etc/nginx/sites-available";
@Column(name = "ssl_enabled")
@Builder.Default
private boolean sslEnabled = false;
@Column(name = "ssl_email")
private String sslEmail;
@Column(name = "deployed_at")
private LocalDateTime deployedAt;
@Column(nullable = false, updatable = false)
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist

View File

@@ -18,43 +18,47 @@ import java.util.UUID;
public class Site {
@Id
@Column(name = "id")
private UUID id;
@Column(nullable = false)
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(nullable = false)
@Column(name = "name", nullable = false)
private String name;
@Column(length = 1000)
@Column(name = "description", length = 1000)
private String description;
@Column(unique = true)
@Column(name = "subdomain", unique = true)
private String subdomain;
@Column(name = "published_url")
private String publishedUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(name = "status", nullable = false)
@Builder.Default
private SiteStatus status = SiteStatus.DRAFT;
@Enumerated(EnumType.STRING)
@Column(name = "deployment_target")
@Builder.Default
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
@Column(name = "published_at")
private LocalDateTime publishedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public DeploymentTarget getDeploymentTarget() {
return deploymentTarget != null ? deploymentTarget : DeploymentTarget.NONE;
}
private LocalDateTime publishedAt;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
public enum DeploymentTarget {
GIT_PAGES, SELF_HOSTED, NONE
}

View File

@@ -18,31 +18,36 @@ import java.util.UUID;
public class User {
@Id
@Column(name = "id")
private UUID id;
@Column(nullable = false, unique = true)
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password")
private String password;
@Column(nullable = false)
@Column(name = "name", nullable = false)
private String name;
@Column(name = "avatar_url")
private String avatarUrl;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(name = "provider", nullable = false)
private AuthProvider provider;
@Column(name = "provider_id")
private String providerId;
@Column(name = "email_verified")
@Builder.Default
private boolean emailVerified = false;
@Column(nullable = false, updatable = false)
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist

View File

@@ -38,7 +38,11 @@ public class SshRsyncDeployer implements Deployer {
rsync(tempDir.resolve("site"), keyFile, config, resolvedDeployPath);
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath);
if (config.isSslEnabled()) {
provisionSsl(keyFile, config, resolvedDeployPath);
}
String nginxConfig = buildNginxConfig(config.getDomain(), resolvedDeployPath, config.isSslEnabled());
Path nginxConfPath = tempDir.resolve(config.getDomain());
Files.writeString(nginxConfPath, nginxConfig);
@@ -73,6 +77,10 @@ public class SshRsyncDeployer implements Deployer {
+ " /etc/nginx/sites-enabled/" + config.getDomain()
+ " && sudo nginx -s reload");
if (config.isSslEnabled()) {
removeSsl(keyFile, config);
}
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
} finally {
deleteDirectory(tempDir);
@@ -81,7 +89,8 @@ public class SshRsyncDeployer implements Deployer {
@Override
public String buildUrl(SelfHostedConfig config) {
return "https://" + config.getDomain() + "/";
String scheme = config.isSslEnabled() ? "https" : "http";
return scheme + "://" + config.getDomain() + "/";
}
private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
@@ -122,7 +131,25 @@ public class SshRsyncDeployer implements Deployer {
execute(rsyncCmd);
}
private String buildNginxConfig(String domain, String rootPath) {
private String buildNginxConfig(String domain, String rootPath, boolean sslEnabled) {
if (sslEnabled) {
return "server {\n"
+ " listen 443 ssl http2;\n"
+ " server_name " + domain + ";\n"
+ " root " + rootPath + ";\n"
+ " index index.html;\n"
+ " ssl_certificate /etc/letsencrypt/live/" + domain + "/fullchain.pem;\n"
+ " ssl_certificate_key /etc/letsencrypt/live/" + domain + "/privkey.pem;\n"
+ " location / {\n"
+ " try_files $uri $uri/ /index.html;\n"
+ " }\n"
+ "}\n"
+ "server {\n"
+ " listen 80;\n"
+ " server_name " + domain + ";\n"
+ " return 301 https://$host$request_uri;\n"
+ "}\n";
}
return "server {\n"
+ " listen 80;\n"
+ " server_name " + domain + ";\n"
@@ -134,6 +161,61 @@ public class SshRsyncDeployer implements Deployer {
+ "}\n";
}
private void provisionSsl(String keyFile, SelfHostedConfig config, String deployPath) throws Exception {
String domain = config.getDomain();
String email = config.getSslEmail();
if (email == null || email.isBlank()) {
throw new RuntimeException("SSL email is required when SSL is enabled");
}
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
String certStatus = sshWithOutput(keyFile, config, checkCerts);
if (certStatus.contains("EXISTS")) {
log.info("SSL certificates already exist for {}", domain);
return;
}
log.info("Provisioning SSL certificate for {} via Let's Encrypt", domain);
String certbotCmd = String.format(
"sudo certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s",
deployPath, domain, email
);
ssh(keyFile, config, certbotCmd);
log.info("SSL certificate obtained for {}", domain);
}
private void removeSsl(String keyFile, SelfHostedConfig config) throws Exception {
String domain = config.getDomain();
String checkCerts = "sudo test -d /etc/letsencrypt/live/" + domain + " && echo EXISTS || echo MISSING";
String certStatus = sshWithOutput(keyFile, config, checkCerts);
if (!certStatus.contains("EXISTS")) {
return;
}
log.info("Removing SSL certificates for {}", domain);
ssh(keyFile, config, "sudo certbot delete --non-interactive -d " + domain);
}
private String sshWithOutput(String keyFile, SelfHostedConfig config, String command) throws Exception {
String sshCmd = String.format(
"ssh -i %s -p %d -o StrictHostKeyChecking=accept-new %s@%s \"%s\"",
keyFile, config.getSshPort(), config.getSshUser(), config.getSshHost(), command
);
ProcessBuilder pb = new ProcessBuilder("sh", "-c", sshCmd);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new RuntimeException("Command failed (exit " + exitCode + "): " + sshCmd + "\n" + output);
}
return output;
}
private void execute(String command) throws Exception {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);

View File

@@ -0,0 +1,278 @@
package com.krrishg.controller;
import com.krrishg.config.GlobalExceptionHandler;
import com.krrishg.config.JwtAuthenticationToken;
import com.krrishg.config.UserPrincipal;
import com.krrishg.dto.SelfHostedConfigRequest;
import com.krrishg.model.SelfHostedConfig;
import com.krrishg.model.Site;
import com.krrishg.repository.SelfHostedConfigRepository;
import com.krrishg.repository.SiteRepository;
import com.krrishg.service.Deployer;
import com.krrishg.service.KeyManager;
import com.krrishg.support.TestSecurityConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(SelfHostedController.class)
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
class SelfHostedControllerTest {
private final MockMvc mockMvc;
private final ObjectMapper objectMapper;
@MockBean
private SiteRepository siteRepository;
@MockBean
private SelfHostedConfigRepository selfHostedConfigRepository;
@MockBean
private Deployer deployer;
@MockBean
private KeyManager keyManager;
private UUID userId;
private UUID siteId;
private UserPrincipal principal;
private JwtAuthenticationToken auth;
SelfHostedControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
this.mockMvc = mockMvc;
this.objectMapper = objectMapper;
}
@BeforeEach
void setUp() {
siteId = UUID.randomUUID();
userId = UUID.randomUUID();
principal = new UserPrincipal(userId, "user@test.com", "Test User");
auth = new JwtAuthenticationToken(principal, "test-token");
}
void givenSaveReturnsInput() {
when(selfHostedConfigRepository.save(any())).thenAnswer(invocation -> {
SelfHostedConfig input = invocation.getArgument(0);
if (input.getId() == null) {
input.setId(UUID.randomUUID());
}
if (input.getCreatedAt() == null) {
input.setCreatedAt(LocalDateTime.now());
input.setUpdatedAt(LocalDateTime.now());
}
return input;
});
}
private Site givenSiteOwnedByUser() {
Site site = Site.builder()
.id(siteId)
.userId(userId)
.name("Test Site")
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
return site;
}
@Test
void getConfigReturnsConfigWithPublicKey() throws Exception {
givenSiteOwnedByUser();
SelfHostedConfig config = SelfHostedConfig.builder()
.id(UUID.randomUUID())
.siteId(siteId)
.domain("example.com")
.sshHost("192.168.1.1")
.sshPort(22)
.sshUser("deploy")
.deployPath("/var/www/example/public")
.nginxSitesPath("/etc/nginx/sites-available")
.sslEnabled(true)
.sslEmail("admin@example.com")
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.domain").value("example.com"))
.andExpect(jsonPath("$.sshHost").value("192.168.1.1"))
.andExpect(jsonPath("$.publicKey").value("ssh-ed25519 AAA..."))
.andExpect(jsonPath("$.sslEnabled").value(true));
}
@Test
void getConfigReturnsNotConfiguredWhenNoConfig() throws Exception {
givenSiteOwnedByUser();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.configured").value(false));
}
@Test
void putSaveConfigCreatesWithDefaults() throws Exception {
givenSiteOwnedByUser();
givenSaveReturnsInput();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 BBB...");
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
request.setDomain("mysite.com");
request.setSshHost("10.0.0.1");
request.setSshPort(2222);
request.setSshUser("admin");
request.setSslEnabled(true);
request.setSslEmail("ssl@mysite.com");
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.domain").value("mysite.com"))
.andExpect(jsonPath("$.publicKey").value("ssh-ed25519 BBB..."));
verify(selfHostedConfigRepository).save(any(SelfHostedConfig.class));
}
@Test
void putSaveConfigUsesDefaultPathsWhenNotProvided() throws Exception {
givenSiteOwnedByUser();
givenSaveReturnsInput();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
when(keyManager.getPublicKey(siteId.toString())).thenReturn("key");
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
request.setDomain("site.com");
request.setSshHost("host");
request.setSshPort(22);
request.setSshUser("user");
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
verify(selfHostedConfigRepository).save(argThat(config ->
"/var/www/{domain}/public".equals(config.getDeployPath()) &&
"/etc/nginx/sites-available".equals(config.getNginxSitesPath())
));
}
@Test
void putReturns400OnValidationError() throws Exception {
SelfHostedConfigRequest request = new SelfHostedConfigRequest();
mockMvc.perform(put("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void deleteRemovesConfigAndCallsDeployer() throws Exception {
givenSiteOwnedByUser();
SelfHostedConfig config = SelfHostedConfig.builder()
.id(UUID.randomUUID())
.siteId(siteId)
.domain("example.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Self-hosted configuration removed"));
verify(deployer).remove(config);
verify(selfHostedConfigRepository).delete(config);
}
@Test
void deleteSwallowsDeployerException() throws Exception {
givenSiteOwnedByUser();
SelfHostedConfig config = SelfHostedConfig.builder()
.id(UUID.randomUUID())
.siteId(siteId)
.domain("example.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.of(config));
doThrow(new RuntimeException("SSH error")).when(deployer).remove(config);
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isOk());
verify(selfHostedConfigRepository).delete(config);
}
@Test
void deleteHandlesNoConfig() throws Exception {
givenSiteOwnedByUser();
when(selfHostedConfigRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
mockMvc.perform(delete("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isOk());
verify(deployer, never()).remove(any());
verify(selfHostedConfigRepository, never()).delete(any());
}
@Test
void getConfigReturns400WhenSiteNotFound() throws Exception {
when(siteRepository.findById(siteId)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isBadRequest());
}
@Test
void getConfigReturns400ForWrongOwner() throws Exception {
Site site = Site.builder()
.id(siteId)
.userId(UUID.randomUUID())
.name("Test Site")
.build();
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
mockMvc.perform(get("/api/sites/{id}/self-hosted", siteId)
.with(authentication(auth)))
.andExpect(status().isBadRequest());
}
}

View File

@@ -0,0 +1,144 @@
package com.krrishg.repository;
import com.krrishg.model.SelfHostedConfig;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
class SelfHostedConfigRepositoryTest {
@Autowired
private SelfHostedConfigRepository repository;
@Test
void saveAndFindBySiteId() {
UUID siteId = UUID.randomUUID();
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(siteId)
.domain("example.com")
.sshHost("192.168.1.1")
.sshPort(22)
.sshUser("deploy")
.build();
repository.save(config);
Optional<SelfHostedConfig> found = repository.findBySiteId(siteId);
assertTrue(found.isPresent());
assertEquals("example.com", found.get().getDomain());
}
@Test
void findBySiteIdReturnsEmptyWhenNotExists() {
Optional<SelfHostedConfig> found = repository.findBySiteId(UUID.randomUUID());
assertTrue(found.isEmpty());
}
@Test
void findByDomainReturnsConfig() {
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(UUID.randomUUID())
.domain("mysite.com")
.sshHost("10.0.0.1")
.sshPort(2222)
.sshUser("admin")
.build();
repository.save(config);
Optional<SelfHostedConfig> found = repository.findByDomain("mysite.com");
assertTrue(found.isPresent());
assertEquals("mysite.com", found.get().getDomain());
}
@Test
void findByDomainReturnsEmptyWhenNotExists() {
Optional<SelfHostedConfig> found = repository.findByDomain("nonexistent.com");
assertTrue(found.isEmpty());
}
@Test
void existsBySiteIdReturnsTrueWhenExists() {
UUID siteId = UUID.randomUUID();
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(siteId)
.domain("exists.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
repository.save(config);
assertTrue(repository.existsBySiteId(siteId));
}
@Test
void existsBySiteIdReturnsFalseWhenNotExists() {
assertFalse(repository.existsBySiteId(UUID.randomUUID()));
}
@Test
void existsByDomainReturnsTrueWhenExists() {
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(UUID.randomUUID())
.domain("domain-check.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
repository.save(config);
assertTrue(repository.existsByDomain("domain-check.com"));
}
@Test
void existsByDomainReturnsFalseWhenNotExists() {
assertFalse(repository.existsByDomain("no-domain.com"));
}
@Test
void savedEntityHasIdAndTimestamps() {
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(UUID.randomUUID())
.domain("timestamps.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
SelfHostedConfig saved = repository.save(config);
assertNotNull(saved.getId());
assertNotNull(saved.getCreatedAt());
assertNotNull(saved.getUpdatedAt());
}
@Test
void siteIdIsUniqueByConstraint() {
UUID siteId = UUID.randomUUID();
SelfHostedConfig config1 = SelfHostedConfig.builder()
.siteId(siteId)
.domain("first.com")
.sshHost("host1")
.sshPort(22)
.sshUser("user1")
.build();
repository.saveAndFlush(config1);
SelfHostedConfig config2 = SelfHostedConfig.builder()
.siteId(siteId)
.domain("second.com")
.sshHost("host2")
.sshPort(22)
.sshUser("user2")
.build();
assertThrows(Exception.class, () -> repository.saveAndFlush(config2));
}
}

View File

@@ -0,0 +1,283 @@
package com.krrishg.service;
import com.krrishg.dto.SiteStructure;
import com.krrishg.model.*;
import com.krrishg.model.Site.DeploymentTarget;
import com.krrishg.service.RenderService.SiteOutput;
import com.krrishg.support.FakeGitRemoteRepository;
import com.krrishg.support.FakeSelfHostedConfigRepository;
import com.krrishg.support.FakeSiteRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DeploymentServiceTest {
private FakeSiteRepository siteRepository;
private FakeGitRemoteRepository gitRemoteRepository;
private FakeSelfHostedConfigRepository selfHostedConfigRepository;
@Mock
private RenderService renderService;
@Mock
private GitService gitService;
@Mock
private LLMService llmService;
@Mock
private Deployer deployer;
private DeploymentService deploymentService;
private UUID siteId;
private UUID userId;
private Site site;
private SiteStructure structure;
private SiteOutput siteOutput;
@BeforeEach
void setUp() {
siteRepository = new FakeSiteRepository();
gitRemoteRepository = new FakeGitRemoteRepository();
selfHostedConfigRepository = new FakeSelfHostedConfigRepository();
deploymentService = new DeploymentService(renderService, gitService,
siteRepository, llmService, gitRemoteRepository,
selfHostedConfigRepository, deployer);
siteId = UUID.randomUUID();
userId = UUID.randomUUID();
site = Site.builder()
.userId(userId)
.name("Test Site")
.build();
site.setId(siteId);
site.setCreatedAt(LocalDateTime.now());
site.setUpdatedAt(LocalDateTime.now());
structure = SiteStructure.builder()
.pages(List.of(
SiteStructure.PageStructure.builder()
.title("Home").slug("home").rawHtml("<h1>Hello</h1>")
.build()
))
.build();
siteOutput = new SiteOutput("css", "js", new LinkedHashMap<>());
}
private void givenVersionExists() {
GenerationVersion version = GenerationVersion.builder()
.id("v1").siteId(siteId).versionNumber(1).fullStructure("{}").build();
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
when(llmService.restoreVersion("v1")).thenReturn(structure);
}
@Test
void publishWithGitPagesTargetDelegatesToGitService() {
site.setDeploymentTarget(DeploymentTarget.GIT_PAGES);
siteRepository.save(site);
GitRemote remote = GitRemote.builder()
.siteId(siteId)
.provider(GitRemote.GitProvider.GITHUB)
.remoteUrl("https://github.com/user/repo.git")
.encryptedToken("token")
.build();
gitRemoteRepository.save(remote);
givenVersionExists();
when(renderService.renderSite(structure)).thenReturn(siteOutput);
when(gitService.deployToPages(eq(siteId.toString()), any(GitRemote.class), eq(structure)))
.thenReturn("https://user.github.io/repo/");
String url = deploymentService.publish(siteId);
assertEquals("https://user.github.io/repo/", url);
verify(gitService).deployToPages(siteId.toString(), remote, structure);
Site updated = siteRepository.findById(siteId).orElseThrow();
assertEquals(SiteStatus.PUBLISHED, updated.getStatus());
assertNotNull(updated.getPublishedAt());
assertEquals("https://user.github.io/repo/", updated.getPublishedUrl());
}
@Test
void publishWithSelfHostedTargetDelegatesToDeployer() throws Exception {
site.setDeploymentTarget(DeploymentTarget.SELF_HOSTED);
siteRepository.save(site);
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(siteId)
.domain("example.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
selfHostedConfigRepository.save(config);
givenVersionExists();
when(renderService.renderSite(structure)).thenReturn(siteOutput);
when(deployer.deploy(any(SiteOutput.class), any(SelfHostedConfig.class)))
.thenReturn("http://example.com/");
when(deployer.buildUrl(any(SelfHostedConfig.class))).thenReturn("http://example.com/");
String url = deploymentService.publish(siteId);
assertEquals("http://example.com/", url);
verify(deployer).deploy(siteOutput, config);
}
@Test
void publishWithNoneTargetReturnsNullUrl() throws Exception {
site.setDeploymentTarget(DeploymentTarget.NONE);
siteRepository.save(site);
givenVersionExists();
when(renderService.renderSite(structure)).thenReturn(siteOutput);
String url = deploymentService.publish(siteId);
assertNull(url);
verify(gitService, never()).deployToPages(any(), any(), any());
verify(deployer, never()).deploy(any(), any());
Site updated = siteRepository.findById(siteId).orElseThrow();
assertEquals(SiteStatus.PUBLISHED, updated.getStatus());
assertNull(updated.getPublishedUrl());
}
@Test
void publishThrowsWhenSiteNotFound() {
assertThrows(IllegalArgumentException.class,
() -> deploymentService.publish(UUID.randomUUID()));
}
@Test
void publishThrowsWhenNoVersions() {
siteRepository.save(site);
when(llmService.getVersions(siteId)).thenReturn(List.of());
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> deploymentService.publish(siteId));
assertTrue(ex.getMessage().contains("No generation versions"));
}
@Test
void publishWithGitPagesTargetThrowsWhenRemoteMissing() {
site.setDeploymentTarget(DeploymentTarget.GIT_PAGES);
siteRepository.save(site);
givenVersionExists();
when(renderService.renderSite(structure)).thenReturn(siteOutput);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> deploymentService.publish(siteId));
assertTrue(ex.getMessage().contains("no git remote is configured"));
}
@Test
void publishWithSelfHostedTargetThrowsWhenConfigMissing() {
site.setDeploymentTarget(DeploymentTarget.SELF_HOSTED);
siteRepository.save(site);
givenVersionExists();
when(renderService.renderSite(structure)).thenReturn(siteOutput);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> deploymentService.publish(siteId));
assertTrue(ex.getMessage().contains("no self-hosted config exists"));
}
@Test
void unpublishResetsSiteToDraft() {
site.setStatus(SiteStatus.PUBLISHED);
site.setPublishedUrl("https://example.com");
site.setPublishedAt(LocalDateTime.now());
siteRepository.save(site);
deploymentService.unpublish(siteId);
Site updated = siteRepository.findById(siteId).orElseThrow();
assertEquals(SiteStatus.DRAFT, updated.getStatus());
assertNull(updated.getPublishedAt());
assertNull(updated.getPublishedUrl());
}
@Test
void unpublishWithRemoteRemovesPages() {
site.setDeploymentTarget(DeploymentTarget.GIT_PAGES);
siteRepository.save(site);
GitRemote remote = GitRemote.builder()
.siteId(siteId)
.provider(GitRemote.GitProvider.GITHUB)
.remoteUrl("https://github.com/user/repo.git")
.encryptedToken("token")
.build();
gitRemoteRepository.save(remote);
deploymentService.unpublish(siteId);
verify(gitService).removePages(siteId.toString());
}
@Test
void unpublishWithSelfHostedConfigRemovesDeploy() throws Exception {
siteRepository.save(site);
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(siteId)
.domain("example.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
selfHostedConfigRepository.save(config);
deploymentService.unpublish(siteId);
verify(deployer).remove(config);
}
@Test
void unpublishThrowsWhenSiteNotFound() {
assertThrows(IllegalArgumentException.class,
() -> deploymentService.unpublish(UUID.randomUUID()));
}
@Test
void unpublishSwallowsDeployerException() throws Exception {
siteRepository.save(site);
SelfHostedConfig config = SelfHostedConfig.builder()
.siteId(siteId)
.domain("example.com")
.sshHost("host")
.sshPort(22)
.sshUser("user")
.build();
selfHostedConfigRepository.save(config);
doThrow(new RuntimeException("SSH failed")).when(deployer).remove(config);
deploymentService.unpublish(siteId);
Site updated = siteRepository.findById(siteId).orElseThrow();
assertEquals(SiteStatus.DRAFT, updated.getStatus());
}
}

View File

@@ -0,0 +1,89 @@
package com.krrishg.service;
import com.krrishg.model.GitRemote;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class GitHubPagesProviderTest {
private final GitHubPagesProvider provider = new GitHubPagesProvider();
@Test
void branchNameIsGhPages() {
assertEquals("gh-pages", provider.branchName());
}
@Test
void contentRootIsTempDirItself(@TempDir Path tempDir) {
assertEquals(tempDir, provider.contentRoot(tempDir));
}
@Test
void writeProviderFilesCreatesNoJekyll(@TempDir Path tempDir) throws Exception {
provider.writeProviderFiles(tempDir);
Path nojekyll = tempDir.resolve(".nojekyll");
assertTrue(Files.exists(nojekyll));
assertEquals("", Files.readString(nojekyll));
}
@Test
void buildPagesUrlWithStandardRemote() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://github.com/user/my-repo.git")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://user.github.io/my-repo/", url);
}
@Test
void buildPagesUrlWithoutGitSuffix() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://github.com/org/project")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://org.github.io/project/", url);
}
@Test
void buildPagesUrlWithTrailingSlash() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://github.com/team/app/")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://team.github.io/app/", url);
}
@Test
void buildPagesUrlReturnsOriginalWhenNotGithub() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://example.com/user/repo.git")
.build();
String url = provider.buildPagesUrl(remote);
assertNotNull(url);
}
@Test
void buildPagesUrlHandlesSshUrl() {
GitRemote remote = GitRemote.builder()
.remoteUrl("git@github.com:user/project.git")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://user.github.io/project/", url);
}
}

View File

@@ -0,0 +1,91 @@
package com.krrishg.service;
import com.krrishg.model.GitRemote;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class GitLabPagesProviderTest {
private final GitLabPagesProvider provider = new GitLabPagesProvider();
@Test
void branchNameIsGlPages() {
assertEquals("gl-pages", provider.branchName());
}
@Test
void contentRootIsPublicDirectory(@TempDir Path tempDir) {
assertEquals(tempDir.resolve("public"), provider.contentRoot(tempDir));
}
@Test
void writeProviderFilesCreatesGitLabCiYml(@TempDir Path tempDir) throws Exception {
provider.writeProviderFiles(tempDir);
Path ciFile = tempDir.resolve(".gitlab-ci.yml");
assertTrue(Files.exists(ciFile));
String content = Files.readString(ciFile);
assertTrue(content.contains("pages:"));
assertTrue(content.contains("gl-pages"));
}
@Test
void buildPagesUrlWithStandardRemote() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://gitlab.com/mygroup/myproject.git")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://mygroup.gitlab.io/myproject/", url);
}
@Test
void buildPagesUrlWithoutGitSuffix() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://gitlab.com/team/app")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://team.gitlab.io/app/", url);
}
@Test
void buildPagesUrlWithTrailingSlash() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://gitlab.com/org/repo/")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://org.gitlab.io/repo/", url);
}
@Test
void buildPagesUrlReturnsOriginalUrlWhenNotGitlab() {
GitRemote remote = GitRemote.builder()
.remoteUrl("https://example.com/user/repo.git")
.build();
String url = provider.buildPagesUrl(remote);
assertNotNull(url);
}
@Test
void buildPagesUrlHandlesSshUrl() {
GitRemote remote = GitRemote.builder()
.remoteUrl("git@gitlab.com:group/project.git")
.build();
String url = provider.buildPagesUrl(remote);
assertEquals("https://group.gitlab.io/project/", url);
}
}

View File

@@ -0,0 +1,97 @@
package com.krrishg.service;
import com.krrishg.config.GitConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
class KeyManagerTest {
private Path tempDir;
private KeyManager keyManager;
private String siteId;
@BeforeEach
void setUp(@TempDir Path tempDir) {
this.tempDir = tempDir;
siteId = "test-site";
GitConfig gitConfig = new GitConfig() {
@Override
public Path getReposPath() {
return KeyManagerTest.this.tempDir;
}
};
gitConfig.setAuthorName("Test");
gitConfig.setAuthorEmail("test@test.com");
keyManager = new KeyManager(gitConfig);
}
@Test
void hasKeyPairReturnsFalseWhenKeysDoNotExist() {
assertFalse(keyManager.hasKeyPair(siteId));
}
@Test
void getPublicKeyGeneratesKeysWhenMissing() {
String publicKey = keyManager.getPublicKey(siteId);
assertNotNull(publicKey);
assertTrue(publicKey.startsWith("ssh-ed25519"));
assertTrue(keyManager.hasKeyPair(siteId));
}
@Test
void getPublicKeyReturnsExistingKey() {
keyManager.generateKeyPair(siteId);
String firstCall = keyManager.getPublicKey(siteId);
String secondCall = keyManager.getPublicKey(siteId);
assertEquals(firstCall, secondCall);
}
@Test
void generateKeyPairCreatesBothFiles() {
keyManager.generateKeyPair(siteId);
Path sshDir = tempDir.resolve(siteId).resolve(".ssh");
assertTrue(Files.exists(sshDir.resolve("id_ed25519")));
assertTrue(Files.exists(sshDir.resolve("id_ed25519.pub")));
}
@Test
void deleteKeysRemovesKeyFilesAndDirectory() {
keyManager.generateKeyPair(siteId);
assertTrue(keyManager.hasKeyPair(siteId));
keyManager.deleteKeys(siteId);
assertFalse(keyManager.hasKeyPair(siteId));
assertFalse(Files.exists(tempDir.resolve(siteId).resolve(".ssh")));
}
@Test
void deleteKeysDoesNotThrowWhenKeysDoNotExist() {
assertDoesNotThrow(() -> keyManager.deleteKeys(siteId));
}
@Test
void getPrivateKeyPathGeneratesOnDemand() {
Path keyPath = keyManager.getPrivateKeyPath(siteId);
assertTrue(Files.exists(keyPath));
assertTrue(keyPath.endsWith("id_ed25519"));
}
@Test
void getSshDirReturnsCorrectPath() {
Path sshDir = keyManager.getSshDir(siteId);
assertEquals(tempDir.resolve(siteId).resolve(".ssh"), sshDir);
}
}

View File

@@ -0,0 +1,149 @@
package com.krrishg.service;
import com.krrishg.service.MediaService.MediaUploadResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.IOException;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
@ExtendWith(MockitoExtension.class)
class MediaServiceTest {
@Mock
private S3Client s3Client;
private MediaService mediaService;
@BeforeEach
void setUp() throws Exception {
mediaService = new MediaService(s3Client);
var bucketField = MediaService.class.getDeclaredField("bucket");
bucketField.setAccessible(true);
bucketField.set(mediaService, "test-bucket");
var urlField = MediaService.class.getDeclaredField("publicUrlBase");
urlField.setAccessible(true);
urlField.set(mediaService, "https://cdn.example.com");
}
@Test
void uploadStoresFileAndReturnsResult() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getContentType()).thenReturn("image/jpeg");
when(file.getOriginalFilename()).thenReturn("photo.jpg");
when(file.getBytes()).thenReturn("image-data".getBytes());
UUID siteId = UUID.randomUUID();
MediaUploadResult result = mediaService.upload(file, siteId);
assertNotNull(result.key());
assertTrue(result.key().startsWith("media/" + siteId + "/"));
assertTrue(result.key().endsWith(".jpg"));
assertEquals("https://cdn.example.com/" + result.key(), result.url());
assertEquals("photo.jpg", result.originalFilename());
assertEquals("image/jpeg", result.contentType());
assertEquals(1024L, result.size());
ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
assertEquals("test-bucket", requestCaptor.getValue().bucket());
assertEquals(result.key(), requestCaptor.getValue().key());
}
@Test
void uploadRejectsEmptyFile() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(true);
assertThrows(IllegalArgumentException.class,
() -> mediaService.upload(file, UUID.randomUUID()));
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadRejectsOversizedFile() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(11 * 1024 * 1024L);
assertThrows(IllegalArgumentException.class,
() -> mediaService.upload(file, UUID.randomUUID()));
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadRejectsUnsupportedContentType() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getContentType()).thenReturn("application/exe");
assertThrows(IllegalArgumentException.class,
() -> mediaService.upload(file, UUID.randomUUID()));
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadRejectsNullContentType() {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getContentType()).thenReturn(null);
assertThrows(IllegalArgumentException.class,
() -> mediaService.upload(file, UUID.randomUUID()));
}
@Test
void uploadHandlesFilenameWithoutExtension() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(512L);
when(file.getContentType()).thenReturn("image/png");
when(file.getOriginalFilename()).thenReturn("logo");
when(file.getBytes()).thenReturn("png-data".getBytes());
UUID siteId = UUID.randomUUID();
MediaUploadResult result = mediaService.upload(file, siteId);
assertFalse(result.key().endsWith("."));
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadThrowsOnIOException() throws IOException {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(false);
when(file.getSize()).thenReturn(1024L);
when(file.getContentType()).thenReturn("image/jpeg");
when(file.getOriginalFilename()).thenReturn("img.jpg");
when(file.getBytes()).thenThrow(new IOException("S3 unreachable"));
assertThrows(RuntimeException.class,
() -> mediaService.upload(file, UUID.randomUUID()));
}
@Test
void getPublicUrlAppendsKey() {
String url = mediaService.getPublicUrl("media/site/file.jpg");
assertEquals("https://cdn.example.com/media/site/file.jpg", url);
}
}

View File

@@ -0,0 +1,79 @@
package com.krrishg.service;
import com.krrishg.model.SelfHostedConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class SshRsyncDeployerTest {
@Mock
private KeyManager keyManager;
private SshRsyncDeployer deployer;
@BeforeEach
void setUp() {
deployer = new SshRsyncDeployer(keyManager);
}
@Test
void buildUrlReturnsHttpWhenSslDisabled() {
SelfHostedConfig config = SelfHostedConfig.builder()
.domain("example.com")
.sslEnabled(false)
.build();
String url = deployer.buildUrl(config);
assertEquals("http://example.com/", url);
}
@Test
void buildUrlReturnsHttpsWhenSslEnabled() {
SelfHostedConfig config = SelfHostedConfig.builder()
.domain("example.com")
.sslEnabled(true)
.build();
String url = deployer.buildUrl(config);
assertEquals("https://example.com/", url);
}
@Test
void buildNginxConfigWithSslIncludesRedirect() throws Exception {
Method method = SshRsyncDeployer.class.getDeclaredMethod(
"buildNginxConfig", String.class, String.class, boolean.class);
method.setAccessible(true);
String config = (String) method.invoke(deployer, "example.com", "/var/www/example/public", true);
assertTrue(config.contains("listen 443 ssl http2"));
assertTrue(config.contains("ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem"));
assertTrue(config.contains("return 301 https://$host$request_uri"));
assertTrue(config.contains("server_name example.com"));
assertTrue(config.contains("listen 80;"));
}
@Test
void buildNginxConfigWithoutSslIsPlainHttp() throws Exception {
Method method = SshRsyncDeployer.class.getDeclaredMethod(
"buildNginxConfig", String.class, String.class, boolean.class);
method.setAccessible(true);
String config = (String) method.invoke(deployer, "mysite.org", "/var/www/mysite/public", false);
assertTrue(config.contains("listen 80;"));
assertTrue(config.contains("root /var/www/mysite/public;"));
assertFalse(config.contains("ssl_certificate"));
assertFalse(config.contains("return 301"));
}
}

View File

@@ -0,0 +1,178 @@
package com.krrishg.service.llm;
import com.krrishg.dto.LLMOptions;
import com.krrishg.dto.LLMResult;
import com.krrishg.dto.Reference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeminiProviderTest {
@Mock
private RestTemplate restTemplate;
private GeminiProvider provider;
private LLMOptions defaultOptions;
@BeforeEach
void setUp() {
RestTemplateBuilder builder = new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(1));
try {
var field = RestTemplateBuilder.class.getDeclaredField("interceptors");
field.setAccessible(true);
} catch (Exception e) {
}
provider = new GeminiProvider("test-key", "gemini-2.0-flash",
"https://generativelanguage.googleapis.com/v1beta/models",
builder);
try {
var restTemplateField = GeminiProvider.class.getDeclaredField("restTemplate");
restTemplateField.setAccessible(true);
restTemplateField.set(provider, restTemplate);
} catch (Exception e) {
throw new RuntimeException(e);
}
defaultOptions = LLMOptions.builder().build();
}
@Test
void generateReturnsContentOnSuccess() {
Map<String, Object> responseBody = Map.of(
"candidates", List.of(
Map.of("content", Map.of(
"parts", List.of(Map.of("text", "Hello from Gemini"))
))
),
"usageMetadata", Map.of(
"promptTokenCount", 10,
"candidatesTokenCount", 20
)
);
when(restTemplate.exchange(
any(String.class),
any(),
any(HttpEntity.class),
eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
LLMResult result = provider.generate("system", "user prompt", null, defaultOptions);
assertEquals("Hello from Gemini", result.getContent());
assertEquals(10, result.getPromptTokens());
assertEquals(20, result.getCompletionTokens());
assertEquals("gemini-2.0-flash", result.getModel());
assertTrue(result.getLatencyMs() >= 0);
}
@Test
void generateThrowsOnEmptyCandidates() {
Map<String, Object> responseBody = Map.of(
"candidates", List.of()
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
assertThrows(RuntimeException.class,
() -> provider.generate("sys", "user", null, defaultOptions));
}
@Test
void generateThrowsOnNullResponseBody() {
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(null));
assertThrows(RuntimeException.class,
() -> provider.generate("sys", "user", null, defaultOptions));
}
@Test
void generateThrowsOnMalformedResponse() {
Map<String, Object> responseBody = Map.of(
"candidates", List.of(Map.of("invalid", "data"))
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
assertThrows(RuntimeException.class,
() -> provider.generate("sys", "user", null, defaultOptions));
}
@Test
void generateIncludesImageReferencesInParts() {
Map<String, Object> responseBody = Map.of(
"candidates", List.of(
Map.of("content", Map.of(
"parts", List.of(Map.of("text", "result"))
))
),
"usageMetadata", Map.of(
"promptTokenCount", 5,
"candidatesTokenCount", 10
)
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
List<Reference> refs = List.of(
Reference.builder()
.type(Reference.ReferenceType.IMAGE)
.url("data:image/png;base64,abc123")
.altText("Screenshot")
.build()
);
LLMResult result = provider.generate("sys", "user", refs, defaultOptions);
assertEquals("result", result.getContent());
}
@Test
void getNameReturnsGemini() {
assertEquals("gemini", provider.getName());
}
@Test
void generateReturnsZeroTokensWhenUsageMissing() {
Map<String, Object> responseBody = Map.of(
"candidates", List.of(
Map.of("content", Map.of(
"parts", List.of(Map.of("text", "no usage"))
))
)
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
LLMResult result = provider.generate("sys", "prompt", null, defaultOptions);
assertEquals(0, result.getPromptTokens());
assertEquals(0, result.getCompletionTokens());
}
}

View File

@@ -0,0 +1,133 @@
package com.krrishg.service.llm;
import com.krrishg.dto.LLMOptions;
import com.krrishg.dto.LLMResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class OpenAIProviderTest {
@Mock
private RestTemplate restTemplate;
private OpenAIProvider provider;
private LLMOptions defaultOptions;
@BeforeEach
void setUp() {
RestTemplateBuilder builder = new RestTemplateBuilder()
.setConnectTimeout(Duration.ofSeconds(1));
provider = new OpenAIProvider("sk-test", "gpt-4o-mini",
"https://api.openai.com/v1/chat/completions", builder);
try {
var field = OpenAIProvider.class.getDeclaredField("restTemplate");
field.setAccessible(true);
field.set(provider, restTemplate);
} catch (Exception e) {
throw new RuntimeException(e);
}
defaultOptions = LLMOptions.builder().build();
}
@Test
void generateReturnsContentOnSuccess() {
Map<String, Object> responseBody = Map.of(
"choices", List.of(
Map.of("message", Map.of("content", "Hello from OpenAI"))
),
"usage", Map.of(
"prompt_tokens", 15,
"completion_tokens", 25
)
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
LLMResult result = provider.generate("system", "user prompt", null, defaultOptions);
assertEquals("Hello from OpenAI", result.getContent());
assertEquals(15, result.getPromptTokens());
assertEquals(25, result.getCompletionTokens());
assertEquals("gpt-4o-mini", result.getModel());
assertTrue(result.getLatencyMs() >= 0);
}
@Test
void generateThrowsOnEmptyChoices() {
Map<String, Object> responseBody = Map.of(
"choices", List.of()
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
assertThrows(RuntimeException.class,
() -> provider.generate("sys", "user", null, defaultOptions));
}
@Test
void generateThrowsOnNullResponseBody() {
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(null));
assertThrows(RuntimeException.class,
() -> provider.generate("sys", "user", null, defaultOptions));
}
@Test
void generateThrowsOnMalformedResponse() {
Map<String, Object> responseBody = Map.of(
"choices", List.of(Map.of("invalid", "data"))
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
assertThrows(RuntimeException.class,
() -> provider.generate("sys", "user", null, defaultOptions));
}
@Test
void getNameReturnsOpenai() {
assertEquals("openai", provider.getName());
}
@Test
void generateReturnsZeroTokensWhenUsageMissing() {
Map<String, Object> responseBody = Map.of(
"choices", List.of(
Map.of("message", Map.of("content", "no usage"))
)
);
when(restTemplate.exchange(
any(String.class), any(), any(HttpEntity.class), eq(Map.class)
)).thenReturn(ResponseEntity.ok(responseBody));
LLMResult result = provider.generate("sys", "prompt", null, defaultOptions);
assertEquals(0, result.getPromptTokens());
assertEquals(0, result.getCompletionTokens());
}
}

View File

@@ -0,0 +1,46 @@
package com.krrishg.support;
import com.krrishg.model.GitRemote;
import com.krrishg.repository.GitRemoteRepository;
import java.util.Optional;
import java.util.UUID;
public class FakeGitRemoteRepository
extends InMemoryRepository<GitRemote, UUID>
implements GitRemoteRepository {
@Override
protected UUID getId(GitRemote entity) {
return entity.getId();
}
@Override
protected GitRemote setId(GitRemote entity, UUID id) {
entity.setId(id);
return entity;
}
@Override
protected UUID generateId() {
return UUID.randomUUID();
}
@Override
public Optional<GitRemote> findBySiteId(UUID siteId) {
return store.values().stream()
.filter(r -> siteId.equals(r.getSiteId()))
.findFirst();
}
@Override
public boolean existsBySiteId(UUID siteId) {
return store.values().stream()
.anyMatch(r -> siteId.equals(r.getSiteId()));
}
@Override
public void deleteBySiteId(UUID siteId) {
store.values().removeIf(r -> siteId.equals(r.getSiteId()));
}
}