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

284
PLAN-byok.md Normal file
View File

@@ -0,0 +1,284 @@
# Bring Your Own Key (BYOK) — Execution Plan
## Goal
Allow users to provide their own API keys for LLM providers (Gemini, OpenAI) instead of relying on globally configured environment variables. Users can store keys for multiple providers and select which one to use during site generation. Remove all default/global API key configuration.
---
## Scope
### What will be changed
| Area | Files |
|------|-------|
| User entity | `User.java` — add `Map<String, String> llmApiKeys` (JSON column, encrypted values) |
| Encryption | `EncryptionService.java` — AES-GCM encrypt/decrypt for stored keys |
| LLM providers | `GeminiProvider.java`, `OpenAIProvider.java` — remove `@Component`, remove `@Value` apiKey, accept all params in constructor |
| Provider creation | `ProviderFactory.java` (new) — creates provider instances per-request with user's key |
| LLM factory | `LLMFactory.java` — delete, replaced by `ProviderFactory` |
| LLM service | `LLMService.java` — accept `provider`+`apiKey` params, use `ProviderFactory`, remove fallback logic |
| LLM controller | `LLMController.java` — extract `provider`+`apiKey` from request; fall back to user's stored keys |
| User settings API | `UserSettingsController.java` (new) — `PUT/GET/DELETE /api/user/llm-config` |
| DTOs | `LLMConfigRequest.java` (new) — `{provider, apiKey}` |
| Config | `application.yml` — remove `active-provider`, `fallback-provider`, `api-key` entries |
| Frontend types | `llm.ts` — add `provider` field to requests; add `LLMKeyStatus` type |
| Frontend service | `llm-config.service.ts` (new) — API calls for key CRUD |
| Frontend settings | `settings.component.ts` — add LLM API keys card (provider list + key input) |
| Frontend workspace | `llm-workspace.component.ts` — load key status, pass provider in generate/refine calls |
| Tests | All modified files + new tests for `EncryptionService`, `ProviderFactory`, `UserSettingsController` |
### What will NOT be changed
- Storage keys (S3/MinIO) — infrastructure concern
- Git remote tokens — separate feature
- JWT signing keys — internal auth
- Rate limiting — kept as-is
- `Site.java`, `GitRemote.java`, `SelfHostedConfig.java` — unrelated
- `GenerationVersion.java` — no need to track which key was used
- `docker-compose.yml` — unrelated
---
## Architecture Decisions
### 1. Multiple provider keys stored per user
`User.java` gains a JSON column `llmApiKeys` mapping provider names to encrypted API keys:
```
{"gemini": "AES-encrypted-value", "openai": "AES-encrypted-value"}
```
Stored as a `Map<String, String>` with Hibernate 6 `@JdbcTypeCode(SqlTypes.JSON)`. Each value is encrypted individually via `EncryptionService`.
### 2. No global/default keys
The env-var-based API keys (`GEMINI_API_KEY`, `OPENAI_API_KEY`) are removed. Every user must configure their own key(s). If no key is available for the requested provider, the API returns a clear error: "No API key configured for {provider}. Add one in Settings."
### 3. Per-request provider instances
`LLMProvider` implementations are no longer Spring beans. A `ProviderFactory` creates a fresh instance per request with the user's key. This keeps the `LLMProvider` interface clean (no `apiKey` parameter on `generate()`).
### 4. Provider selected per request
Each generation/refine request includes a `provider` field. The user's stored key for that provider is used. The request body can optionally include an `apiKey` to override the stored key on-the-fly.
### 5. Encryption
AES-256-GCM via `EncryptionService`. Master key from env var `INDIE_ENCRYPTION_KEY`. If unset, a key is generated and persisted to disk (same pattern as JWT keys in `JwtConfig`).
---
## Implementation Plan
### Step 1: Backend — Encryption service
**New file:** `backend/src/main/java/com/krrishg/config/EncryptionService.java`
- AES-256-GCM encrypt/decrypt
- Master key loaded from `@Value("${indie.encryption.master-key:}")`
- If empty, generate random key and save to `config/encryption.key`
- Methods: `encrypt(String plaintext) → String`, `decrypt(String ciphertext) → String`
### Step 2: Backend — Add LLM API keys to User entity
**Modify:** `backend/src/main/java/com/krrishg/model/User.java`
```java
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "TEXT")
private Map<String, String> llmApiKeys;
```
Import `java.util.Map`, `org.hibernate.type.SqlTypes`, `jakarta.persistence.Column`.
### Step 3: Backend — LLM config DTO and controller
**New file:** `backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java`
```java
public record LLMConfigRequest(String provider, String apiKey) {}
```
**New file:** `backend/src/main/java/com/krrishg/controller/UserSettingsController.java`
- `@RestController @RequestMapping("/api/user")`
- `PUT /llm-config` — Accept `LLMConfigRequest`, encrypt the key, store in `User.llmApiKeys`
- `GET /llm-config` — Return map of `{provider: boolean}` indicating which keys are configured (never return actual keys)
- `DELETE /llm-config/{provider}` — Remove the key for that provider
### Step 4: Backend — Refactor LLM providers
**Modify:** `backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java`
- Remove `@Component`
- Constructor: `GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate)`
- Remove all `@Value` annotations
**Modify:** `backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java`
- Same refactoring as GeminiProvider
### Step 5: Backend — Create ProviderFactory
**New file:** `backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java`
```java
@Component
public class ProviderFactory {
// Injected via @Value: model names, base URLs from application.yml
// Methods:
// createProvider("gemini", apiKey) → new GeminiProvider(...)
// createProvider("openai", apiKey) → new OpenAIProvider(...)
// getAvailableProviders() → ["gemini", "openai"]
}
```
**Delete:** `backend/src/main/java/com/krrishg/service/llm/LLMFactory.java`
### Step 6: Backend — Update LLMService
**Modify:** `backend/src/main/java/com/krrishg/service/LLMService.java`
- Remove `LLMFactory`, inject `ProviderFactory`, `UserRepository`, `EncryptionService`
- `generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references, String provider, String apiKey)` — look up key from user if not provided; call `callLLM()`
- `refineSite(...)` — same treatment
- `callLLM()` now takes `provider` + `apiKey`:
```java
private LLMResult callLLM(..., String providerName, String apiKey) {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalStateException("No API key configured for " + providerName);
}
LLMProvider provider = providerFactory.createProvider(providerName, apiKey);
return provider.generate(systemPrompt, userPrompt, references, options);
}
```
- Remove the old active/fallback provider logic entirely
### Step 7: Backend — Update LLMController
**Modify:** `backend/src/main/java/com/krrishg/controller/LLMController.java`
- In `/generate` and `/refine`: extract `provider` (required) and optional `apiKey` from request body
- If `apiKey` not provided, load from user's stored keys via `UserRepository` + `EncryptionService.decrypt()`
- Pass `provider` + `apiKey` to `LLMService`
- Add validation: provider must be one of the supported values
### Step 8: Backend — Update application.yml
**Modify:** `backend/src/main/resources/application.yml`
- Remove `indie.llm.active-provider` and `indie.llm.fallback-provider`
- Remove `indie.llm.providers.gemini.api-key` and `indie.llm.providers.openai.api-key`
- Keep `indie.llm.providers.gemini.model`, `gemini.url`, `openai.model`, `openai.url`
- Add `indie.encryption.master-key: ${INDIE_ENCRYPTION_KEY:}`
### Step 9: Frontend — Update LLM types
**Modify:** `frontend/src/app/core/models/llm.ts`
```typescript
export interface LLMGenerateRequest {
prompt: string;
siteId: string;
provider: string;
apiKey?: string;
references?: Reference[];
}
export interface LLMRefineRequest {
prompt: string;
siteId: string;
provider: string;
currentStructure: SiteStructure;
apiKey?: string;
references?: Reference[];
}
export interface LLMKeyStatus {
gemini: boolean;
openai: boolean;
}
```
### Step 10: Frontend — LLM config service
**New file:** `frontend/src/app/core/services/llm-config.service.ts`
- `getStatus(): Observable<LLMKeyStatus>` — GET `/api/user/llm-config`
- `saveKey(provider: string, apiKey: string): Observable<void>` — PUT `/api/user/llm-config`
### Step 11: Frontend — Add LLM keys UI to settings
**Modify:** `frontend/src/app/llm-workspace/settings/settings.component.ts`
Add a new card "LLM API Keys":
- For each provider (Gemini, OpenAI): show a row with provider name, status indicator (configured/not configured), and expandable input for the API key
- "Save" button per provider
- On init, load `LLMKeyStatus` from backend
- On save, call `llm-config.service.saveKey()`
### Step 12: Frontend — Update workspace
**Modify:** `frontend/src/app/llm-workspace/llm-workspace.component.ts`
- On init, load `LLMKeyStatus` via `llm-config.service`
- If no keys configured, show a notice: "Configure an API key in Settings to generate sites"
- If only one provider has a key, auto-select it
- If multiple, add a dropdown to select provider before generating
- Pass `provider` (and optionally `apiKey`) in generate/refine requests
### Step 13: Backend — Tests
| Test file | What to test |
|-----------|-------------|
| `EncryptionServiceTest.java` (new) | Encrypt → decrypt round-trip; different keys produce different ciphertexts |
| `ProviderFactoryTest.java` (new) | Creates correct provider type for each name; throws for unknown provider |
| `LLMControllerTest.java` (update) | Request with/without provider, with/without apiKey, missing key returns 400 |
| `LLMServiceTest.java` (update) | Uses ProviderFactory instead of LLMFactory; passes key correctly; no fallback logic |
| `UserSettingsControllerTest.java` (new) | Save key, get status, delete key |
### Step 14: Frontend — Tests (optional)
Add/tests for:
- `llm-config.service.ts`
- New settings UI code
---
## Validation
### Test scenarios
| # | Scenario | Expected Behavior |
|---|----------|------------------|
| 1 | User has no keys configured | Workspace shows notice; API returns 400 with clear message |
| 2 | User saves Gemini key | `PUT /api/user/llm-config` returns success; `GET /api/user/llm-config` shows `gemini: true` |
| 3 | User saves OpenAI key | Same as above |
| 4 | User saves both keys | `GET` returns both as configured |
| 5 | Generate with stored Gemini key | Request with `provider: "gemini"` succeeds |
| 6 | Generate with stored OpenAI key | Request with `provider: "openai"` succeeds |
| 7 | Generate with per-request key override | Passing `apiKey` in request body uses that key instead of stored |
| 8 | Generate with unknown provider | 400 error |
| 9 | Generate with provider that has no key stored | 400 error: "No API key configured for {provider}" |
| 10 | Delete a stored key | `DELETE` clears it; subsequent generate for that provider fails |
| 11 | Encryption round-trip | `decrypt(encrypt("key"))` == `"key"` |
| 12 | Provider factory creates correct instances | GeminiProvider for "gemini", OpenAIProvider for "openai" |
### Risks and mitigations
| Risk | Mitigation |
|------|-----------|
| Encryption master key changes → stored keys unreadable | Generate + persist key to file on first run (like JWT keys); document `INDIE_ENCRYPTION_KEY` as critical env var |
| Existing users have no key → immediate errors | Clear error messages in API responses and UI prompts |
| Per-request provider creation overhead | `RestTemplate` shared via injected `RestTemplateBuilder`; negligible cost |
| Hibernate JSON column compatibility | Use `@JdbcTypeCode(SqlTypes.JSON)` (Hibernate 6); fallback to `columnDefinition = "TEXT"` with manual JSON serialization if needed |
### Success criteria
- Users can configure API keys for Gemini and/or OpenAI via settings UI
- Users can select which provider to use during site generation
- Keys are encrypted at rest in the database
- No global/default API key is required to run the application
- All existing tests pass with appropriate modifications

View File

@@ -26,5 +26,19 @@ public class SchemaMigration {
} catch (Exception e) { } catch (Exception e) {
log.warn("Could not drop encrypted_ssh_key column: {}", e.getMessage()); 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) { @Valid @RequestBody SelfHostedConfigRequest request) {
Site site = findSiteForUser(id, principal.id()); Site site = findSiteForUser(id, principal.id());
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> {
selfHostedConfigRepository.delete(existing)); selfHostedConfigRepository.delete(existing);
selfHostedConfigRepository.flush();
});
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank() String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
? request.getDeployPath() ? request.getDeployPath()
@@ -78,6 +80,8 @@ public class SelfHostedController {
.sshUser(request.getSshUser()) .sshUser(request.getSshUser())
.deployPath(deployPath) .deployPath(deployPath)
.nginxSitesPath(nginxSitesPath) .nginxSitesPath(nginxSitesPath)
.sslEnabled(request.isSslEnabled())
.sslEmail(request.getSslEmail())
.build(); .build();
config = selfHostedConfigRepository.save(config); config = selfHostedConfigRepository.save(config);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,11 @@ public class SshRsyncDeployer implements Deployer {
rsync(tempDir.resolve("site"), keyFile, config, resolvedDeployPath); 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()); Path nginxConfPath = tempDir.resolve(config.getDomain());
Files.writeString(nginxConfPath, nginxConfig); Files.writeString(nginxConfPath, nginxConfig);
@@ -73,6 +77,10 @@ public class SshRsyncDeployer implements Deployer {
+ " /etc/nginx/sites-enabled/" + config.getDomain() + " /etc/nginx/sites-enabled/" + config.getDomain()
+ " && sudo nginx -s reload"); + " && sudo nginx -s reload");
if (config.isSslEnabled()) {
removeSsl(keyFile, config);
}
log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain()); log.info("Removed deployment for site {} ({})", config.getSiteId(), config.getDomain());
} finally { } finally {
deleteDirectory(tempDir); deleteDirectory(tempDir);
@@ -81,7 +89,8 @@ public class SshRsyncDeployer implements Deployer {
@Override @Override
public String buildUrl(SelfHostedConfig config) { 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 { private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
@@ -122,7 +131,25 @@ public class SshRsyncDeployer implements Deployer {
execute(rsyncCmd); 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" return "server {\n"
+ " listen 80;\n" + " listen 80;\n"
+ " server_name " + domain + ";\n" + " server_name " + domain + ";\n"
@@ -134,6 +161,61 @@ public class SshRsyncDeployer implements Deployer {
+ "}\n"; + "}\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 { private void execute(String command) throws Exception {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command); ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true); 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()));
}
}

View File

@@ -31,6 +31,8 @@ export interface SelfHostedConfigRequest {
sshUser: string; sshUser: string;
deployPath: string; deployPath: string;
nginxSitesPath: string; nginxSitesPath: string;
sslEnabled: boolean;
sslEmail: string;
} }
export interface SelfHostedConfigResponse { export interface SelfHostedConfigResponse {
@@ -42,6 +44,8 @@ export interface SelfHostedConfigResponse {
sshUser: string; sshUser: string;
deployPath: string; deployPath: string;
nginxSitesPath: string; nginxSitesPath: string;
sslEnabled: boolean;
sslEmail: string;
publicKey: string; publicKey: string;
deployedAt: string | null; deployedAt: string | null;
createdAt: string; createdAt: string;

View File

@@ -8,6 +8,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
@@ -24,7 +25,7 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment';
imports: [ imports: [
NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe, FormsModule, RouterModule, NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule, MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatSelectModule, MatInputModule, MatFormFieldModule, MatSelectModule, MatSlideToggleModule,
MatCardModule, MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule, MatCardModule, MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule,
], ],
template: ` template: `
@@ -185,6 +186,16 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment';
<input matInput [(ngModel)]="shNginxSitesPath" [disabled]="shSaving" /> <input matInput [(ngModel)]="shNginxSitesPath" [disabled]="shSaving" />
<mat-hint>Path to nginx sites-available directory</mat-hint> <mat-hint>Path to nginx sites-available directory</mat-hint>
</mat-form-field> </mat-form-field>
<div class="flex items-center gap-2 py-2">
<mat-slide-toggle [(ngModel)]="shSslEnabled" [disabled]="shSaving" color="primary">
SSL / HTTPS
</mat-slide-toggle>
</div>
<mat-form-field appearance="outline" class="w-full" *ngIf="shSslEnabled">
<mat-label>SSL Email (for Let's Encrypt)</mat-label>
<input matInput type="email" [(ngModel)]="shSslEmail" [disabled]="shSaving" placeholder="admin@example.com" />
<mat-hint>Used for Let's Encrypt certificate registration and renewal notices</mat-hint>
</mat-form-field>
<div class="bg-gray-50 rounded-lg p-3 space-y-2" *ngIf="shPublicKey"> <div class="bg-gray-50 rounded-lg p-3 space-y-2" *ngIf="shPublicKey">
<div class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Public Key</div> <div class="text-xs font-semibold text-gray-500 uppercase tracking-wide">Public Key</div>
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600">
@@ -260,6 +271,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
shSshUser = ''; shSshUser = '';
shDeployPath = '/var/www/{domain}/public'; shDeployPath = '/var/www/{domain}/public';
shNginxSitesPath = '/etc/nginx/sites-available'; shNginxSitesPath = '/etc/nginx/sites-available';
shSslEnabled = false;
shSslEmail = '';
shPublicKey = ''; shPublicKey = '';
shPublicKeyCopied = false; shPublicKeyCopied = false;
shDeployedAt: string | null = null; shDeployedAt: string | null = null;
@@ -323,6 +336,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.shSshUser = c.sshUser; this.shSshUser = c.sshUser;
this.shDeployPath = c.deployPath; this.shDeployPath = c.deployPath;
this.shNginxSitesPath = c.nginxSitesPath; this.shNginxSitesPath = c.nginxSitesPath;
this.shSslEnabled = c.sslEnabled;
this.shSslEmail = c.sslEmail;
this.shPublicKey = c.publicKey; this.shPublicKey = c.publicKey;
this.shDeployedAt = c.deployedAt; this.shDeployedAt = c.deployedAt;
}, },
@@ -376,6 +391,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
sshUser: this.shSshUser, sshUser: this.shSshUser,
deployPath: this.shDeployPath, deployPath: this.shDeployPath,
nginxSitesPath: this.shNginxSitesPath, nginxSitesPath: this.shNginxSitesPath,
sslEnabled: this.shSslEnabled,
sslEmail: this.shSslEmail,
}).pipe(takeUntil(this.destroy$)) }).pipe(takeUntil(this.destroy$))
.subscribe({ .subscribe({
next: (config) => { next: (config) => {
@@ -408,6 +425,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.shSshUser = ''; this.shSshUser = '';
this.shDeployPath = '/var/www/{domain}/public'; this.shDeployPath = '/var/www/{domain}/public';
this.shNginxSitesPath = '/etc/nginx/sites-available'; this.shNginxSitesPath = '/etc/nginx/sites-available';
this.shSslEnabled = false;
this.shSslEmail = '';
this.shPublicKey = ''; this.shPublicKey = '';
this.shPublicKeyCopied = false; this.shPublicKeyCopied = false;
this.shDeployedAt = null; this.shDeployedAt = null;