From a6a9ac6e7eba3006c094dc201842c4ec444a2f3f Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Mon, 6 Jul 2026 14:49:44 +0545 Subject: [PATCH] add ssl certificates and tests --- PLAN-byok.md | 284 ++++++++++++++++++ .../com/krrishg/config/SchemaMigration.java | 14 + .../controller/SelfHostedController.java | 8 +- .../krrishg/dto/SelfHostedConfigRequest.java | 4 + .../krrishg/dto/SelfHostedConfigResponse.java | 4 + .../java/com/krrishg/model/GitRemote.java | 15 +- .../com/krrishg/model/SelfHostedConfig.java | 27 +- .../src/main/java/com/krrishg/model/Site.java | 30 +- .../src/main/java/com/krrishg/model/User.java | 15 +- .../com/krrishg/service/SshRsyncDeployer.java | 88 +++++- .../controller/SelfHostedControllerTest.java | 278 +++++++++++++++++ .../SelfHostedConfigRepositoryTest.java | 144 +++++++++ .../service/DeploymentServiceTest.java | 283 +++++++++++++++++ .../service/GitHubPagesProviderTest.java | 89 ++++++ .../service/GitLabPagesProviderTest.java | 91 ++++++ .../com/krrishg/service/KeyManagerTest.java | 97 ++++++ .../com/krrishg/service/MediaServiceTest.java | 149 +++++++++ .../krrishg/service/SshRsyncDeployerTest.java | 79 +++++ .../service/llm/GeminiProviderTest.java | 178 +++++++++++ .../service/llm/OpenAIProviderTest.java | 133 ++++++++ .../support/FakeGitRemoteRepository.java | 46 +++ frontend/src/app/core/models/deployment.ts | 4 + .../settings/settings.component.ts | 21 +- 23 files changed, 2041 insertions(+), 40 deletions(-) create mode 100644 PLAN-byok.md create mode 100644 backend/src/test/java/com/krrishg/controller/SelfHostedControllerTest.java create mode 100644 backend/src/test/java/com/krrishg/repository/SelfHostedConfigRepositoryTest.java create mode 100644 backend/src/test/java/com/krrishg/service/DeploymentServiceTest.java create mode 100644 backend/src/test/java/com/krrishg/service/GitHubPagesProviderTest.java create mode 100644 backend/src/test/java/com/krrishg/service/GitLabPagesProviderTest.java create mode 100644 backend/src/test/java/com/krrishg/service/KeyManagerTest.java create mode 100644 backend/src/test/java/com/krrishg/service/MediaServiceTest.java create mode 100644 backend/src/test/java/com/krrishg/service/SshRsyncDeployerTest.java create mode 100644 backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java create mode 100644 backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java create mode 100644 backend/src/test/java/com/krrishg/support/FakeGitRemoteRepository.java diff --git a/PLAN-byok.md b/PLAN-byok.md new file mode 100644 index 0000000..0b41e8e --- /dev/null +++ b/PLAN-byok.md @@ -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 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` 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 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 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` — GET `/api/user/llm-config` +- `saveKey(provider: string, apiKey: string): Observable` — 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 diff --git a/backend/src/main/java/com/krrishg/config/SchemaMigration.java b/backend/src/main/java/com/krrishg/config/SchemaMigration.java index 3fbf854..1be7303 100644 --- a/backend/src/main/java/com/krrishg/config/SchemaMigration.java +++ b/backend/src/main/java/com/krrishg/config/SchemaMigration.java @@ -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()); + } } } diff --git a/backend/src/main/java/com/krrishg/controller/SelfHostedController.java b/backend/src/main/java/com/krrishg/controller/SelfHostedController.java index fdb4a33..71175ea 100644 --- a/backend/src/main/java/com/krrishg/controller/SelfHostedController.java +++ b/backend/src/main/java/com/krrishg/controller/SelfHostedController.java @@ -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); diff --git a/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java index 4829014..2b1430a 100644 --- a/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java +++ b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigRequest.java @@ -20,4 +20,8 @@ public class SelfHostedConfigRequest { private String deployPath; private String nginxSitesPath; + + private boolean sslEnabled; + + private String sslEmail; } diff --git a/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java index 63cda23..b2c6528 100644 --- a/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java +++ b/backend/src/main/java/com/krrishg/dto/SelfHostedConfigResponse.java @@ -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()) diff --git a/backend/src/main/java/com/krrishg/model/GitRemote.java b/backend/src/main/java/com/krrishg/model/GitRemote.java index 1e61e6c..eeafabd 100644 --- a/backend/src/main/java/com/krrishg/model/GitRemote.java +++ b/backend/src/main/java/com/krrishg/model/GitRemote.java @@ -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 diff --git a/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java b/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java index cb12f94..9b78637 100644 --- a/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java +++ b/backend/src/main/java/com/krrishg/model/SelfHostedConfig.java @@ -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 diff --git a/backend/src/main/java/com/krrishg/model/Site.java b/backend/src/main/java/com/krrishg/model/Site.java index f9f136e..6cd2386 100644 --- a/backend/src/main/java/com/krrishg/model/Site.java +++ b/backend/src/main/java/com/krrishg/model/Site.java @@ -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 } diff --git a/backend/src/main/java/com/krrishg/model/User.java b/backend/src/main/java/com/krrishg/model/User.java index df7efb6..635997d 100644 --- a/backend/src/main/java/com/krrishg/model/User.java +++ b/backend/src/main/java/com/krrishg/model/User.java @@ -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 diff --git a/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java b/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java index 506b383..c0db3f7 100644 --- a/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java +++ b/backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java @@ -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); diff --git a/backend/src/test/java/com/krrishg/controller/SelfHostedControllerTest.java b/backend/src/test/java/com/krrishg/controller/SelfHostedControllerTest.java new file mode 100644 index 0000000..32308c5 --- /dev/null +++ b/backend/src/test/java/com/krrishg/controller/SelfHostedControllerTest.java @@ -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()); + } +} diff --git a/backend/src/test/java/com/krrishg/repository/SelfHostedConfigRepositoryTest.java b/backend/src/test/java/com/krrishg/repository/SelfHostedConfigRepositoryTest.java new file mode 100644 index 0000000..9c342a2 --- /dev/null +++ b/backend/src/test/java/com/krrishg/repository/SelfHostedConfigRepositoryTest.java @@ -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 found = repository.findBySiteId(siteId); + + assertTrue(found.isPresent()); + assertEquals("example.com", found.get().getDomain()); + } + + @Test + void findBySiteIdReturnsEmptyWhenNotExists() { + Optional 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 found = repository.findByDomain("mysite.com"); + + assertTrue(found.isPresent()); + assertEquals("mysite.com", found.get().getDomain()); + } + + @Test + void findByDomainReturnsEmptyWhenNotExists() { + Optional 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)); + } +} diff --git a/backend/src/test/java/com/krrishg/service/DeploymentServiceTest.java b/backend/src/test/java/com/krrishg/service/DeploymentServiceTest.java new file mode 100644 index 0000000..225f075 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/DeploymentServiceTest.java @@ -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("

Hello

") + .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()); + } +} diff --git a/backend/src/test/java/com/krrishg/service/GitHubPagesProviderTest.java b/backend/src/test/java/com/krrishg/service/GitHubPagesProviderTest.java new file mode 100644 index 0000000..e2fdfc0 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/GitHubPagesProviderTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/krrishg/service/GitLabPagesProviderTest.java b/backend/src/test/java/com/krrishg/service/GitLabPagesProviderTest.java new file mode 100644 index 0000000..9fc299f --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/GitLabPagesProviderTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/krrishg/service/KeyManagerTest.java b/backend/src/test/java/com/krrishg/service/KeyManagerTest.java new file mode 100644 index 0000000..05d836d --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/KeyManagerTest.java @@ -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); + } +} diff --git a/backend/src/test/java/com/krrishg/service/MediaServiceTest.java b/backend/src/test/java/com/krrishg/service/MediaServiceTest.java new file mode 100644 index 0000000..917bfa5 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/MediaServiceTest.java @@ -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 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); + } +} diff --git a/backend/src/test/java/com/krrishg/service/SshRsyncDeployerTest.java b/backend/src/test/java/com/krrishg/service/SshRsyncDeployerTest.java new file mode 100644 index 0000000..ffb61fa --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/SshRsyncDeployerTest.java @@ -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")); + } +} diff --git a/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java b/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java new file mode 100644 index 0000000..6b0c820 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java @@ -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 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 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 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 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 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 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()); + } +} diff --git a/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java b/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java new file mode 100644 index 0000000..865d852 --- /dev/null +++ b/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java @@ -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 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 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 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 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()); + } +} diff --git a/backend/src/test/java/com/krrishg/support/FakeGitRemoteRepository.java b/backend/src/test/java/com/krrishg/support/FakeGitRemoteRepository.java new file mode 100644 index 0000000..2367b07 --- /dev/null +++ b/backend/src/test/java/com/krrishg/support/FakeGitRemoteRepository.java @@ -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 + 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 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())); + } +} diff --git a/frontend/src/app/core/models/deployment.ts b/frontend/src/app/core/models/deployment.ts index 271af52..8b25fef 100644 --- a/frontend/src/app/core/models/deployment.ts +++ b/frontend/src/app/core/models/deployment.ts @@ -31,6 +31,8 @@ export interface SelfHostedConfigRequest { sshUser: string; deployPath: string; nginxSitesPath: string; + sslEnabled: boolean; + sslEmail: string; } export interface SelfHostedConfigResponse { @@ -42,6 +44,8 @@ export interface SelfHostedConfigResponse { sshUser: string; deployPath: string; nginxSitesPath: string; + sslEnabled: boolean; + sslEmail: string; publicKey: string; deployedAt: string | null; createdAt: string; diff --git a/frontend/src/app/llm-workspace/settings/settings.component.ts b/frontend/src/app/llm-workspace/settings/settings.component.ts index 49b9c74..54cd176 100644 --- a/frontend/src/app/llm-workspace/settings/settings.component.ts +++ b/frontend/src/app/llm-workspace/settings/settings.component.ts @@ -8,6 +8,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @@ -24,7 +25,7 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment'; imports: [ NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault, DatePipe, FormsModule, RouterModule, MatToolbarModule, MatButtonModule, MatIconModule, - MatInputModule, MatFormFieldModule, MatSelectModule, + MatInputModule, MatFormFieldModule, MatSelectModule, MatSlideToggleModule, MatCardModule, MatDividerModule, MatSnackBarModule, MatProgressSpinnerModule, ], template: ` @@ -185,6 +186,16 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment'; Path to nginx sites-available directory +
+ + SSL / HTTPS + +
+ + SSL Email (for Let's Encrypt) + + Used for Let's Encrypt certificate registration and renewal notices +
Public Key

@@ -260,6 +271,8 @@ export class SettingsComponent implements OnInit, OnDestroy { shSshUser = ''; shDeployPath = '/var/www/{domain}/public'; shNginxSitesPath = '/etc/nginx/sites-available'; + shSslEnabled = false; + shSslEmail = ''; shPublicKey = ''; shPublicKeyCopied = false; shDeployedAt: string | null = null; @@ -323,6 +336,8 @@ export class SettingsComponent implements OnInit, OnDestroy { this.shSshUser = c.sshUser; this.shDeployPath = c.deployPath; this.shNginxSitesPath = c.nginxSitesPath; + this.shSslEnabled = c.sslEnabled; + this.shSslEmail = c.sslEmail; this.shPublicKey = c.publicKey; this.shDeployedAt = c.deployedAt; }, @@ -376,6 +391,8 @@ export class SettingsComponent implements OnInit, OnDestroy { sshUser: this.shSshUser, deployPath: this.shDeployPath, nginxSitesPath: this.shNginxSitesPath, + sslEnabled: this.shSslEnabled, + sslEmail: this.shSslEmail, }).pipe(takeUntil(this.destroy$)) .subscribe({ next: (config) => { @@ -408,6 +425,8 @@ export class SettingsComponent implements OnInit, OnDestroy { this.shSshUser = ''; this.shDeployPath = '/var/www/{domain}/public'; this.shNginxSitesPath = '/etc/nginx/sites-available'; + this.shSslEnabled = false; + this.shSslEmail = ''; this.shPublicKey = ''; this.shPublicKeyCopied = false; this.shDeployedAt = null;