Compare commits
10 Commits
874eb33678
...
77c4cfc777
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77c4cfc777 | ||
|
|
212785725f | ||
|
|
708cac6848 | ||
|
|
ea69080710 | ||
|
|
ad15ff8f2e | ||
|
|
25db5b2970 | ||
|
|
a6a9ac6e7e | ||
|
|
a498d55e8c | ||
|
|
51d887a005 | ||
|
|
f70fe81432 |
284
PLAN-byok.md
Normal file
284
PLAN-byok.md
Normal 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
|
||||
425
PLAN-selfhosted.md
Normal file
425
PLAN-selfhosted.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# Self-Hosted Deployment Plan
|
||||
|
||||
Deploy sites to a custom server via SSH + rsync + nginx, alongside the existing git-based (GitHub/GitLab Pages) publishing.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Git push always happens** when a `GitRemote` is configured — regardless of deployment target.
|
||||
2. **Self-hosted deploy is additive** — rsyncs to the server on top of the git push.
|
||||
3. **User selects `deploymentTarget`** on the Site (`GIT_PAGES`, `SELF_HOSTED`, or `NONE`) — controls which URL is the primary published URL and which prereqs are validated on publish.
|
||||
4. **Custom domain lives in `SelfHostedConfig`**, not on `Site`. Custom domains for git pages are handled outside Indie.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1 — Site Model: Add DeploymentTarget
|
||||
|
||||
**File: `model/Site.java`** — add enum + field:
|
||||
|
||||
```java
|
||||
public enum DeploymentTarget {
|
||||
GIT_PAGES, SELF_HOSTED, NONE
|
||||
}
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private DeploymentTarget deploymentTarget = DeploymentTarget.NONE;
|
||||
```
|
||||
|
||||
**File: `model/SiteStatus.java`** — no change needed.
|
||||
|
||||
### Step 2 — DTOs: Update SiteRequest/SiteResponse
|
||||
|
||||
**File: `dto/SiteRequest.java`** — add:
|
||||
```java
|
||||
private String deploymentTarget; // "GIT_PAGES", "SELF_HOSTED", "NONE"
|
||||
```
|
||||
|
||||
**File: `dto/SiteResponse.java`** — add:
|
||||
```java
|
||||
private String deploymentTarget;
|
||||
```
|
||||
Update `from(Site site)` to map the field.
|
||||
|
||||
### Step 3 — SelfHostedConfig Entity (New)
|
||||
|
||||
**File: `model/SelfHostedConfig.java`:**
|
||||
|
||||
```java
|
||||
package com.krrishg.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "self_hosted_configs")
|
||||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
public class SelfHostedConfig {
|
||||
@Id
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(nullable = false, unique = true)
|
||||
private String domain;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String sshHost;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private int sshPort = 22;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String sshUser;
|
||||
|
||||
@Column(nullable = false, length = 8192)
|
||||
private String encryptedSshKey;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private String deployPath = "/var/www/{domain}/public";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private String nginxSitesPath = "/etc/nginx/sites-available";
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean nginxSslEnabled = false;
|
||||
|
||||
private LocalDateTime deployedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) id = UUID.randomUUID();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4 — Repository (New)
|
||||
|
||||
**File: `repository/SelfHostedConfigRepository.java`:**
|
||||
|
||||
```java
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SelfHostedConfigRepository extends JpaRepository<SelfHostedConfig, UUID> {
|
||||
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
|
||||
Optional<SelfHostedConfig> findByDomain(String domain);
|
||||
boolean existsBySiteId(UUID siteId);
|
||||
boolean existsByDomain(String domain);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5 — KeyManager: SSH Key Encryption
|
||||
|
||||
**File: `service/KeyManager.java`** — add methods using the same AES-256-GCM pattern as git token encryption:
|
||||
|
||||
```java
|
||||
public String encryptSshKey(String rawKey);
|
||||
public String decryptSshKey(String encryptedKey);
|
||||
```
|
||||
|
||||
### Step 6 — Deployer Interface (New)
|
||||
|
||||
**File: `service/Deployer.java`:**
|
||||
|
||||
```java
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
|
||||
public interface Deployer {
|
||||
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
|
||||
void remove(SelfHostedConfig config) throws Exception;
|
||||
String buildUrl(SelfHostedConfig config);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7 — SshRsyncDeployer Implementation (New)
|
||||
|
||||
**File: `service/SshRsyncDeployer.java`:**
|
||||
|
||||
Key operations:
|
||||
|
||||
**`deploy()`:**
|
||||
1. Render site files to temp directory
|
||||
2. Expand `{domain}` placeholder in `deployPath` to `config.getDomain()`
|
||||
3. Decrypt SSH key to temp file (chmod 0600, delete after)
|
||||
4. `ssh <user>@<host> -p <port> -i <keyfile> "mkdir -p <deployPath>"`
|
||||
5. `rsync -avz --delete -e "ssh -p <port> -i <keyfile> -o StrictHostKeyChecking=accept-new" <tempDir>/ <user>@<host>:<deployPath>/`
|
||||
6. Generate nginx config string for the domain
|
||||
7. Write nginx config to temp file, rsync it to `<nginxSitesPath>/<domain>` on server
|
||||
8. `ssh <user>@<host> -i <keyfile> "sudo ln -sf <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/ && sudo nginx -s reload"`
|
||||
9. Clean up temp files
|
||||
10. Return `"https://" + domain + "/"`
|
||||
|
||||
**Nginx config template:**
|
||||
```
|
||||
server {
|
||||
listen 80;
|
||||
server_name <domain>;
|
||||
root <deployPath>;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optionally support SSL: if `nginxSslEnabled`, also generate a `listen 443 ssl;` block with placeholder cert paths, or skip SSL (user configures separately).
|
||||
|
||||
**`remove()`:**
|
||||
1. SSH `rm -rf <deployPath>`
|
||||
2. SSH `sudo rm <nginxSitesPath>/<domain> /etc/nginx/sites-enabled/<domain>`
|
||||
3. SSH `sudo nginx -s reload`
|
||||
|
||||
### Step 8 — DeploymentService: Dispatch Logic
|
||||
|
||||
**File: `service/DeploymentService.java`** — inject `SelfHostedConfigRepository`, `Deployer`, `SshRsyncDeployer`.
|
||||
|
||||
Update `publish()`:
|
||||
|
||||
```java
|
||||
public String publish(UUID siteId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
|
||||
SiteStructure structure = getLatestVersion(siteId);
|
||||
if (structure == null) {
|
||||
throw new IllegalStateException("No generation versions found");
|
||||
}
|
||||
|
||||
SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
// Step 1: Always push to git if remote is configured
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote != null) {
|
||||
gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
}
|
||||
|
||||
// Step 2: Additionally deploy to custom server if configured
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
deployer.deploy(output, selfHostedConfig);
|
||||
}
|
||||
|
||||
// Step 3: Validate and set URL based on deployment target
|
||||
String url = null;
|
||||
switch (site.getDeploymentTarget()) {
|
||||
case GIT_PAGES:
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException("Deployment target is GIT_PAGES but no git remote is configured");
|
||||
}
|
||||
PagesProvider provider = PagesProvider.forRemote(remote);
|
||||
url = provider.buildPagesUrl(remote);
|
||||
break;
|
||||
case SELF_HOSTED:
|
||||
if (selfHostedConfig == null) {
|
||||
throw new IllegalStateException("Deployment target is SELF_HOSTED but no self-hosted config exists");
|
||||
}
|
||||
url = deployer.buildUrl(selfHostedConfig);
|
||||
break;
|
||||
case NONE:
|
||||
// Still publish but no primary URL — user might want git push without pages URL
|
||||
break;
|
||||
}
|
||||
|
||||
site.setStatus(SiteStatus.PUBLISHED);
|
||||
site.setPublishedAt(LocalDateTime.now());
|
||||
site.setPublishedUrl(url);
|
||||
siteRepository.save(site);
|
||||
|
||||
log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url);
|
||||
return url;
|
||||
}
|
||||
```
|
||||
|
||||
Update `unpublish()` similarly — still removes git pages branch, and also removes from self-hosted server if configured.
|
||||
|
||||
### Step 9 — SelfHostedController (New)
|
||||
|
||||
**File: `controller/SelfHostedController.java`:**
|
||||
|
||||
`@RestController @RequestMapping("/api/sites/{id}/self-hosted")`
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/sites/{id}/self-hosted` | Get config (sshKey omitted from response) |
|
||||
| `PUT` | `/api/sites/{id}/self-hosted` | Create/update config. Encrypts sshKey via KeyManager. Optionally sets `site.deploymentTarget = SELF_HOSTED` |
|
||||
| `DELETE` | `/api/sites/{id}/self-hosted` | Remove config. Removes files from server via `deployer.remove()`. Resets `site.deploymentTarget` if it was `SELF_HOSTED` |
|
||||
|
||||
All endpoints require ownership check (same pattern as `SiteController.findSiteForUser`).
|
||||
|
||||
### Step 10 — SiteController: Accept deploymentTarget
|
||||
|
||||
**File: `controller/SiteController.java`** — in `createSite()` and `updateSite()`, set `site.setDeploymentTarget(...)` from the request. Validate that if target is `GIT_PAGES`, a GitRemote exists (or at least warn). If `SELF_HOSTED`, a `SelfHostedConfig` should exist.
|
||||
|
||||
### Step 11 — DTOs for Self-Hosted (New)
|
||||
|
||||
**File: `dto/SelfHostedConfigRequest.java`:**
|
||||
```java
|
||||
@Data
|
||||
public class SelfHostedConfigRequest {
|
||||
@NotBlank private String domain;
|
||||
@NotBlank private String sshHost;
|
||||
private int sshPort = 22;
|
||||
@NotBlank private String sshUser;
|
||||
@NotBlank private String sshKey;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private Boolean nginxSslEnabled;
|
||||
}
|
||||
```
|
||||
|
||||
**File: `dto/SelfHostedConfigResponse.java`:**
|
||||
```java
|
||||
@Data @Builder
|
||||
public class SelfHostedConfigResponse {
|
||||
private UUID id;
|
||||
private UUID siteId;
|
||||
private String domain;
|
||||
private String sshHost;
|
||||
private int sshPort;
|
||||
private String sshUser;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private Boolean nginxSslEnabled;
|
||||
private LocalDateTime deployedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 12 — Frontend: Models
|
||||
|
||||
**File: `frontend/src/app/core/models/site.ts`** — add to `SiteResponse`:
|
||||
```typescript
|
||||
deploymentTarget: 'GIT_PAGES' | 'SELF_HOSTED' | 'NONE';
|
||||
```
|
||||
|
||||
**File: `frontend/src/app/core/models/deployment.ts`** — add:
|
||||
```typescript
|
||||
export interface SelfHostedConfigRequest {
|
||||
domain: string;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUser: string;
|
||||
sshKey: string;
|
||||
deployPath: string;
|
||||
nginxSitesPath: string;
|
||||
nginxSslEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SelfHostedConfigResponse {
|
||||
id: string;
|
||||
siteId: string;
|
||||
domain: string;
|
||||
sshHost: string;
|
||||
sshPort: number;
|
||||
sshUser: string;
|
||||
deployPath: string;
|
||||
nginxSitesPath: string;
|
||||
nginxSslEnabled: boolean;
|
||||
deployedAt: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 13 — Frontend: Site Service
|
||||
|
||||
**File: `frontend/src/app/core/services/site.service.ts`** — add:
|
||||
```typescript
|
||||
getSelfHostedConfig(siteId: string): Observable<SelfHostedConfigResponse> {
|
||||
return this.http.get<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`);
|
||||
}
|
||||
|
||||
saveSelfHostedConfig(siteId: string, config: SelfHostedConfigRequest): Observable<SelfHostedConfigResponse> {
|
||||
return this.http.put<SelfHostedConfigResponse>(`/api/sites/${siteId}/self-hosted`, config);
|
||||
}
|
||||
|
||||
deleteSelfHostedConfig(siteId: string): Observable<void> {
|
||||
return this.http.delete<void>(`/api/sites/${siteId}/self-hosted`);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 14 — Frontend: Settings Component
|
||||
|
||||
**File: `frontend/src/app/llm-workspace/settings/settings.component.ts`** — add:
|
||||
|
||||
1. **Deployment Target selector** (dropdown) — between "Git Pages", "Self-Hosted", "None". Shows status indicators for which targets are configured.
|
||||
|
||||
2. **Self-Hosted Config card** — expandable section with form fields for domain, SSH host, SSH port, SSH user, SSH key (textarea, masked), deploy path, nginx sites path. Save and Remove buttons.
|
||||
|
||||
3. **Publishing card update** — show which target will be used and the expected URL. Keep the publish/unpublish buttons.
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Prerequisites
|
||||
|
||||
The SSH user on the target server needs:
|
||||
|
||||
1. **Write access** to the `deployPath` directory
|
||||
2. **sudo NOPASSWD** for:
|
||||
```bash
|
||||
deploy-user ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, \
|
||||
/bin/ln -sf /etc/nginx/sites-available/* /etc/nginx/sites-enabled/, \
|
||||
/bin/rm /etc/nginx/sites-enabled/*, \
|
||||
/bin/rm /etc/nginx/sites-available/*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### Backend (existing, modified)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `model/Site.java` | Add `DeploymentTarget` enum + field |
|
||||
| `model/SiteStatus.java` | No change |
|
||||
| `dto/SiteRequest.java` | Add `deploymentTarget` |
|
||||
| `dto/SiteResponse.java` | Add `deploymentTarget` |
|
||||
| `service/KeyManager.java` | Add SSH key encrypt/decrypt methods |
|
||||
| `service/DeploymentService.java` | Dispatch logic for both targets |
|
||||
| `controller/SiteController.java` | Accept `deploymentTarget` in create/update |
|
||||
| `repository/SiteRepository.java` | No change (optional: add findByDeploymentTarget) |
|
||||
|
||||
### Backend (new files)
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `model/SelfHostedConfig.java` | Entity |
|
||||
| `repository/SelfHostedConfigRepository.java` | JPA repository |
|
||||
| `dto/SelfHostedConfigRequest.java` | Request DTO |
|
||||
| `dto/SelfHostedConfigResponse.java` | Response DTO |
|
||||
| `service/Deployer.java` | Deployer interface |
|
||||
| `service/SshRsyncDeployer.java` | SSH + rsync + nginx implementation |
|
||||
| `controller/SelfHostedController.java` | REST endpoints |
|
||||
|
||||
### Frontend (modified)
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `core/models/site.ts` | Add `deploymentTarget` |
|
||||
| `core/models/deployment.ts` | Add self-hosted config interfaces |
|
||||
| `core/services/site.service.ts` | Add self-hosted API methods |
|
||||
| `llm-workspace/settings/settings.component.ts` | Add target selector + self-hosted config card |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (Out of Scope)
|
||||
|
||||
- Automatic TLS via Let's Encrypt / Certbot integration
|
||||
- Multiple server targets per site
|
||||
- Health checks / monitoring
|
||||
- Rollback on server (keep previous deploy versions)
|
||||
- Staged deployments (staging → production)
|
||||
22
PLAN.md
22
PLAN.md
@@ -1,6 +1,6 @@
|
||||
# Indie — Independent Website Platform
|
||||
|
||||
A platform that lets anyone create, own, and publish their own static website through AI prompts. No drag-and-drop builder — just describe what you want, and the AI generates it. Every site is backed by a git repository. Users self-host or use free subdomain hosting.
|
||||
A platform that lets anyone create, own, and publish their own static website through AI prompts. No drag-and-drop builder — just describe what you want, and the AI generates it. Every site is backed by a git repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,7 +75,7 @@ Prompt + Reference URLs
|
||||
| `com/indie/config/JwtConfig.java` | Add | RS256 signing, access token 15min, refresh token 7d |
|
||||
| `com/indie/config/GitConfig.java` | Add | Repos root path (`/var/git/sites/`), author name/email |
|
||||
| `com/indie/model/User.java` | Add | id(UUID), email, password(bcrypt, nullable), name, avatarUrl, provider(LOCAL/GOOGLE/GITHUB/OPENID), providerId, emailVerified, createdAt, updatedAt |
|
||||
| `com/indie/model/Site.java` | Add | id, userId, name, description, subdomain(unique), status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt |
|
||||
| `com/indie/model/Site.java` | Add | id, userId, name, description, status(DRAFT/PUBLISHED), publishedAt, createdAt, updatedAt |
|
||||
| `com/indie/repository/*.java` | Add | JPA repositories for User, Site |
|
||||
| `com/indie/service/AuthService.java` | Add | register, login, refreshToken, oauthLogin |
|
||||
| `com/indie/service/OAuth2UserService.java` | Add | Maps Google/GitHub/OpenID user info to User entity |
|
||||
@@ -151,7 +151,7 @@ Prompt + Reference URLs
|
||||
|
||||
### v0.3 — Export, Git & Hosting (Target: Weeks 8-11)
|
||||
|
||||
**Goal:** User can publish their site as pure static files. Every publish creates a git commit. User can connect GitHub/GitLab for automatic pushes. Sites served at `*.indie.com` for free.
|
||||
**Goal:** User can publish their site as pure static files. Every publish creates a git commit. User can connect GitHub/GitLab for automatic pushes.
|
||||
|
||||
#### Backend
|
||||
|
||||
@@ -178,13 +178,13 @@ Prompt + Reference URLs
|
||||
| File | Action | Details |
|
||||
|------|--------|---------|
|
||||
| `src/app/workspace/git-settings/` | Add | GitSettingsComponent — connect/disconnect GitHub/GitLab OAuth, select remote repo, connection status indicator |
|
||||
| `src/app/workspace/settings/` | Add | SettingsComponent — site name, subdomain, export zip button, publish/unpublish buttons |
|
||||
| `src/app/workspace/settings/` | Add | SettingsComponent — site name, export zip button, publish/unpublish buttons |
|
||||
|
||||
#### Infrastructure
|
||||
|
||||
| File | Action | Details |
|
||||
|------|--------|---------|
|
||||
| `infra/cloudflare-worker.js` | Add | Routes `*.indie.com` → R2 bucket `indie-sites/{subdomain}/` |
|
||||
| `infra/cloudflare-worker.js` | TBD | Routes custom domains → R2 bucket |
|
||||
| `docker-compose.prod.yml` | Add | Production services: PostgreSQL, MongoDB, backend (×2), nginx, certbot |
|
||||
|
||||
**Git directory structure on server:**
|
||||
@@ -225,9 +225,9 @@ site-export/
|
||||
| File | Action | Details |
|
||||
|------|--------|---------|
|
||||
| `com/indie/model/Comment.java` | Add | JPA: id(UUID), siteId, pageId, authorName, authorEmail, content, isApproved, parentCommentId, createdAt |
|
||||
| `com/indie/controller/CommentController.java` | Add | Public: `POST /api/sites/{subdomain}/comments`. Authenticated: `GET/PUT/DELETE` moderation |
|
||||
| `com/indie/controller/CommentController.java` | Add | Public: `POST /api/sites/{siteId}/comments`. Authenticated: `GET/PUT/DELETE` moderation |
|
||||
| `com/indie/service/RssService.java` | Add | `generateRss(siteId) → String` — RSS 2.0 feed from site pages |
|
||||
| `com/indie/controller/RssController.java` | Add | `GET /api/feed/{subdomain}` → `application/rss+xml`, 1h cache |
|
||||
| `com/indie/controller/RssController.java` | Add | `GET /api/feed/{siteId}` → `application/rss+xml`, 1h cache |
|
||||
|
||||
#### Frontend
|
||||
|
||||
@@ -243,13 +243,13 @@ site-export/
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Cloudflare CDN │
|
||||
│ *.indie.com → R2 │
|
||||
│ custom domains → R2 │
|
||||
└────────┬─────────────┘
|
||||
│
|
||||
┌────────▼─────────────┐
|
||||
│ Cloudflare R2 │
|
||||
│ indie-sites bucket │
|
||||
│ /{subdomain}/* │
|
||||
│ /{siteId}/* │
|
||||
└──────────────────────┘
|
||||
▲
|
||||
┌────────┴─────────────┐
|
||||
@@ -309,7 +309,7 @@ Adding a new provider = implement `LLMProvider` interface + add config block.
|
||||
|
||||
5. **Content editor** — Text edits happen in a structured form grouped by page, not inside the iframe. Style/structure changes go through prompts.
|
||||
|
||||
6. **No billing in MVP** — Subdomain hosting is free. Monetization is post-MVP.
|
||||
6. **No billing in MVP** — Monetization is post-MVP.
|
||||
|
||||
---
|
||||
|
||||
@@ -319,7 +319,7 @@ Adding a new provider = implement `LLMProvider` interface + add config block.
|
||||
|---------|-------------|
|
||||
| v0.1 | Register, login (email + OAuth), refresh token, site CRUD, 401 on unauthenticated |
|
||||
| v0.2 | Prompt → valid SiteStructure generated, preview renders correctly, version timeline shows history, restore works, content editor updates preview, refinement prompt modifies existing structure, reference URLs included in generation |
|
||||
| v0.3 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds, subdomain serves content |
|
||||
| v0.3 | Exported zip opens in browser identically to preview, git init + commit works, git push to remote works, R2 upload succeeds |
|
||||
| v0.4 | Comment submit → approve → display, RSS validates, rollback restores previous version |
|
||||
|
||||
---
|
||||
|
||||
10
README.md
10
README.md
@@ -1,6 +1,6 @@
|
||||
# Indie — Independent Website Platform
|
||||
|
||||
A platform that lets anyone create, own, and publish their own static website without social media gatekeeping. Built with a drag-and-drop builder and LLM prompt-to-site generation. Every site is backed by a git repository. Users self-host or use free subdomain hosting.
|
||||
A platform that lets anyone create, own, and publish their own static website without social media gatekeeping. Built with a drag-and-drop builder and LLM prompt-to-site generation. Every site is backed by a git repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -137,10 +137,8 @@ frontend/
|
||||
├── auth/
|
||||
│ ├── login/
|
||||
│ │ └── login.component.ts # Email/password login form
|
||||
│ ├── register/
|
||||
│ │ └── register.component.ts # Name/email/password register form
|
||||
│ └── oauth-callback/
|
||||
│ └── oauth-callback.component.ts # Reads tokens from OAuth redirect
|
||||
│ └── register/
|
||||
│ └── register.component.ts # Name/email/password register form
|
||||
└── dashboard/
|
||||
├── dashboard.component.ts # Site cards grid + toolbar
|
||||
├── create-site-dialog/
|
||||
@@ -161,8 +159,6 @@ frontend/
|
||||
| POST | `/api/auth/login` | Login with email + password |
|
||||
| POST | `/api/auth/refresh` | Refresh access token |
|
||||
| POST | `/api/auth/logout` | Logout |
|
||||
| GET | `/oauth2/authorization/google` | Login with Google |
|
||||
| GET | `/oauth2/authorization/github` | Login with GitHub |
|
||||
|
||||
### Sites (`/api/sites`)
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
|
||||
implementation "com.bucket4j:bucket4j-core:8.10.1"
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.krrishg;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class IndieApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
@Component
|
||||
public class EncryptionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EncryptionService.class);
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12;
|
||||
private static final int GCM_TAG_LENGTH = 128;
|
||||
|
||||
private final String masterKeyPath;
|
||||
private SecretKey key;
|
||||
|
||||
public EncryptionService(
|
||||
@Value("${indie.encryption.master-key-path:config/encryption.key}") String masterKeyPath) {
|
||||
this.masterKeyPath = masterKeyPath;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
Path path = Path.of(masterKeyPath);
|
||||
if (Files.exists(path)) {
|
||||
try {
|
||||
byte[] encoded = Files.readAllBytes(path);
|
||||
this.key = new SecretKeySpec(Base64.getDecoder().decode(encoded), "AES");
|
||||
log.info("Loaded encryption key from {}", masterKeyPath);
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load encryption key from {}, generating new one: {}", masterKeyPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(256);
|
||||
this.key = kg.generateKey();
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.write(path, Base64.getEncoder().encode(key.getEncoded()));
|
||||
log.info("Generated and saved encryption key to {}", masterKeyPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to persist encryption key, using ephemeral key (data lost on restart): {}", e.getMessage());
|
||||
try {
|
||||
KeyGenerator kg = KeyGenerator.getInstance("AES");
|
||||
kg.init(256);
|
||||
this.key = kg.generateKey();
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to generate encryption key", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plaintext) {
|
||||
try {
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
SecureRandom.getInstanceStrong().nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes());
|
||||
ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length);
|
||||
buffer.put(iv);
|
||||
buffer.put(ciphertext);
|
||||
return Base64.getEncoder().encodeToString(buffer.array());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Encryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String encrypted) {
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(Base64.getDecoder().decode(encrypted));
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
buffer.get(iv);
|
||||
byte[] ciphertext = new byte[buffer.remaining()];
|
||||
buffer.get(ciphertext);
|
||||
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||
return new String(cipher.doFinal(ciphertext));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decryption failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -16,6 +17,11 @@ public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(JwtException.class)
|
||||
public ProblemDetail handleJwtException(JwtException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Converter
|
||||
public class MapToJsonConverter implements AttributeConverter<Map<String, String>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, String> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to serialize map to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
try {
|
||||
return MAPPER.readValue(dbData, new TypeReference<Map<String, String>>() {});
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to deserialize JSON to map", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class SchemaMigration {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public SchemaMigration(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void migrate() {
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE self_hosted_configs DROP COLUMN IF EXISTS encrypted_ssh_key");
|
||||
log.info("Dropped deprecated column encrypted_ssh_key from self_hosted_configs");
|
||||
} 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());
|
||||
}
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_provider_check");
|
||||
log.info("Dropped provider check constraint from users");
|
||||
} catch (Exception e) {
|
||||
log.warn("Could not drop provider check constraint: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import com.krrishg.service.OAuth2SuccessHandler;
|
||||
import com.krrishg.service.OAuth2UserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -34,23 +32,12 @@ import java.util.UUID;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private final OAuth2UserService oAuth2UserService;
|
||||
private final OAuth2SuccessHandler oAuth2SuccessHandler;
|
||||
|
||||
@Value("${indie.cors.allowed-origins:http://localhost:4200}")
|
||||
private String[] allowedOrigins;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.google.client-id:}")
|
||||
private String googleClientId;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.github.client-id:}")
|
||||
private String githubClientId;
|
||||
|
||||
public SecurityConfig(JwtConfig jwtConfig, OAuth2UserService oAuth2UserService,
|
||||
OAuth2SuccessHandler oAuth2SuccessHandler) {
|
||||
public SecurityConfig(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.oAuth2UserService = oAuth2UserService;
|
||||
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -72,25 +59,11 @@ public class SecurityConfig {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden"))
|
||||
);
|
||||
|
||||
if (isOAuth2Configured()) {
|
||||
http.oauth2Login(oauth2 -> oauth2
|
||||
.userInfoEndpoint(userInfo -> userInfo
|
||||
.userService(oAuth2UserService)
|
||||
)
|
||||
.successHandler(oAuth2SuccessHandler)
|
||||
);
|
||||
}
|
||||
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private boolean isOAuth2Configured() {
|
||||
return (googleClientId != null && !googleClientId.isBlank())
|
||||
|| (githubClientId != null && !githubClientId.isBlank());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
@@ -134,9 +107,6 @@ public class SecurityConfig {
|
||||
} catch (Exception e) {
|
||||
org.springframework.security.core.context.SecurityContextHolder
|
||||
.clearContext();
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
|
||||
"Invalid or expired token");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import com.krrishg.dto.AuthResponse;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.service.AuthService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -24,8 +29,8 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
AuthResponse response = authService.register(request);
|
||||
public ResponseEntity<RegistrationResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
RegistrationResponse response = authService.register(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
}
|
||||
|
||||
@@ -45,4 +50,22 @@ public class AuthController {
|
||||
public ResponseEntity<Void> logout() {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/verify-email")
|
||||
public ResponseEntity<VerifyEmailResponse> verifyEmail(@Valid @RequestBody VerifyEmailRequest request) {
|
||||
VerifyEmailResponse response = authService.verifyEmail(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/resend-verification")
|
||||
public ResponseEntity<RegistrationResponse> resendVerification(@Valid @RequestBody ResendVerificationRequest request) {
|
||||
RegistrationResponse response = authService.resendVerification(request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/set-password")
|
||||
public ResponseEntity<Void> setPassword(@Valid @RequestBody SetPasswordRequest request) {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.DeploymentService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -14,15 +16,19 @@ import java.util.UUID;
|
||||
public class DeploymentController {
|
||||
|
||||
private final DeploymentService deploymentService;
|
||||
private final SiteRepository siteRepository;
|
||||
|
||||
public DeploymentController(DeploymentService deploymentService) {
|
||||
public DeploymentController(DeploymentService deploymentService,
|
||||
SiteRepository siteRepository) {
|
||||
this.deploymentService = deploymentService;
|
||||
this.siteRepository = siteRepository;
|
||||
}
|
||||
|
||||
@PostMapping("/publish")
|
||||
public ResponseEntity<?> publish(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
findSiteForUser(id, principal.id());
|
||||
try {
|
||||
String url = deploymentService.publish(id);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
@@ -38,7 +44,17 @@ public class DeploymentController {
|
||||
public ResponseEntity<?> unpublish(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
findSiteForUser(id, principal.id());
|
||||
deploymentService.unpublish(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Site unpublished successfully"));
|
||||
}
|
||||
|
||||
private Site findSiteForUser(UUID siteId, UUID userId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
if (!site.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("Site not found");
|
||||
}
|
||||
return site;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class ExportController {
|
||||
SiteStructure structure = llmService.restoreVersion(versions.get(0).getId());
|
||||
try {
|
||||
byte[] zip = exportService.exportSite(structure);
|
||||
String filename = site.getSubdomain() != null ? site.getSubdomain() : site.getName().replaceAll("\\s+", "-");
|
||||
String filename = site.getName().replaceAll("\\s+", "-");
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + sanitizeFilename(filename) + "-export.zip\"")
|
||||
|
||||
@@ -53,14 +53,28 @@ public class LLMController {
|
||||
}
|
||||
UUID siteId = UUID.fromString(siteIdStr);
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
List<Reference> references = null;
|
||||
if (request.containsKey("references")) {
|
||||
references = objectMapper.convertValue(
|
||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||
}
|
||||
|
||||
SiteStructure structure = llmService.generateSite(principal.id(), siteId, prompt, references);
|
||||
try {
|
||||
SiteStructure structure = llmService.generateSite(
|
||||
principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/refine")
|
||||
@@ -85,6 +99,15 @@ public class LLMController {
|
||||
}
|
||||
UUID siteId = UUID.fromString(siteIdStr);
|
||||
|
||||
String provider = (String) request.get("provider");
|
||||
if (provider == null || provider.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "provider is required"));
|
||||
}
|
||||
|
||||
String apiKey = (String) request.get("apiKey");
|
||||
String baseUrl = (String) request.get("baseUrl");
|
||||
String model = (String) request.get("model");
|
||||
|
||||
SiteStructure currentStructure;
|
||||
try {
|
||||
currentStructure = objectMapper.convertValue(
|
||||
@@ -103,9 +126,13 @@ public class LLMController {
|
||||
request.get("references"), new TypeReference<List<Reference>>() {});
|
||||
}
|
||||
|
||||
try {
|
||||
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
|
||||
currentStructure, prompt, references);
|
||||
currentStructure, prompt, references, provider, apiKey, baseUrl, model);
|
||||
return ResponseEntity.ok(structure);
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/versions/{siteId}")
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.SelfHostedConfigRequest;
|
||||
import com.krrishg.dto.SelfHostedConfigResponse;
|
||||
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 jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sites/{id}/self-hosted")
|
||||
public class SelfHostedController {
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
private final Deployer deployer;
|
||||
private final KeyManager keyManager;
|
||||
|
||||
public SelfHostedController(SiteRepository siteRepository,
|
||||
SelfHostedConfigRepository selfHostedConfigRepository,
|
||||
Deployer deployer,
|
||||
KeyManager keyManager) {
|
||||
this.siteRepository = siteRepository;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
this.deployer = deployer;
|
||||
this.keyManager = keyManager;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<?> getConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
findSiteForUser(id, principal.id());
|
||||
var config = selfHostedConfigRepository.findBySiteId(id);
|
||||
if (config.isPresent()) {
|
||||
String publicKey = keyManager.getPublicKey(id.toString());
|
||||
return ResponseEntity.ok(SelfHostedConfigResponse.from(config.get(), publicKey));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("configured", false));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PutMapping
|
||||
public ResponseEntity<?> saveConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody SelfHostedConfigRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
selfHostedConfigRepository.findBySiteId(id).ifPresent(existing -> {
|
||||
selfHostedConfigRepository.delete(existing);
|
||||
selfHostedConfigRepository.flush();
|
||||
});
|
||||
|
||||
String deployPath = request.getDeployPath() != null && !request.getDeployPath().isBlank()
|
||||
? request.getDeployPath()
|
||||
: "/var/www/{domain}/public";
|
||||
|
||||
String nginxSitesPath = request.getNginxSitesPath() != null && !request.getNginxSitesPath().isBlank()
|
||||
? request.getNginxSitesPath()
|
||||
: "/etc/nginx/sites-available";
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(id)
|
||||
.domain(request.getDomain())
|
||||
.sshHost(request.getSshHost())
|
||||
.sshPort(request.getSshPort())
|
||||
.sshUser(request.getSshUser())
|
||||
.deployPath(deployPath)
|
||||
.nginxSitesPath(nginxSitesPath)
|
||||
.sslEnabled(request.isSslEnabled())
|
||||
.sslEmail(request.getSslEmail())
|
||||
.build();
|
||||
|
||||
config = selfHostedConfigRepository.save(config);
|
||||
|
||||
String publicKey = keyManager.getPublicKey(id.toString());
|
||||
|
||||
return ResponseEntity.ok(SelfHostedConfigResponse.from(config, publicKey));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@DeleteMapping
|
||||
public ResponseEntity<?> removeConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
SelfHostedConfig config = selfHostedConfigRepository.findBySiteId(id).orElse(null);
|
||||
if (config != null) {
|
||||
try {
|
||||
deployer.remove(config);
|
||||
} catch (Exception e) {
|
||||
}
|
||||
selfHostedConfigRepository.delete(config);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Self-hosted configuration removed"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@PostMapping("/deploy")
|
||||
public ResponseEntity<?> deploy(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable UUID id) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED)
|
||||
.body(Map.of("error", "Use POST /api/sites/{id}/publish instead"));
|
||||
}
|
||||
|
||||
private Site findSiteForUser(UUID siteId, UUID userId) {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
if (!site.getUserId().equals(userId)) {
|
||||
throw new IllegalArgumentException("Site not found");
|
||||
}
|
||||
return site;
|
||||
}
|
||||
}
|
||||
@@ -36,16 +36,13 @@ public class SiteController {
|
||||
@PostMapping
|
||||
public ResponseEntity<SiteResponse> createSite(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody SiteRequest request) {
|
||||
String subdomain = sanitizeSubdomain(request.getSubdomain());
|
||||
if (subdomain != null && siteRepository.existsBySubdomain(subdomain)) {
|
||||
throw new IllegalArgumentException("Subdomain already taken");
|
||||
}
|
||||
Site.DeploymentTarget target = parseDeploymentTarget(request.getDeploymentTarget());
|
||||
|
||||
Site site = Site.builder()
|
||||
.userId(principal.id())
|
||||
.name(request.getName())
|
||||
.description(request.getDescription())
|
||||
.subdomain(subdomain)
|
||||
.deploymentTarget(target)
|
||||
.build();
|
||||
|
||||
site = siteRepository.save(site);
|
||||
@@ -65,16 +62,12 @@ public class SiteController {
|
||||
@Valid @RequestBody SiteRequest request) {
|
||||
Site site = findSiteForUser(id, principal.id());
|
||||
|
||||
String newSubdomain = sanitizeSubdomain(request.getSubdomain());
|
||||
boolean subdomainChanged = (site.getSubdomain() != null && !site.getSubdomain().equals(newSubdomain))
|
||||
|| (site.getSubdomain() == null && newSubdomain != null);
|
||||
if (subdomainChanged && newSubdomain != null && siteRepository.existsBySubdomain(newSubdomain)) {
|
||||
throw new IllegalArgumentException("Subdomain already taken");
|
||||
}
|
||||
|
||||
site.setName(request.getName());
|
||||
site.setDescription(request.getDescription());
|
||||
site.setSubdomain(newSubdomain);
|
||||
|
||||
if (request.getDeploymentTarget() != null) {
|
||||
site.setDeploymentTarget(parseDeploymentTarget(request.getDeploymentTarget()));
|
||||
}
|
||||
|
||||
site = siteRepository.save(site);
|
||||
return ResponseEntity.ok(SiteResponse.from(site));
|
||||
@@ -97,14 +90,14 @@ public class SiteController {
|
||||
return site;
|
||||
}
|
||||
|
||||
private String sanitizeSubdomain(String subdomain) {
|
||||
if (subdomain == null || subdomain.isBlank()) {
|
||||
return null;
|
||||
private Site.DeploymentTarget parseDeploymentTarget(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return Site.DeploymentTarget.NONE;
|
||||
}
|
||||
try {
|
||||
return Site.DeploymentTarget.valueOf(value.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Site.DeploymentTarget.NONE;
|
||||
}
|
||||
String cleaned = subdomain.strip().toLowerCase();
|
||||
if (!cleaned.matches("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")) {
|
||||
throw new IllegalArgumentException("Subdomain must be 3-63 characters, lowercase alphanumeric with hyphens");
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.LLMConfigRequest;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
public class UserSettingsController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
|
||||
public UserSettingsController(UserRepository userRepository, EncryptionService encryptionService) {
|
||||
this.userRepository = userRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
}
|
||||
|
||||
@GetMapping("/llm-config")
|
||||
public ResponseEntity<Map<String, Object>> getLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> keys = user.getLlmApiKeys();
|
||||
Map<String, String> urls = user.getLlmBaseUrls();
|
||||
Map<String, String> models = user.getLlmModels();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (keys != null) {
|
||||
for (String provider : keys.keySet()) {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put("configured", true);
|
||||
if (urls != null && urls.containsKey(provider)) {
|
||||
config.put("baseUrl", urls.get(provider));
|
||||
}
|
||||
if (models != null && models.containsKey(provider)) {
|
||||
config.put("model", models.get(provider));
|
||||
}
|
||||
result.put(provider, config);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PutMapping("/llm-config")
|
||||
public ResponseEntity<Void> saveLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody LLMConfigRequest request) {
|
||||
if (request.provider() == null || request.provider().isBlank()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
if (request.apiKey() != null && !request.apiKey().isBlank()) {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
if (user.getLlmApiKeys() != null) {
|
||||
keys.putAll(user.getLlmApiKeys());
|
||||
}
|
||||
keys.put(request.provider(), encryptionService.encrypt(request.apiKey()));
|
||||
user.setLlmApiKeys(keys);
|
||||
}
|
||||
|
||||
if (request.baseUrl() != null && !request.baseUrl().isBlank()) {
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
if (user.getLlmBaseUrls() != null) {
|
||||
urls.putAll(user.getLlmBaseUrls());
|
||||
}
|
||||
urls.put(request.provider(), request.baseUrl().trim());
|
||||
user.setLlmBaseUrls(urls);
|
||||
}
|
||||
|
||||
if (request.model() != null && !request.model().isBlank()) {
|
||||
Map<String, String> models = new HashMap<>();
|
||||
if (user.getLlmModels() != null) {
|
||||
models.putAll(user.getLlmModels());
|
||||
}
|
||||
models.put(request.provider(), request.model().trim());
|
||||
user.setLlmModels(models);
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@DeleteMapping("/llm-config/{provider}")
|
||||
public ResponseEntity<Void> deleteLlmConfig(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String provider) {
|
||||
User user = userRepository.findById(principal.id())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
if (user.getLlmApiKeys() != null) {
|
||||
keys.putAll(user.getLlmApiKeys());
|
||||
}
|
||||
keys.remove(provider);
|
||||
user.setLlmApiKeys(keys);
|
||||
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
if (user.getLlmBaseUrls() != null) {
|
||||
urls.putAll(user.getLlmBaseUrls());
|
||||
}
|
||||
urls.remove(provider);
|
||||
user.setLlmBaseUrls(urls);
|
||||
|
||||
Map<String, String> models = new HashMap<>();
|
||||
if (user.getLlmModels() != null) {
|
||||
models.putAll(user.getLlmModels());
|
||||
}
|
||||
models.remove(provider);
|
||||
user.setLlmModels(models);
|
||||
|
||||
userRepository.save(user);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -32,6 +31,5 @@ public class AuthResponse {
|
||||
private String email;
|
||||
private String name;
|
||||
private String avatarUrl;
|
||||
private AuthProvider provider;
|
||||
}
|
||||
}
|
||||
|
||||
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
public record LLMConfigRequest(String provider, String apiKey, String baseUrl, String model) {
|
||||
|
||||
public LLMConfigRequest(String provider, String apiKey) {
|
||||
this(provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public LLMConfigRequest(String provider, String apiKey, String baseUrl) {
|
||||
this(provider, apiKey, baseUrl, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class RegistrationResponse {
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ResendVerificationRequest {
|
||||
@NotBlank
|
||||
@Email
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SelfHostedConfigRequest {
|
||||
|
||||
@NotBlank(message = "Domain is required")
|
||||
private String domain;
|
||||
|
||||
@NotBlank(message = "SSH host is required")
|
||||
private String sshHost;
|
||||
|
||||
private int sshPort = 22;
|
||||
|
||||
@NotBlank(message = "SSH user is required")
|
||||
private String sshUser;
|
||||
|
||||
private String deployPath;
|
||||
|
||||
private String nginxSitesPath;
|
||||
|
||||
private boolean sslEnabled;
|
||||
|
||||
private String sslEmail;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SelfHostedConfigResponse {
|
||||
|
||||
private UUID id;
|
||||
private UUID siteId;
|
||||
private String domain;
|
||||
private String sshHost;
|
||||
private int sshPort;
|
||||
private String sshUser;
|
||||
private String deployPath;
|
||||
private String nginxSitesPath;
|
||||
private boolean sslEnabled;
|
||||
private String sslEmail;
|
||||
private String publicKey;
|
||||
private LocalDateTime deployedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public static SelfHostedConfigResponse from(SelfHostedConfig config, String publicKey) {
|
||||
return SelfHostedConfigResponse.builder()
|
||||
.id(config.getId())
|
||||
.siteId(config.getSiteId())
|
||||
.domain(config.getDomain())
|
||||
.sshHost(config.getSshHost())
|
||||
.sshPort(config.getSshPort())
|
||||
.sshUser(config.getSshUser())
|
||||
.deployPath(config.getDeployPath())
|
||||
.nginxSitesPath(config.getNginxSitesPath())
|
||||
.sslEnabled(config.isSslEnabled())
|
||||
.sslEmail(config.getSslEmail())
|
||||
.publicKey(publicKey)
|
||||
.deployedAt(config.getDeployedAt())
|
||||
.createdAt(config.getCreatedAt())
|
||||
.updatedAt(config.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SetPasswordRequest {
|
||||
@NotBlank
|
||||
private String setupToken;
|
||||
|
||||
@NotBlank
|
||||
@Size(min = 8, max = 100)
|
||||
private String password;
|
||||
}
|
||||
@@ -11,5 +11,5 @@ public class SiteRequest {
|
||||
|
||||
private String description;
|
||||
|
||||
private String subdomain;
|
||||
private String deploymentTarget;
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ public class SiteResponse {
|
||||
private UUID userId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String subdomain;
|
||||
private String publishedUrl;
|
||||
private SiteStatus status;
|
||||
private String deploymentTarget;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
@@ -31,9 +31,9 @@ public class SiteResponse {
|
||||
.userId(site.getUserId())
|
||||
.name(site.getName())
|
||||
.description(site.getDescription())
|
||||
.subdomain(site.getSubdomain())
|
||||
.publishedUrl(site.getPublishedUrl())
|
||||
.status(site.getStatus())
|
||||
.deploymentTarget(site.getDeploymentTarget().name())
|
||||
.publishedAt(site.getPublishedAt())
|
||||
.createdAt(site.getCreatedAt())
|
||||
.updatedAt(site.getUpdatedAt())
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class VerifyEmailRequest {
|
||||
@NotBlank
|
||||
private String token;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class VerifyEmailResponse {
|
||||
private String message;
|
||||
private String setupToken;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
public enum AuthProvider {
|
||||
LOCAL, GOOGLE, GITHUB, OPENID
|
||||
LOCAL
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "self_hosted_configs")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SelfHostedConfig {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "site_id", nullable = false, unique = true)
|
||||
private UUID siteId;
|
||||
|
||||
@Column(name = "domain", nullable = false)
|
||||
private String domain;
|
||||
|
||||
@Column(name = "ssh_host", nullable = false)
|
||||
private String sshHost;
|
||||
|
||||
@Column(name = "ssh_port", nullable = false)
|
||||
@Builder.Default
|
||||
private int sshPort = 22;
|
||||
|
||||
@Column(name = "ssh_user", nullable = false)
|
||||
private String sshUser;
|
||||
|
||||
@Column(name = "deploy_path", nullable = false)
|
||||
@Builder.Default
|
||||
private String deployPath = "/var/www/{domain}/public";
|
||||
|
||||
@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(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -18,35 +18,48 @@ 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)
|
||||
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(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;
|
||||
|
||||
public DeploymentTarget getDeploymentTarget() {
|
||||
return deploymentTarget != null ? deploymentTarget : DeploymentTarget.NONE;
|
||||
}
|
||||
|
||||
public enum DeploymentTarget {
|
||||
GIT_PAGES, SELF_HOSTED, NONE
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (id == null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import com.krrishg.config.MapToJsonConverter;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -7,6 +8,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -18,35 +20,53 @@ 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)
|
||||
private AuthProvider provider;
|
||||
|
||||
private String providerId;
|
||||
|
||||
@Column(name = "email_verified")
|
||||
@Builder.Default
|
||||
private boolean emailVerified = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "provider", nullable = false)
|
||||
@Builder.Default
|
||||
private AuthProvider provider = AuthProvider.LOCAL;
|
||||
|
||||
@Column(name = "provider_id")
|
||||
private String providerId;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_api_keys", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmApiKeys;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_base_urls", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmBaseUrls;
|
||||
|
||||
@Convert(converter = MapToJsonConverter.class)
|
||||
@Column(name = "llm_models", columnDefinition = "TEXT")
|
||||
private Map<String, String> llmModels;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "verification_tokens")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class VerificationToken {
|
||||
|
||||
@Id
|
||||
@Column(name = "id")
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "token", nullable = false, unique = true)
|
||||
private String token;
|
||||
|
||||
@Column(name = "setup_token")
|
||||
private String setupToken;
|
||||
|
||||
@Column(name = "setup_token_used")
|
||||
@Builder.Default
|
||||
private boolean setupTokenUsed = false;
|
||||
|
||||
@Column(name = "expiry_date", nullable = false)
|
||||
private LocalDateTime expiryDate;
|
||||
|
||||
@Column(name = "used")
|
||||
@Builder.Default
|
||||
private boolean used = false;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@PrePersist
|
||||
public void onCreate() {
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
createdAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SelfHostedConfigRepository extends JpaRepository<SelfHostedConfig, UUID> {
|
||||
|
||||
Optional<SelfHostedConfig> findBySiteId(UUID siteId);
|
||||
|
||||
Optional<SelfHostedConfig> findByDomain(String domain);
|
||||
|
||||
boolean existsBySiteId(UUID siteId);
|
||||
|
||||
boolean existsByDomain(String domain);
|
||||
}
|
||||
@@ -4,14 +4,9 @@ import com.krrishg.model.Site;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface SiteRepository extends JpaRepository<Site, UUID> {
|
||||
|
||||
List<Site> findByUserIdOrderByCreatedAtDesc(UUID userId);
|
||||
|
||||
Optional<Site> findBySubdomain(String subdomain);
|
||||
|
||||
boolean existsBySubdomain(String subdomain);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
@@ -11,7 +10,5 @@ public interface UserRepository extends JpaRepository<User, UUID> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
Optional<User> findByProviderAndProviderId(AuthProvider provider, String providerId);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface VerificationTokenRepository extends JpaRepository<VerificationToken, UUID> {
|
||||
|
||||
Optional<VerificationToken> findByToken(String token);
|
||||
|
||||
Optional<VerificationToken> findBySetupToken(String setupToken);
|
||||
|
||||
void deleteByUserId(UUID userId);
|
||||
|
||||
List<VerificationToken> findByUsedFalseAndExpiryDateBefore(LocalDateTime now);
|
||||
}
|
||||
@@ -4,44 +4,83 @@ import com.krrishg.config.TokenService;
|
||||
import com.krrishg.dto.AuthResponse;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.repository.VerificationTokenRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final TokenService jwtConfig;
|
||||
private final VerificationTokenRepository verificationTokenRepository;
|
||||
private final EmailService emailService;
|
||||
private final int tokenExpirationMinutes;
|
||||
|
||||
public AuthService(UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
TokenService jwtConfig,
|
||||
VerificationTokenRepository verificationTokenRepository,
|
||||
EmailService emailService,
|
||||
@Value("${indie.app.verification-token-expiration-minutes}") int tokenExpirationMinutes) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.verificationTokenRepository = verificationTokenRepository;
|
||||
this.emailService = emailService;
|
||||
this.tokenExpirationMinutes = tokenExpirationMinutes;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
public RegistrationResponse register(RegisterRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new IllegalArgumentException("Email already registered");
|
||||
Optional<User> existingUser = userRepository.findByEmail(email);
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
if (existingUser.get().isEmailVerified()) {
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
User user = existingUser.get();
|
||||
verificationTokenRepository.deleteByUserId(user.getId());
|
||||
VerificationToken token = createVerificationToken(user.getId());
|
||||
verificationTokenRepository.save(token);
|
||||
emailService.sendVerificationEmail(user.getEmail(), user.getName(), token.getToken());
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
User user = User.builder()
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(request.getPassword()))
|
||||
.name(request.getName())
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
|
||||
user = userRepository.save(user);
|
||||
return generateAuthResponse(user);
|
||||
|
||||
VerificationToken token = createVerificationToken(user.getId());
|
||||
verificationTokenRepository.save(token);
|
||||
emailService.sendVerificationEmail(user.getEmail(), user.getName(), token.getToken());
|
||||
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
@@ -49,17 +88,99 @@ public class AuthService {
|
||||
User user = userRepository.findByEmail(email)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid email or password"));
|
||||
|
||||
if (user.getProvider() != AuthProvider.LOCAL) {
|
||||
throw new IllegalArgumentException("Please sign in with " + user.getProvider().name().toLowerCase());
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||
throw new IllegalArgumentException("Invalid email or password");
|
||||
}
|
||||
|
||||
if (!user.isEmailVerified()) {
|
||||
throw new IllegalArgumentException("Please verify your email address before logging in");
|
||||
}
|
||||
|
||||
return generateAuthResponse(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public VerifyEmailResponse verifyEmail(VerifyEmailRequest request) {
|
||||
VerificationToken token = verificationTokenRepository.findByToken(request.getToken())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid verification token"));
|
||||
|
||||
if (token.isUsed()) {
|
||||
throw new IllegalArgumentException("Verification token has already been used");
|
||||
}
|
||||
|
||||
if (token.getExpiryDate().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalArgumentException("Verification token has expired");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(token.getUserId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
token.setUsed(true);
|
||||
String setupToken = UUID.randomUUID().toString();
|
||||
token.setSetupToken(setupToken);
|
||||
token.setSetupTokenUsed(false);
|
||||
verificationTokenRepository.save(token);
|
||||
|
||||
return VerifyEmailResponse.builder()
|
||||
.message("Email verified successfully")
|
||||
.setupToken(setupToken)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RegistrationResponse resendVerification(ResendVerificationRequest request) {
|
||||
String email = request.getEmail().toLowerCase().strip();
|
||||
|
||||
Optional<User> existingUser = userRepository.findByEmail(email);
|
||||
|
||||
if (existingUser.isEmpty()) {
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
User user = existingUser.get();
|
||||
|
||||
if (user.isEmailVerified()) {
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
verificationTokenRepository.deleteByUserId(user.getId());
|
||||
VerificationToken token = createVerificationToken(user.getId());
|
||||
verificationTokenRepository.save(token);
|
||||
emailService.sendVerificationEmail(user.getEmail(), user.getName(), token.getToken());
|
||||
|
||||
return new RegistrationResponse("If this email is available, a verification link has been sent.");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void setPassword(SetPasswordRequest request) {
|
||||
VerificationToken token = verificationTokenRepository.findBySetupToken(request.getSetupToken())
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid setup token"));
|
||||
|
||||
if (!token.isUsed()) {
|
||||
throw new IllegalArgumentException("Email must be verified first");
|
||||
}
|
||||
|
||||
if (token.isSetupTokenUsed()) {
|
||||
throw new IllegalArgumentException("Setup token has already been used");
|
||||
}
|
||||
|
||||
if (token.getExpiryDate().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalArgumentException("Setup token has expired");
|
||||
}
|
||||
|
||||
User user = userRepository.findById(token.getUserId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
userRepository.save(user);
|
||||
|
||||
token.setSetupTokenUsed(true);
|
||||
verificationTokenRepository.save(token);
|
||||
}
|
||||
|
||||
public AuthResponse refreshToken(RefreshTokenRequest request) {
|
||||
try {
|
||||
Claims claims = jwtConfig.validateToken(request.getRefreshToken());
|
||||
@@ -74,6 +195,15 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
private VerificationToken createVerificationToken(UUID userId) {
|
||||
return VerificationToken.builder()
|
||||
.userId(userId)
|
||||
.token(UUID.randomUUID().toString())
|
||||
.expiryDate(LocalDateTime.now().plusMinutes(tokenExpirationMinutes))
|
||||
.used(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
private AuthResponse generateAuthResponse(User user) {
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
@@ -83,7 +213,6 @@ public class AuthService {
|
||||
.email(user.getEmail())
|
||||
.name(user.getName())
|
||||
.avatarUrl(user.getAvatarUrl())
|
||||
.provider(user.getProvider())
|
||||
.build();
|
||||
|
||||
return AuthResponse.builder()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.repository.VerificationTokenRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class CleanupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(CleanupService.class);
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final VerificationTokenRepository verificationTokenRepository;
|
||||
|
||||
public CleanupService(UserRepository userRepository,
|
||||
VerificationTokenRepository verificationTokenRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.verificationTokenRepository = verificationTokenRepository;
|
||||
}
|
||||
|
||||
@Scheduled(cron = "0 0 */6 * * ?")
|
||||
@Transactional
|
||||
public void cleanupStaleUnverifiedAccounts() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusHours(24);
|
||||
List<VerificationToken> staleTokens = verificationTokenRepository
|
||||
.findByUsedFalseAndExpiryDateBefore(cutoff);
|
||||
|
||||
if (staleTokens.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<UUID> userIds = staleTokens.stream()
|
||||
.map(VerificationToken::getUserId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (UUID userId : userIds) {
|
||||
userRepository.findById(userId).ifPresent(user -> {
|
||||
if (!user.isEmailVerified()) {
|
||||
userRepository.delete(user);
|
||||
verificationTokenRepository.deleteByUserId(userId);
|
||||
log.info("Cleaned up unverified account: {} ({})", user.getEmail(), userId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.User;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class CustomOAuth2User implements OAuth2User {
|
||||
|
||||
private final User user;
|
||||
private final Map<String, Object> attributes;
|
||||
|
||||
public CustomOAuth2User(User user, Map<String, Object> attributes) {
|
||||
this.user = user;
|
||||
this.attributes = attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return user.getId().toString();
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/com/krrishg/service/Deployer.java
Normal file
13
backend/src/main/java/com/krrishg/service/Deployer.java
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
|
||||
public interface Deployer {
|
||||
|
||||
String deploy(SiteOutput output, SelfHostedConfig config) throws Exception;
|
||||
|
||||
void remove(SelfHostedConfig config) throws Exception;
|
||||
|
||||
String buildUrl(SelfHostedConfig config);
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GitRemote;
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.model.Site.DeploymentTarget;
|
||||
import com.krrishg.model.SiteStatus;
|
||||
import com.krrishg.repository.GitRemoteRepository;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,17 +27,23 @@ public class DeploymentService {
|
||||
private final SiteRepository siteRepository;
|
||||
private final LLMService llmService;
|
||||
private final GitRemoteRepository gitRemoteRepository;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
private final Deployer deployer;
|
||||
|
||||
public DeploymentService(RenderService renderService,
|
||||
GitService gitService,
|
||||
SiteRepository siteRepository,
|
||||
LLMService llmService,
|
||||
GitRemoteRepository gitRemoteRepository) {
|
||||
GitRemoteRepository gitRemoteRepository,
|
||||
SelfHostedConfigRepository selfHostedConfigRepository,
|
||||
Deployer deployer) {
|
||||
this.renderService = renderService;
|
||||
this.gitService = gitService;
|
||||
this.siteRepository = siteRepository;
|
||||
this.llmService = llmService;
|
||||
this.gitRemoteRepository = gitRemoteRepository;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
this.deployer = deployer;
|
||||
}
|
||||
|
||||
public String publish(UUID siteId) {
|
||||
@@ -45,19 +55,46 @@ public class DeploymentService {
|
||||
throw new IllegalStateException("No generation versions found for site " + siteId);
|
||||
}
|
||||
|
||||
SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException("No git remote configured. Connect a GitHub or GitLab remote first.");
|
||||
if (remote != null) {
|
||||
gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
}
|
||||
|
||||
String url = gitService.deployToPages(siteId.toString(), remote, structure);
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
try {
|
||||
deployer.deploy(output, selfHostedConfig);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to deploy to custom server: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
String url = switch (site.getDeploymentTarget()) {
|
||||
case GIT_PAGES -> {
|
||||
if (remote == null) {
|
||||
throw new IllegalStateException(
|
||||
"Deployment target is GIT_PAGES but no git remote is configured");
|
||||
}
|
||||
yield PagesProvider.forRemote(remote).buildPagesUrl(remote);
|
||||
}
|
||||
case SELF_HOSTED -> {
|
||||
if (selfHostedConfig == null) {
|
||||
throw new IllegalStateException(
|
||||
"Deployment target is SELF_HOSTED but no self-hosted config exists");
|
||||
}
|
||||
yield deployer.buildUrl(selfHostedConfig);
|
||||
}
|
||||
case NONE -> null;
|
||||
};
|
||||
|
||||
site.setStatus(SiteStatus.PUBLISHED);
|
||||
site.setPublishedAt(LocalDateTime.now());
|
||||
site.setPublishedUrl(url);
|
||||
siteRepository.save(site);
|
||||
|
||||
log.info("Published site {} to {} at {}", siteId, remote.getProvider(), url);
|
||||
log.info("Published site {}: target={}, url={}", siteId, site.getDeploymentTarget(), url);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -65,7 +102,19 @@ public class DeploymentService {
|
||||
Site site = siteRepository.findById(siteId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Site not found"));
|
||||
|
||||
GitRemote remote = gitRemoteRepository.findBySiteId(siteId).orElse(null);
|
||||
if (remote != null) {
|
||||
gitService.removePages(siteId.toString());
|
||||
}
|
||||
|
||||
SelfHostedConfig selfHostedConfig = selfHostedConfigRepository.findBySiteId(siteId).orElse(null);
|
||||
if (selfHostedConfig != null) {
|
||||
try {
|
||||
deployer.remove(selfHostedConfig);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to remove self-hosted deployment for site {}: {}", siteId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
site.setStatus(SiteStatus.DRAFT);
|
||||
site.setPublishedAt(null);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SelfHostedConfigRepository;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -12,49 +11,27 @@ import java.util.Optional;
|
||||
@Service
|
||||
public class DomainService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DomainService.class);
|
||||
|
||||
private final SiteRepository siteRepository;
|
||||
private final String siteDomain;
|
||||
private final SelfHostedConfigRepository selfHostedConfigRepository;
|
||||
|
||||
public DomainService(SiteRepository siteRepository,
|
||||
@Value("${indie.domain}") String siteDomain) {
|
||||
SelfHostedConfigRepository selfHostedConfigRepository) {
|
||||
this.siteRepository = siteRepository;
|
||||
this.siteDomain = siteDomain;
|
||||
this.selfHostedConfigRepository = selfHostedConfigRepository;
|
||||
}
|
||||
|
||||
public Optional<Site> resolveSite(String hostname) {
|
||||
String subdomain = extractSubdomain(hostname);
|
||||
if (subdomain != null) {
|
||||
return siteRepository.findBySubdomain(subdomain);
|
||||
}
|
||||
if (hostname == null || hostname.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String extractSubdomain(String hostname) {
|
||||
if (hostname == null || hostname.isBlank()) {
|
||||
return null;
|
||||
String lower = hostname.toLowerCase().strip();
|
||||
|
||||
Optional<SelfHostedConfig> config = selfHostedConfigRepository.findByDomain(lower);
|
||||
if (config.isPresent()) {
|
||||
return siteRepository.findById(config.get().getSiteId());
|
||||
}
|
||||
|
||||
String lower = hostname.toLowerCase();
|
||||
String suffix = "." + siteDomain;
|
||||
if (lower.endsWith(suffix)) {
|
||||
String subdomain = lower.substring(0, lower.indexOf(suffix));
|
||||
if (!subdomain.isEmpty() && !subdomain.contains(".")) {
|
||||
return subdomain;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isSubdomainAvailable(String subdomain) {
|
||||
return !siteRepository.existsBySubdomain(subdomain);
|
||||
}
|
||||
|
||||
public String getSiteUrl(Site site) {
|
||||
if (site.getSubdomain() != null) {
|
||||
return "https://" + site.getSubdomain() + "." + siteDomain;
|
||||
}
|
||||
return null;
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
80
backend/src/main/java/com/krrishg/service/EmailService.java
Normal file
80
backend/src/main/java/com/krrishg/service/EmailService.java
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailService.class);
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final String baseUrl;
|
||||
|
||||
public EmailService(JavaMailSender mailSender,
|
||||
@Value("${indie.app.base-url}") String baseUrl) {
|
||||
this.mailSender = mailSender;
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public void sendVerificationEmail(String to, String name, String token) {
|
||||
String link = baseUrl + "/verify-email?token=" + token;
|
||||
String subject = "Verify your Indie account";
|
||||
String html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background-color: #f9fafb;">
|
||||
<table width="100%%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<tr><td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 32px; color: #4f46e5;">Indie</h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding: 0 32px 16px;">
|
||||
<p style="margin: 0 0 8px; font-size: 16px; color: #374151;">Hi %s,</p>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; color: #374151; line-height: 1.5;">
|
||||
Someone created an account with this email address. If this was you, click the button below to verify your email and set your password.
|
||||
</p>
|
||||
<table cellpadding="0" cellspacing="0" style="margin: 0 auto 24px;">
|
||||
<tr><td align="center" style="background: #4f46e5; border-radius: 8px; padding: 12px 32px;">
|
||||
<a href="%s" style="color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; display: inline-block;">Verify Email Address</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 8px; font-size: 14px; color: #9ca3af;">This link expires in 24 hours.</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #9ca3af;">If you didn't create this account, you can safely ignore this email.</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding: 24px 32px; background: #f9fafb; border-top: 1px solid #e5e7eb;">
|
||||
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">Indie Platform</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""".formatted(name, link);
|
||||
|
||||
sendHtml(to, subject, html);
|
||||
}
|
||||
|
||||
private void sendHtml(String to, String subject, String html) {
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(html, true);
|
||||
mailSender.send(message);
|
||||
log.info("Verification email sent to {}", to);
|
||||
} catch (MessagingException e) {
|
||||
log.error("Failed to send verification email to {}", to, e);
|
||||
throw new RuntimeException("Failed to send verification email", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,10 @@ public class GitService {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
String processOutput = new String(process.getInputStream().readAllBytes());
|
||||
if (processOutput.contains("nothing to commit")) {
|
||||
log.info("Nothing to commit, skipping: {}", cmd);
|
||||
continue;
|
||||
}
|
||||
throw new RuntimeException("git command failed (exit " + exitCode + "): " + cmd + "\n" + processOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.llm.LLMFactory;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import com.krrishg.service.llm.LLMProvider;
|
||||
import com.krrishg.service.llm.ProviderFactory;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -25,6 +29,7 @@ import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -34,34 +39,54 @@ public class LLMService {
|
||||
private static final Logger log = LoggerFactory.getLogger(LLMService.class);
|
||||
private static final int MAX_VERSIONS_PER_SITE = 100;
|
||||
|
||||
private final LLMFactory llmFactory;
|
||||
private final ProviderFactory providerFactory;
|
||||
private final LLMResponseParser responseParser;
|
||||
private final ReferenceService referenceService;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
private final UserRepository userRepository;
|
||||
private final EncryptionService encryptionService;
|
||||
private final String systemPrompt;
|
||||
private final String refinerPrompt;
|
||||
|
||||
public LLMService(LLMFactory llmFactory,
|
||||
public LLMService(ProviderFactory providerFactory,
|
||||
LLMResponseParser responseParser,
|
||||
ReferenceService referenceService,
|
||||
MongoTemplate mongoTemplate,
|
||||
UserRepository userRepository,
|
||||
EncryptionService encryptionService,
|
||||
ResourceLoader resourceLoader,
|
||||
@Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath,
|
||||
@Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) {
|
||||
this.llmFactory = llmFactory;
|
||||
this.providerFactory = providerFactory;
|
||||
this.responseParser = responseParser;
|
||||
this.referenceService = referenceService;
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
this.userRepository = userRepository;
|
||||
this.encryptionService = encryptionService;
|
||||
this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath);
|
||||
this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, List<Reference> references) {
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey) {
|
||||
return generateSite(userId, siteId, prompt, references, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey, String baseUrl) {
|
||||
return generateSite(userId, siteId, prompt, references, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
|
||||
List<Reference> references, String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
String referenceContext = referenceService.buildReferenceContext(references);
|
||||
String fullPrompt = prompt + referenceContext;
|
||||
|
||||
LLMOptions options = LLMOptions.builder().build();
|
||||
LLMResult result = callLLM(systemPrompt, fullPrompt, references, options);
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, systemPrompt, fullPrompt, references, options);
|
||||
log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
@@ -73,7 +98,23 @@ public class LLMService {
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references) {
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey) {
|
||||
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, null, null);
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey, String baseUrl) {
|
||||
return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure,
|
||||
String refinementPrompt, List<Reference> references,
|
||||
String provider, String apiKey, String baseUrl, String model) {
|
||||
String resolvedKey = resolveApiKey(userId, provider, apiKey);
|
||||
String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl);
|
||||
String resolvedModel = resolveModel(userId, provider, model);
|
||||
String currentJson = serializeStructure(currentStructure);
|
||||
String referenceContext = referenceService.buildReferenceContext(references);
|
||||
String combinedPrompt = "Current site structure:\n" + currentJson
|
||||
@@ -84,7 +125,7 @@ public class LLMService {
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.5)
|
||||
.build();
|
||||
LLMResult result = callLLM(refinerPrompt, combinedPrompt, references, options);
|
||||
LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options);
|
||||
log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}",
|
||||
result.getModel(), result.getLatencyMs(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
@@ -118,16 +159,62 @@ public class LLMService {
|
||||
mongoTemplate.remove(version);
|
||||
}
|
||||
|
||||
private LLMResult callLLM(String systemPrompt, String userPrompt, List<Reference> references, LLMOptions options) {
|
||||
LLMProvider provider = llmFactory.getActiveProvider();
|
||||
private String resolveApiKey(UUID userId, String provider, String apiKey) {
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
return apiKey;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> keys = user.getLlmApiKeys();
|
||||
if (keys != null && keys.containsKey(provider)) {
|
||||
return encryptionService.decrypt(keys.get(provider));
|
||||
}
|
||||
throw new IllegalStateException("No API key configured for " + provider
|
||||
+ ". Add one in Settings.");
|
||||
}
|
||||
|
||||
private String resolveModel(UUID userId, String provider, String model) {
|
||||
if (model != null && !model.isBlank()) {
|
||||
return model;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> models = user.getLlmModels();
|
||||
if (models != null && models.containsKey(provider)) {
|
||||
return models.get(provider);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveBaseUrl(UUID userId, String provider, String baseUrl) {
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
return baseUrl;
|
||||
}
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Map<String, String> urls = user.getLlmBaseUrls();
|
||||
if (urls != null && urls.containsKey(provider)) {
|
||||
return urls.get(provider);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LLMResult callLLM(String provider, String apiKey, String baseUrl, String model, String systemPrompt,
|
||||
String userPrompt, List<Reference> references, LLMOptions options) {
|
||||
LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model);
|
||||
try {
|
||||
return provider.generate(systemPrompt, userPrompt, references, options);
|
||||
} catch (Exception e) {
|
||||
log.warn("Active provider {} failed: {}. Trying fallback...",
|
||||
provider.getName(), e.getMessage());
|
||||
return llmFactory.getFallbackProvider()
|
||||
.orElseThrow(() -> new RuntimeException("All LLM providers failed", e))
|
||||
.generate(systemPrompt, userPrompt, references, options);
|
||||
return p.generate(systemPrompt, userPrompt, references, options);
|
||||
} catch (HttpClientErrorException e) {
|
||||
String detail = e.getResponseBodyAsString();
|
||||
String msg = switch (e.getStatusCode().value()) {
|
||||
case 401 -> provider + " rejected the API key (401 Unauthorized). Verify the key is valid in Settings.";
|
||||
case 429 -> provider + " rate limit exceeded (429). Try again later.";
|
||||
default -> {
|
||||
String body = (detail != null && !detail.isBlank()) ? ": " + detail : "";
|
||||
yield provider + " API error: " + e.getStatusCode() + " " + e.getStatusText() + body;
|
||||
}
|
||||
};
|
||||
throw new IllegalStateException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.config.JwtConfig;
|
||||
import com.krrishg.model.User;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
|
||||
public OAuth2SuccessHandler(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Authentication authentication) throws IOException, ServletException {
|
||||
if (authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) {
|
||||
User user = oAuth2User.getUser();
|
||||
|
||||
String accessToken = jwtConfig.generateAccessToken(user);
|
||||
String refreshToken = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
String redirectUrl = "http://localhost:4200/oauth2/callback"
|
||||
+ "?accessToken=" + accessToken
|
||||
+ "&refreshToken=" + refreshToken;
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
} else {
|
||||
super.onAuthenticationSuccess(request, response, authentication);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class OAuth2UserService extends DefaultOAuth2UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public OAuth2UserService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
OAuth2User oAuth2User = super.loadUser(userRequest);
|
||||
|
||||
String registrationId = userRequest.getClientRegistration().getRegistrationId();
|
||||
AuthProvider provider = switch (registrationId) {
|
||||
case "google" -> AuthProvider.GOOGLE;
|
||||
case "github" -> AuthProvider.GITHUB;
|
||||
default -> AuthProvider.OPENID;
|
||||
};
|
||||
|
||||
String providerId = getProviderId(oAuth2User, provider);
|
||||
String email = getAttribute(oAuth2User, "email");
|
||||
if (email != null) {
|
||||
email = email.toLowerCase().strip();
|
||||
}
|
||||
String name = getAttribute(oAuth2User, "name");
|
||||
String avatarUrl = getAvatarUrl(oAuth2User, provider);
|
||||
|
||||
User user = findOrCreateUser(provider, providerId, email, name, avatarUrl);
|
||||
return new CustomOAuth2User(user, oAuth2User.getAttributes());
|
||||
}
|
||||
|
||||
String getProviderId(OAuth2User oAuth2User, AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> oAuth2User.getAttribute("sub");
|
||||
case GITHUB -> oAuth2User.getAttribute("id").toString();
|
||||
default -> oAuth2User.getName();
|
||||
};
|
||||
}
|
||||
|
||||
String getAttribute(OAuth2User oAuth2User, String key) {
|
||||
Object value = oAuth2User.getAttribute(key);
|
||||
return value != null ? value.toString() : null;
|
||||
}
|
||||
|
||||
String getAvatarUrl(OAuth2User oAuth2User, AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> oAuth2User.getAttribute("picture");
|
||||
case GITHUB -> oAuth2User.getAttribute("avatar_url");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
User findOrCreateUser(AuthProvider provider, String providerId,
|
||||
String email, String name, String avatarUrl) {
|
||||
Optional<User> existing = userRepository.findByProviderAndProviderId(provider, providerId);
|
||||
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
user.setName(name);
|
||||
if (avatarUrl != null) {
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
}
|
||||
if (email != null && !email.equals(user.getEmail())) {
|
||||
user.setEmail(email);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
if (email != null) {
|
||||
Optional<User> emailUser = userRepository.findByEmail(email);
|
||||
if (emailUser.isPresent()) {
|
||||
User user = emailUser.get();
|
||||
user.setProvider(provider);
|
||||
user.setProviderId(providerId);
|
||||
user.setEmailVerified(true);
|
||||
if (avatarUrl != null) {
|
||||
user.setAvatarUrl(avatarUrl);
|
||||
}
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
User newUser = User.builder()
|
||||
.email(email != null ? email : providerId + "@" + provider.name().toLowerCase() + ".indie")
|
||||
.name(name != null ? name : "User")
|
||||
.avatarUrl(avatarUrl)
|
||||
.provider(provider)
|
||||
.providerId(providerId)
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
|
||||
return userRepository.save(newUser);
|
||||
}
|
||||
}
|
||||
244
backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java
Normal file
244
backend/src/main/java/com/krrishg/service/SshRsyncDeployer.java
Normal file
@@ -0,0 +1,244 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.service.RenderService.SiteOutput;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class SshRsyncDeployer implements Deployer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SshRsyncDeployer.class);
|
||||
|
||||
private final KeyManager keyManager;
|
||||
|
||||
public SshRsyncDeployer(KeyManager keyManager) {
|
||||
this.keyManager = keyManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String deploy(SiteOutput output, SelfHostedConfig config) throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("indie-deploy-" + config.getSiteId());
|
||||
try {
|
||||
String resolvedDeployPath = config.getDeployPath().replace("{domain}", config.getDomain());
|
||||
String resolvedNginxPath = config.getNginxSitesPath().replace("{domain}", config.getDomain());
|
||||
|
||||
writeSiteFiles(tempDir, output);
|
||||
|
||||
String keyFile = keyManager.getPrivateKeyPath(config.getSiteId().toString()).toString();
|
||||
|
||||
ssh(keyFile, config, "mkdir -p " + resolvedDeployPath);
|
||||
|
||||
rsync(tempDir.resolve("site"), keyFile, config, 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);
|
||||
|
||||
String remoteTemp = "/tmp/" + config.getDomain();
|
||||
rsyncSingleFile(nginxConfPath, keyFile, config, "/tmp", config.getDomain());
|
||||
ssh(keyFile, config,
|
||||
"sudo cp " + remoteTemp + " " + resolvedNginxPath + "/" + config.getDomain()
|
||||
+ " && rm " + remoteTemp
|
||||
+ " && sudo ln -sf " + resolvedNginxPath + "/" + config.getDomain()
|
||||
+ " /etc/nginx/sites-enabled/ && sudo nginx -s reload");
|
||||
|
||||
log.info("Deployed site {} to {} ({}", config.getSiteId(), config.getDomain(), resolvedDeployPath);
|
||||
return buildUrl(config);
|
||||
} finally {
|
||||
deleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(SelfHostedConfig config) throws Exception {
|
||||
Path tempDir = Files.createTempDirectory("indie-deploy-remove-" + config.getSiteId());
|
||||
try {
|
||||
String resolvedDeployPath = config.getDeployPath().replace("{domain}", config.getDomain());
|
||||
|
||||
String keyFile = keyManager.getPrivateKeyPath(config.getSiteId().toString()).toString();
|
||||
|
||||
ssh(keyFile, config, "rm -rf " + resolvedDeployPath);
|
||||
|
||||
String resolvedNginxPath = config.getNginxSitesPath().replace("{domain}", config.getDomain());
|
||||
ssh(keyFile, config,
|
||||
"sudo rm -f " + resolvedNginxPath + "/" + config.getDomain()
|
||||
+ " /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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String buildUrl(SelfHostedConfig config) {
|
||||
String scheme = config.isSslEnabled() ? "https" : "http";
|
||||
return scheme + "://" + config.getDomain() + "/";
|
||||
}
|
||||
|
||||
private void writeSiteFiles(Path tempDir, SiteOutput output) throws IOException {
|
||||
Path siteDir = tempDir.resolve("site");
|
||||
Files.createDirectories(siteDir);
|
||||
for (Map.Entry<String, byte[]> entry : output.asFileMap().entrySet()) {
|
||||
Path filePath = siteDir.resolve(entry.getKey());
|
||||
Files.createDirectories(filePath.getParent());
|
||||
Files.write(filePath, entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private void ssh(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
|
||||
);
|
||||
execute(sshCmd);
|
||||
}
|
||||
|
||||
private void rsync(Path sourceDir, String keyFile, SelfHostedConfig config, String targetDir) throws Exception {
|
||||
String rsyncCmd = String.format(
|
||||
"rsync -avz --delete -e 'ssh -p %d -i %s -o StrictHostKeyChecking=accept-new' %s/ %s@%s:%s/",
|
||||
config.getSshPort(), keyFile, sourceDir.toString(),
|
||||
config.getSshUser(), config.getSshHost(), targetDir
|
||||
);
|
||||
execute(rsyncCmd);
|
||||
}
|
||||
|
||||
private void rsyncSingleFile(Path sourceFile, String keyFile, SelfHostedConfig config,
|
||||
String targetDir, String filename) throws Exception {
|
||||
ssh(keyFile, config, "mkdir -p " + targetDir);
|
||||
String rsyncCmd = String.format(
|
||||
"rsync -avz -e 'ssh -p %d -i %s -o StrictHostKeyChecking=accept-new' %s %s@%s:%s/%s",
|
||||
config.getSshPort(), keyFile, sourceFile.toString(),
|
||||
config.getSshUser(), config.getSshHost(), targetDir, filename
|
||||
);
|
||||
execute(rsyncCmd);
|
||||
}
|
||||
|
||||
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"
|
||||
+ " root " + rootPath + ";\n"
|
||||
+ " index index.html;\n"
|
||||
+ " location / {\n"
|
||||
+ " try_files $uri $uri/ /index.html;\n"
|
||||
+ " }\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 {
|
||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
||||
pb.redirectErrorStream(true);
|
||||
Process process = pb.start();
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
String output = new String(process.getInputStream().readAllBytes());
|
||||
throw new RuntimeException("Command failed (exit " + exitCode + "): " + command + "\n" + output);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDirectory(Path path) throws IOException {
|
||||
if (Files.exists(path)) {
|
||||
try (var walk = Files.walk(path)) {
|
||||
walk.sorted(Comparator.reverseOrder())
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete {}", p);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,13 @@ package com.krrishg.service.llm;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.Reference;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class GeminiProvider implements LLMProvider {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
@@ -22,18 +17,11 @@ public class GeminiProvider implements LLMProvider {
|
||||
private final String model;
|
||||
private final String baseUrl;
|
||||
|
||||
public GeminiProvider(
|
||||
@Value("${indie.llm.providers.gemini.api-key:}") String apiKey,
|
||||
@Value("${indie.llm.providers.gemini.model:gemini-2.0-flash}") String model,
|
||||
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String baseUrl,
|
||||
RestTemplateBuilder builder) {
|
||||
public GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.baseUrl = baseUrl;
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class LLMFactory {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LLMFactory.class);
|
||||
|
||||
private final List<LLMProvider> providers;
|
||||
private final String activeProvider;
|
||||
private final String fallbackProvider;
|
||||
|
||||
private Map<String, LLMProvider> providerMap;
|
||||
|
||||
public LLMFactory(List<LLMProvider> providers,
|
||||
@Value("${indie.llm.active-provider:gemini}") String activeProvider,
|
||||
@Value("${indie.llm.fallback-provider:openai}") String fallbackProvider) {
|
||||
this.providers = providers;
|
||||
this.activeProvider = activeProvider;
|
||||
this.fallbackProvider = fallbackProvider;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
providerMap = providers.stream()
|
||||
.collect(Collectors.toMap(LLMProvider::getName, Function.identity()));
|
||||
log.info("Available LLM providers: {}", providerMap.keySet());
|
||||
}
|
||||
|
||||
public LLMProvider getActiveProvider() {
|
||||
return getProvider(activeProvider)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Active LLM provider '" + activeProvider + "' not found. Available: " + providerMap.keySet()));
|
||||
}
|
||||
|
||||
public Optional<LLMProvider> getFallbackProvider() {
|
||||
return getProvider(fallbackProvider);
|
||||
}
|
||||
|
||||
private Optional<LLMProvider> getProvider(String name) {
|
||||
return Optional.ofNullable(providerMap.get(name));
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,12 @@ package com.krrishg.service.llm;
|
||||
import com.krrishg.dto.LLMResult;
|
||||
import com.krrishg.dto.LLMOptions;
|
||||
import com.krrishg.dto.Reference;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class OpenAIProvider implements LLMProvider {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
@@ -21,18 +16,11 @@ public class OpenAIProvider implements LLMProvider {
|
||||
private final String model;
|
||||
private final String url;
|
||||
|
||||
public OpenAIProvider(
|
||||
@Value("${indie.llm.providers.openai.api-key:}") String apiKey,
|
||||
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String model,
|
||||
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String url,
|
||||
RestTemplateBuilder builder) {
|
||||
public OpenAIProvider(String apiKey, String model, String url, RestTemplate restTemplate) {
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.url = url;
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.krrishg.service.llm;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ProviderFactory {
|
||||
|
||||
private final RestTemplateBuilder restTemplateBuilder;
|
||||
private final Map<String, ProviderConfig> providerConfigs;
|
||||
|
||||
public ProviderFactory(
|
||||
@Value("${indie.llm.providers.gemini.model:gemini-3.1-flash-lite}") String geminiModel,
|
||||
@Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String geminiUrl,
|
||||
@Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String openaiModel,
|
||||
@Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String openaiUrl,
|
||||
RestTemplateBuilder restTemplateBuilder) {
|
||||
this.restTemplateBuilder = restTemplateBuilder;
|
||||
this.providerConfigs = Map.of(
|
||||
"gemini", new ProviderConfig(geminiModel, geminiUrl),
|
||||
"openai", new ProviderConfig(openaiModel, openaiUrl)
|
||||
);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey) {
|
||||
return createProvider(name, apiKey, null, null);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl) {
|
||||
return createProvider(name, apiKey, baseUrl, null);
|
||||
}
|
||||
|
||||
public LLMProvider createProvider(String name, String apiKey, String baseUrl, String model) {
|
||||
ProviderConfig config = providerConfigs.get(name);
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Unknown LLM provider: " + name
|
||||
+ ". Available: " + providerConfigs.keySet());
|
||||
}
|
||||
String resolvedModel = (model != null && !model.isBlank()) ? model : config.model;
|
||||
String url = config.url;
|
||||
if (baseUrl != null && !baseUrl.isBlank()) {
|
||||
url = baseUrl;
|
||||
if ("openai".equals(name) && !url.endsWith("/chat/completions")) {
|
||||
url = url.endsWith("/") ? url + "chat/completions" : url + "/chat/completions";
|
||||
}
|
||||
}
|
||||
RestTemplate rt = restTemplateBuilder
|
||||
.setConnectTimeout(Duration.ofSeconds(30))
|
||||
.setReadTimeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
return switch (name) {
|
||||
case "gemini" -> new GeminiProvider(apiKey, resolvedModel, url, rt);
|
||||
case "openai" -> new OpenAIProvider(apiKey, resolvedModel, url, rt);
|
||||
default -> throw new IllegalArgumentException("Unknown LLM provider: " + name);
|
||||
};
|
||||
}
|
||||
|
||||
public List<String> getAvailableProviders() {
|
||||
return List.copyOf(providerConfigs.keySet());
|
||||
}
|
||||
|
||||
private record ProviderConfig(String model, String url) {}
|
||||
}
|
||||
@@ -26,6 +26,18 @@ spring:
|
||||
show-sql: true
|
||||
open-in-view: false
|
||||
|
||||
mail:
|
||||
host: ${EMAIL_HOST:localhost}
|
||||
port: ${EMAIL_PORT:1025}
|
||||
username: ${EMAIL_USERNAME:}
|
||||
password: ${EMAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: ${EMAIL_SMTP_AUTH:true}
|
||||
starttls:
|
||||
enable: ${EMAIL_SMTP_STARTTLS:true}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
@@ -43,7 +55,6 @@ indie:
|
||||
repos-path: ${INDIE_GIT_REPOS_PATH:/home/krrish/git/sites/}
|
||||
author-name: ${INDIE_GIT_AUTHOR_NAME:Indie Platform}
|
||||
author-email: ${INDIE_GIT_AUTHOR_EMAIL:git@indie.local}
|
||||
domain: ${INDIE_SITE_DOMAIN:indie.com}
|
||||
cors:
|
||||
allowed-origins: ${INDIE_CORS_ORIGINS:http://localhost:4200,http://localhost:3000}
|
||||
|
||||
@@ -55,17 +66,20 @@ indie:
|
||||
bucket: ${INDIE_STORAGE_BUCKET:indie-sites}
|
||||
public-url-base: ${INDIE_STORAGE_PUBLIC_URL:http://localhost:9000/indie-sites}
|
||||
|
||||
encryption:
|
||||
master-key-path: ${INDIE_ENCRYPTION_KEY_PATH:config/encryption.key}
|
||||
|
||||
app:
|
||||
base-url: ${INDIE_APP_BASE_URL:http://localhost:4200}
|
||||
verification-token-expiration-minutes: ${INDIE_VERIFICATION_TOKEN_EXPIRATION:1440}
|
||||
|
||||
llm:
|
||||
active-provider: ${INDIE_LLM_ACTIVE_PROVIDER:gemini}
|
||||
fallback-provider: ${INDIE_LLM_FALLBACK_PROVIDER:openai}
|
||||
rate-limit:
|
||||
max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10}
|
||||
providers:
|
||||
gemini:
|
||||
api-key: ${GEMINI_API_KEY:}
|
||||
model: ${GEMINI_MODEL:gemini-3.1-flash-lite}
|
||||
url: https://generativelanguage.googleapis.com/v1beta/models
|
||||
openai:
|
||||
api-key: ${OPENAI_API_KEY:}
|
||||
model: ${OPENAI_MODEL:gpt-4o-mini}
|
||||
url: https://api.openai.com/v1/chat/completions
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EncryptionServiceTest {
|
||||
|
||||
private Path tempKeyPath;
|
||||
private EncryptionService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
try {
|
||||
tempKeyPath = Files.createTempFile("test-encryption", ".key");
|
||||
service = new EncryptionService(tempKeyPath.toString());
|
||||
service.init();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
Files.deleteIfExists(tempKeyPath);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptAndDecryptRoundtrip() {
|
||||
String plaintext = "Hello, World!";
|
||||
|
||||
String encrypted = service.encrypt(plaintext);
|
||||
assertNotNull(encrypted);
|
||||
assertNotEquals(plaintext, encrypted);
|
||||
|
||||
String decrypted = service.decrypt(encrypted);
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptAndDecryptSpecialCharacters() {
|
||||
String plaintext = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?`~\n\t";
|
||||
|
||||
String encrypted = service.encrypt(plaintext);
|
||||
String decrypted = service.decrypt(encrypted);
|
||||
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptAndDecryptEmptyString() {
|
||||
String encrypted = service.encrypt("");
|
||||
String decrypted = service.decrypt(encrypted);
|
||||
|
||||
assertEquals("", decrypted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentPlaintextsProduceDifferentCiphertexts() {
|
||||
String encrypted1 = service.encrypt("text1");
|
||||
String encrypted2 = service.encrypt("text2");
|
||||
|
||||
assertNotEquals(encrypted1, encrypted2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePlaintextProducedDifferentCiphertextsDueToRandomIv() {
|
||||
String encrypted1 = service.encrypt("same text");
|
||||
String encrypted2 = service.encrypt("same text");
|
||||
|
||||
assertNotEquals(encrypted1, encrypted2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptThrowsOnInvalidData() {
|
||||
assertThrows(RuntimeException.class, () -> service.decrypt("invalid-base64!"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesKeyFile() throws IOException {
|
||||
assertTrue(Files.exists(tempKeyPath));
|
||||
assertTrue(Files.size(tempKeyPath) > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initLoadsExistingKeyFile() {
|
||||
EncryptionService secondService = new EncryptionService(tempKeyPath.toString());
|
||||
secondService.init();
|
||||
|
||||
String plaintext = "Persistent key test";
|
||||
String encrypted = secondService.encrypt(plaintext);
|
||||
String decrypted = secondService.decrypt(encrypted);
|
||||
|
||||
assertEquals(plaintext, decrypted);
|
||||
}
|
||||
}
|
||||
66
backend/src/test/java/com/krrishg/config/GitConfigTest.java
Normal file
66
backend/src/test/java/com/krrishg/config/GitConfigTest.java
Normal file
@@ -0,0 +1,66 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GitConfigTest {
|
||||
|
||||
@Test
|
||||
void getSiteRepoPathResolvesCorrectly() throws IOException {
|
||||
Path tempDir = Files.createTempDirectory("git-config-test");
|
||||
GitConfig config = new GitConfig();
|
||||
setField(config, "reposPath", tempDir.toString());
|
||||
setField(config, "authorName", "Indie Platform");
|
||||
setField(config, "authorEmail", "git@indie.local");
|
||||
config.init();
|
||||
|
||||
Path repoPath = config.getSiteRepoPath("test-site-id");
|
||||
|
||||
assertEquals(tempDir.resolve("test-site-id"), repoPath);
|
||||
assertEquals("Indie Platform", config.getAuthorName());
|
||||
assertEquals("git@indie.local", config.getAuthorEmail());
|
||||
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesDirectory() throws IOException {
|
||||
Path tempDir = Files.createTempDirectory("git-config-init-test");
|
||||
Path reposDir = tempDir.resolve("repos");
|
||||
GitConfig config = new GitConfig();
|
||||
setField(config, "reposPath", reposDir.toString());
|
||||
|
||||
assertFalse(Files.exists(reposDir));
|
||||
config.init();
|
||||
assertTrue(Files.exists(reposDir));
|
||||
|
||||
Files.deleteIfExists(reposDir);
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersUpdateAuthorInfo() {
|
||||
GitConfig config = new GitConfig();
|
||||
|
||||
config.setAuthorName("Custom Author");
|
||||
config.setAuthorEmail("custom@example.com");
|
||||
|
||||
assertEquals("Custom Author", config.getAuthorName());
|
||||
assertEquals("custom@example.com", config.getAuthorEmail());
|
||||
}
|
||||
|
||||
private void setField(Object target, String fieldName, Object value) {
|
||||
try {
|
||||
var field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JwtAuthenticationTokenTest {
|
||||
|
||||
private final UserPrincipal principal = new UserPrincipal(
|
||||
UUID.randomUUID(), "test@example.com", "Test User");
|
||||
private final String token = "test-jwt-token";
|
||||
private final JwtAuthenticationToken auth = new JwtAuthenticationToken(principal, token);
|
||||
|
||||
@Test
|
||||
void constructorSetsAuthenticated() {
|
||||
assertTrue(auth.isAuthenticated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCredentialsReturnsToken() {
|
||||
assertEquals(token, auth.getCredentials());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPrincipalReturnsUserPrincipal() {
|
||||
assertSame(principal, auth.getPrincipal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAuthoritiesContainsRoleUser() {
|
||||
var authorities = auth.getAuthorities();
|
||||
assertEquals(1, authorities.size());
|
||||
assertEquals("ROLE_USER", authorities.iterator().next().getAuthority());
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalFieldsAreAccessible() {
|
||||
assertEquals("test@example.com", principal.email());
|
||||
assertEquals("Test User", principal.name());
|
||||
assertNotNull(principal.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void principalIsInstanceOfUserPrincipal() {
|
||||
assertInstanceOf(UserPrincipal.class, auth.getPrincipal());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.krrishg.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MapToJsonConverterTest {
|
||||
|
||||
private final MapToJsonConverter converter = new MapToJsonConverter();
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumnSerializesMap() {
|
||||
Map<String, String> map = Map.of("key1", "value1", "key2", "value2");
|
||||
|
||||
String json = converter.convertToDatabaseColumn(map);
|
||||
|
||||
assertNotNull(json);
|
||||
assertTrue(json.contains("key1"));
|
||||
assertTrue(json.contains("value1"));
|
||||
assertTrue(json.contains("key2"));
|
||||
assertTrue(json.contains("value2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeDeserializesJson() {
|
||||
String json = "{\"a\":\"1\",\"b\":\"2\"}";
|
||||
|
||||
Map<String, String> result = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertEquals(2, result.size());
|
||||
assertEquals("1", result.get("a"));
|
||||
assertEquals("2", result.get("b"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripPreservesMap() {
|
||||
Map<String, String> original = Map.of("alpha", "beta", "gamma", "delta");
|
||||
|
||||
String json = converter.convertToDatabaseColumn(original);
|
||||
Map<String, String> result = converter.convertToEntityAttribute(json);
|
||||
|
||||
assertEquals(original, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumnReturnsNullForNullInput() {
|
||||
assertNull(converter.convertToDatabaseColumn(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToDatabaseColumnReturnsNullForEmptyMap() {
|
||||
assertNull(converter.convertToDatabaseColumn(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeReturnsEmptyMapForNullInput() {
|
||||
assertEquals(Map.of(), converter.convertToEntityAttribute(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeReturnsEmptyMapForBlankInput() {
|
||||
assertEquals(Map.of(), converter.convertToEntityAttribute(""));
|
||||
assertEquals(Map.of(), converter.convertToEntityAttribute(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertToEntityAttributeThrowsOnMalformedJson() {
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
converter.convertToEntityAttribute("{invalid json}"));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import com.krrishg.dto.AuthResponse.UserInfo;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.service.AuthService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -21,6 +25,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -41,14 +46,9 @@ class AuthControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerReturns201() throws Exception {
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("at-123")
|
||||
.refreshToken("rt-456")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "test@example.com", "Test", null, AuthProvider.LOCAL))
|
||||
.build();
|
||||
when(authService.register(any(RegisterRequest.class))).thenReturn(response);
|
||||
void registerReturns201WithMessage() throws Exception {
|
||||
when(authService.register(any(RegisterRequest.class)))
|
||||
.thenReturn(new RegistrationResponse("If this email is available, a verification link has been sent."));
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("test@example.com");
|
||||
@@ -59,10 +59,7 @@ class AuthControllerTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accessToken").value("at-123"))
|
||||
.andExpect(jsonPath("$.refreshToken").value("rt-456"))
|
||||
.andExpect(jsonPath("$.tokenType").value("Bearer"))
|
||||
.andExpect(jsonPath("$.user.email").value("test@example.com"));
|
||||
.andExpect(jsonPath("$.message").value("If this email is available, a verification link has been sent."));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +78,7 @@ class AuthControllerTest {
|
||||
.accessToken("at-123")
|
||||
.refreshToken("rt-456")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "login@example.com", "Login User", null, AuthProvider.LOCAL))
|
||||
.user(new UserInfo(UUID.randomUUID(), "login@example.com", "Login User", null))
|
||||
.build();
|
||||
when(authService.login(any(LoginRequest.class))).thenReturn(response);
|
||||
|
||||
@@ -113,13 +110,29 @@ class AuthControllerTest {
|
||||
.andExpect(jsonPath("$.detail").value("Invalid email or password"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginReturns400OnUnverifiedEmail() throws Exception {
|
||||
when(authService.login(any(LoginRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Please verify your email address before logging in"));
|
||||
|
||||
LoginRequest request = new LoginRequest();
|
||||
request.setEmail("unverified@example.com");
|
||||
request.setPassword("password123");
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Please verify your email address before logging in"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturns200() throws Exception {
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("new-at")
|
||||
.refreshToken("new-rt")
|
||||
.tokenType("Bearer")
|
||||
.user(new UserInfo(UUID.randomUUID(), "user@example.com", "User", null, AuthProvider.LOCAL))
|
||||
.user(new UserInfo(UUID.randomUUID(), "user@example.com", "User", null))
|
||||
.build();
|
||||
when(authService.refreshToken(any(RefreshTokenRequest.class))).thenReturn(response);
|
||||
|
||||
@@ -138,4 +151,93 @@ class AuthControllerTest {
|
||||
mockMvc.perform(post("/api/auth/logout"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailReturns200() throws Exception {
|
||||
when(authService.verifyEmail(any(VerifyEmailRequest.class)))
|
||||
.thenReturn(new VerifyEmailResponse("Email verified successfully", "setup-token-123"));
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("valid-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/verify-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Email verified successfully"))
|
||||
.andExpect(jsonPath("$.setupToken").value("setup-token-123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailReturns400OnInvalidToken() throws Exception {
|
||||
when(authService.verifyEmail(any(VerifyEmailRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Invalid verification token"));
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("bad-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/verify-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Invalid verification token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailReturns400OnExpiredToken() throws Exception {
|
||||
when(authService.verifyEmail(any(VerifyEmailRequest.class)))
|
||||
.thenThrow(new IllegalArgumentException("Verification token has expired"));
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("expired-token");
|
||||
|
||||
mockMvc.perform(post("/api/auth/verify-email")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Verification token has expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resendVerificationReturns200() throws Exception {
|
||||
when(authService.resendVerification(any(ResendVerificationRequest.class)))
|
||||
.thenReturn(new RegistrationResponse("If this email is available, a verification link has been sent."));
|
||||
|
||||
ResendVerificationRequest request = new ResendVerificationRequest();
|
||||
request.setEmail("user@example.com");
|
||||
|
||||
mockMvc.perform(post("/api/auth/resend-verification")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("If this email is available, a verification link has been sent."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordReturns200() throws Exception {
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("setup-token-123");
|
||||
request.setPassword("new-password");
|
||||
|
||||
mockMvc.perform(post("/api/auth/set-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordReturns400OnInvalidToken() throws Exception {
|
||||
doThrow(new IllegalArgumentException("Invalid setup token"))
|
||||
.when(authService).setPassword(any(SetPasswordRequest.class));
|
||||
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("bad-token");
|
||||
request.setPassword("new-password");
|
||||
|
||||
mockMvc.perform(post("/api/auth/set-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.detail").value("Invalid setup token"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.DeploymentService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
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.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(DeploymentController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class DeploymentControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private DeploymentService deploymentService;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
DeploymentControllerTest(@Autowired MockMvc mockMvc) {
|
||||
this.mockMvc = mockMvc;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
|
||||
Site site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("Test Site")
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishReturns200() throws Exception {
|
||||
when(deploymentService.publish(siteId)).thenReturn("https://mysite.example.com");
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/publish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Site published successfully"))
|
||||
.andExpect(jsonPath("$.url").value("https://mysite.example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishReturns400WhenNoVersion() throws Exception {
|
||||
when(deploymentService.publish(siteId))
|
||||
.thenThrow(new IllegalStateException("No generation versions found for site " + siteId));
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/publish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No generation versions found for site " + siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishReturns400WhenNoRemote() throws Exception {
|
||||
when(deploymentService.publish(siteId))
|
||||
.thenThrow(new IllegalStateException("No git remote configured"));
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/publish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No git remote configured"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unpublishReturns200() throws Exception {
|
||||
mockMvc.perform(post("/api/sites/{id}/unpublish", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Site unpublished successfully"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.model.SiteStatus;
|
||||
import com.krrishg.repository.SiteRepository;
|
||||
import com.krrishg.service.ExportService;
|
||||
import com.krrishg.service.LLMService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
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.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(ExportController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class ExportControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private ExportService exportService;
|
||||
|
||||
@MockBean
|
||||
private LLMService llmService;
|
||||
|
||||
@MockBean
|
||||
private SiteRepository siteRepository;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
private Site site;
|
||||
|
||||
ExportControllerTest(@Autowired MockMvc mockMvc) {
|
||||
this.mockMvc = mockMvc;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
|
||||
site = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportReturnsZipFile() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1")
|
||||
.siteId(siteId)
|
||||
.versionNumber(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build());
|
||||
when(exportService.exportSite(any(SiteStructure.class))).thenReturn("fake-zip-content".getBytes());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Disposition", "attachment; filename=\"my-site-export.zip\""))
|
||||
.andExpect(header().string("Content-Type", "application/octet-stream"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportReturns400WhenNoVersions() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportReturns500OnExportError() throws Exception {
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(site));
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1")
|
||||
.siteId(siteId)
|
||||
.versionNumber(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build());
|
||||
when(exportService.exportSite(any(SiteStructure.class)))
|
||||
.thenThrow(new RuntimeException("Export failed"));
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isInternalServerError());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportSanitizesFilenameWithSpecialCharacters() throws Exception {
|
||||
Site siteWithSpecialName = Site.builder()
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("My Cool Site!")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(siteWithSpecialName));
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("v1")
|
||||
.siteId(siteId)
|
||||
.versionNumber(1)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(llmService.getVersions(siteId)).thenReturn(List.of(version));
|
||||
when(llmService.restoreVersion("v1")).thenReturn(SiteStructure.builder().build());
|
||||
when(exportService.exportSite(any(SiteStructure.class))).thenReturn("zip".getBytes());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/export", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
"attachment; filename=\"my-cool-site-export.zip\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.GitRemote;
|
||||
import com.krrishg.repository.GitRemoteRepository;
|
||||
import com.krrishg.service.GitService;
|
||||
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.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
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(GitController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class GitControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private GitService gitService;
|
||||
|
||||
@MockBean
|
||||
private GitRemoteRepository gitRemoteRepository;
|
||||
|
||||
@MockBean
|
||||
private KeyManager keyManager;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
GitControllerTest(@Autowired MockMvc mockMvc, @Autowired ObjectMapper objectMapper) {
|
||||
this.mockMvc = mockMvc;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReturns200() throws Exception {
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
|
||||
|
||||
Map<String, String> request = Map.of(
|
||||
"provider", "GITHUB",
|
||||
"remoteUrl", "https://github.com/user/repo.git",
|
||||
"token", "ghp_token"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Remote connected successfully"))
|
||||
.andExpect(jsonPath("$.provider").value("GITHUB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReturns400WhenFieldsMissing() throws Exception {
|
||||
Map<String, String> request = Map.of("provider", "GITHUB");
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReturns400WhenInvalidProvider() throws Exception {
|
||||
Map<String, String> request = Map.of(
|
||||
"provider", "INVALID",
|
||||
"remoteUrl", "https://github.com/user/repo.git",
|
||||
"token", "token"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Invalid provider. Must be GITHUB or GITLAB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectRemoteReplacesExistingRemote() throws Exception {
|
||||
GitRemote existing = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/old/repo.git")
|
||||
.encryptedToken("old-token")
|
||||
.build();
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.of(existing));
|
||||
when(keyManager.getPublicKey(siteId.toString())).thenReturn("ssh-ed25519 AAA...");
|
||||
|
||||
Map<String, String> request = Map.of(
|
||||
"provider", "GITHUB",
|
||||
"remoteUrl", "https://github.com/user/repo.git",
|
||||
"token", "ghp_token"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/sites/{id}/git/connect", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(gitRemoteRepository).delete(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disconnectRemoteReturns200() throws Exception {
|
||||
mockMvc.perform(post("/api/sites/{id}/git/disconnect", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Remote disconnected"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusReturnsConnectedInfo() throws Exception {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.of(remote));
|
||||
when(keyManager.hasKeyPair(siteId.toString())).thenReturn(true);
|
||||
when(gitService.getLog(eq(siteId.toString()), eq(10))).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/git/status", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.connected").value(true))
|
||||
.andExpect(jsonPath("$.provider").value("GITHUB"))
|
||||
.andExpect(jsonPath("$.remoteUrl").value("https://github.com/user/repo.git"))
|
||||
.andExpect(jsonPath("$.hasKeys").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatusReturnsDisconnectedState() throws Exception {
|
||||
when(gitRemoteRepository.findBySiteId(siteId)).thenReturn(Optional.empty());
|
||||
when(keyManager.hasKeyPair(siteId.toString())).thenReturn(false);
|
||||
when(gitService.getLog(eq(siteId.toString()), eq(10))).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/sites/{id}/git/status", siteId)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.connected").value(false))
|
||||
.andExpect(jsonPath("$.hasKeys").value(false));
|
||||
}
|
||||
}
|
||||
@@ -76,11 +76,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -103,11 +104,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"references", List.of(
|
||||
Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo")
|
||||
)
|
||||
@@ -120,6 +122,34 @@ class LLMControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns200WithPerRequestApiKey() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.pages(List.of(
|
||||
SiteStructure.PageStructure.builder()
|
||||
.title("Home").slug("home")
|
||||
.rawHtml("<h1>Home</h1>")
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), eq("gemini"), eq("custom-key"), any(), any())).thenReturn(structure);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"apiKey", "custom-key"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns429WhenRateLimited() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(false);
|
||||
@@ -127,7 +157,8 @@ class LLMControllerTest {
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -142,7 +173,8 @@ class LLMControllerTest {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -153,43 +185,12 @@ class LLMControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptBlank() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", " ",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdBlank() throws Exception {
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", ""
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
@@ -198,6 +199,26 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenNoKeyConfigured() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any()))
|
||||
.thenThrow(new IllegalStateException("No API key configured for gemini"));
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Build a site",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini"
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/generate")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("No API key configured for gemini"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -223,11 +244,12 @@ class LLMControllerTest {
|
||||
.build()
|
||||
))
|
||||
.build();
|
||||
when(llmService.refineSite(any(), any(), any(), anyString(), any())).thenReturn(refined);
|
||||
when(llmService.refineSite(any(), any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(refined);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Make it better",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", current
|
||||
);
|
||||
|
||||
@@ -247,6 +269,7 @@ class LLMControllerTest {
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"provider", "gemini",
|
||||
"currentStructure", Map.of()
|
||||
);
|
||||
|
||||
@@ -258,10 +281,11 @@ class LLMControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenPromptMissing() throws Exception {
|
||||
void returns400WhenProviderMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString(),
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
@@ -272,38 +296,6 @@ class LLMControllerTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenSiteIdMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"currentStructure", SiteStructure.builder().build()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returns400WhenCurrentStructureMissing() throws Exception {
|
||||
when(rateLimitConfig.tryConsume(userId)).thenReturn(true);
|
||||
|
||||
Map<String, Object> request = Map.of(
|
||||
"prompt", "Refine",
|
||||
"siteId", UUID.randomUUID().toString()
|
||||
);
|
||||
|
||||
mockMvc.perform(post("/api/llm/refine")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.service.MediaService;
|
||||
import com.krrishg.support.TestSecurityConfig;
|
||||
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.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(MediaController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class MediaControllerTest {
|
||||
|
||||
private final MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private MediaService mediaService;
|
||||
|
||||
private UUID userId;
|
||||
private UUID siteId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
|
||||
MediaControllerTest(@Autowired MockMvc mockMvc) {
|
||||
this.mockMvc = mockMvc;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadReturns200() throws Exception {
|
||||
MediaService.MediaUploadResult result = new MediaService.MediaUploadResult(
|
||||
"media/" + siteId + "/abc123.jpg",
|
||||
"https://cdn.example.com/media/" + siteId + "/abc123.jpg",
|
||||
"photo.jpg",
|
||||
"image/jpeg",
|
||||
1024L
|
||||
);
|
||||
when(mediaService.upload(any(), any())).thenReturn(result);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "photo.jpg", "image/jpeg", "test-image-content".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/media/upload")
|
||||
.file(file)
|
||||
.param("siteId", siteId.toString())
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.key").value("media/" + siteId + "/abc123.jpg"))
|
||||
.andExpect(jsonPath("$.url").value("https://cdn.example.com/media/" + siteId + "/abc123.jpg"))
|
||||
.andExpect(jsonPath("$.originalFilename").value("photo.jpg"))
|
||||
.andExpect(jsonPath("$.contentType").value("image/jpeg"))
|
||||
.andExpect(jsonPath("$.size").value(1024));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadReturns400WhenInvalidFile() throws Exception {
|
||||
when(mediaService.upload(any(), any()))
|
||||
.thenThrow(new IllegalArgumentException("File type not allowed"));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "bad.exe", "application/x-msdownload", "bad".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/media/upload")
|
||||
.file(file)
|
||||
.param("siteId", siteId.toString())
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("File type not allowed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMediaUrlReturns200() throws Exception {
|
||||
String key = "abc.jpg";
|
||||
when(mediaService.getPublicUrl(key)).thenReturn("https://cdn.example.com/media/" + key);
|
||||
|
||||
mockMvc.perform(get("/api/media/{key}", key)
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.key").value(key))
|
||||
.andExpect(jsonPath("$.url").value("https://cdn.example.com/media/abc.jpg"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -80,41 +80,22 @@ class SiteControllerTest {
|
||||
.userId(userId)
|
||||
.name("New Site")
|
||||
.description("Description")
|
||||
.subdomain("new-site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
when(siteRepository.existsBySubdomain("new-site")).thenReturn(false);
|
||||
when(siteRepository.save(any(Site.class))).thenReturn(saved);
|
||||
|
||||
SiteRequest request = new SiteRequest();
|
||||
request.setName("New Site");
|
||||
request.setDescription("Description");
|
||||
request.setSubdomain("new-site");
|
||||
|
||||
mockMvc.perform(post("/api/sites")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.name").value("New Site"))
|
||||
.andExpect(jsonPath("$.subdomain").value("new-site"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSiteReturns400OnDuplicateSubdomain() throws Exception {
|
||||
when(siteRepository.existsBySubdomain("taken")).thenReturn(true);
|
||||
|
||||
SiteRequest request = new SiteRequest();
|
||||
request.setName("Site");
|
||||
request.setSubdomain("taken");
|
||||
|
||||
mockMvc.perform(post("/api/sites")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
.andExpect(jsonPath("$.name").value("New Site"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,23 +154,19 @@ class SiteControllerTest {
|
||||
.id(siteId)
|
||||
.userId(userId)
|
||||
.name("Old Name")
|
||||
.subdomain("old-sub")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.build();
|
||||
when(siteRepository.findById(siteId)).thenReturn(Optional.of(existing));
|
||||
when(siteRepository.existsBySubdomain("new-sub")).thenReturn(false);
|
||||
when(siteRepository.save(any(Site.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
SiteRequest request = new SiteRequest();
|
||||
request.setName("New Name");
|
||||
request.setSubdomain("new-sub");
|
||||
|
||||
mockMvc.perform(put("/api/sites/{id}", siteId)
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("New Name"))
|
||||
.andExpect(jsonPath("$.subdomain").value("new-sub"));
|
||||
.andExpect(jsonPath("$.name").value("New Name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.krrishg.controller;
|
||||
|
||||
import com.krrishg.config.EncryptionService;
|
||||
import com.krrishg.config.GlobalExceptionHandler;
|
||||
import com.krrishg.config.JwtAuthenticationToken;
|
||||
import com.krrishg.config.UserPrincipal;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.repository.UserRepository;
|
||||
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.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
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(UserSettingsController.class)
|
||||
@Import({GlobalExceptionHandler.class, TestSecurityConfig.class})
|
||||
class UserSettingsControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private UserRepository userRepository;
|
||||
|
||||
@MockBean
|
||||
private EncryptionService encryptionService;
|
||||
|
||||
private UUID userId;
|
||||
private UserPrincipal principal;
|
||||
private JwtAuthenticationToken auth;
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = UUID.randomUUID();
|
||||
principal = new UserPrincipal(userId, "user@test.com", "Test User");
|
||||
auth = new JwtAuthenticationToken(principal, "test-token");
|
||||
testUser = User.builder()
|
||||
.id(userId)
|
||||
.email("user@test.com")
|
||||
.name("Test User")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturnsConfigWithAllFields() throws Exception {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
keys.put("gemini", "encrypted-gemini-key");
|
||||
Map<String, String> urls = new HashMap<>();
|
||||
urls.put("gemini", "https://custom.gemini.com");
|
||||
Map<String, String> models = new HashMap<>();
|
||||
models.put("gemini", "gemini-2.0-flash");
|
||||
testUser.setLlmApiKeys(keys);
|
||||
testUser.setLlmBaseUrls(urls);
|
||||
testUser.setLlmModels(models);
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.gemini.configured").value(true))
|
||||
.andExpect(jsonPath("$.gemini.baseUrl").value("https://custom.gemini.com"))
|
||||
.andExpect(jsonPath("$.gemini.model").value("gemini-2.0-flash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturnsMinimalConfigWhenNoUrlsOrModels() throws Exception {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
keys.put("openai", "encrypted-openai-key");
|
||||
testUser.setLlmApiKeys(keys);
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.openai.configured").value(true))
|
||||
.andExpect(jsonPath("$.openai.baseUrl").doesNotExist())
|
||||
.andExpect(jsonPath("$.openai.model").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturnsEmptyMapWhenNoKeys() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("{}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLlmConfigReturns400WhenUserNotFound() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/user/llm-config")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigSavesApiKey() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(encryptionService.encrypt("sk-test-key")).thenReturn("encrypted-key");
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "openai");
|
||||
request.put("apiKey", "sk-test-key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigSavesBaseUrlAndModel() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(encryptionService.encrypt("key")).thenReturn("encrypted");
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "gemini");
|
||||
request.put("apiKey", "key");
|
||||
request.put("baseUrl", "https://gemini.api.com");
|
||||
request.put("model", "gemini-2.0-flash");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigMergesWithExistingConfig() throws Exception {
|
||||
Map<String, String> existingKeys = new HashMap<>();
|
||||
existingKeys.put("gemini", "existing-gemini-key");
|
||||
testUser.setLlmApiKeys(existingKeys);
|
||||
testUser.setLlmBaseUrls(new HashMap<>(Map.of("gemini", "https://gemini.api.com")));
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(encryptionService.encrypt("new-openai-key")).thenReturn("encrypted-new-key");
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "openai");
|
||||
request.put("apiKey", "new-openai-key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigReturns400WhenProviderIsBlank() throws Exception {
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "");
|
||||
request.put("apiKey", "key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigReturns400WhenProviderIsNull() throws Exception {
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("apiKey", "key");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveLlmConfigSkipsEmptyApiKey() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
Map<String, String> request = new HashMap<>();
|
||||
request.put("provider", "openai");
|
||||
request.put("apiKey", "");
|
||||
|
||||
mockMvc.perform(put("/api/user/llm-config")
|
||||
.with(authentication(auth))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertNull(testUser.getLlmApiKeys());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteLlmConfigRemovesProviderConfig() throws Exception {
|
||||
Map<String, String> keys = new HashMap<>();
|
||||
keys.put("gemini", "encrypted-key");
|
||||
keys.put("openai", "encrypted-key");
|
||||
testUser.setLlmApiKeys(keys);
|
||||
testUser.setLlmBaseUrls(new HashMap<>(Map.of("gemini", "url")));
|
||||
testUser.setLlmModels(new HashMap<>(Map.of("gemini", "model")));
|
||||
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
mockMvc.perform(delete("/api/user/llm-config/gemini")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(userRepository).save(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteLlmConfigReturns400WhenUserNotFound() throws Exception {
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(delete("/api/user/llm-config/openai")
|
||||
.with(authentication(auth)))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
58
backend/src/test/java/com/krrishg/dto/AuthResponseTest.java
Normal file
58
backend/src/test/java/com/krrishg/dto/AuthResponseTest.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthResponseTest {
|
||||
|
||||
@Test
|
||||
void constructorSetsBearerTokenType() {
|
||||
AuthResponse.UserInfo userInfo = new AuthResponse.UserInfo(
|
||||
UUID.randomUUID(), "test@example.com", "Test", null);
|
||||
|
||||
AuthResponse response = new AuthResponse("access-token", "refresh-token", userInfo);
|
||||
|
||||
assertEquals("access-token", response.getAccessToken());
|
||||
assertEquals("refresh-token", response.getRefreshToken());
|
||||
assertEquals("Bearer", response.getTokenType());
|
||||
assertSame(userInfo, response.getUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderWorksWithTokenType() {
|
||||
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.email("user@example.com")
|
||||
.name("User")
|
||||
.build();
|
||||
|
||||
AuthResponse response = AuthResponse.builder()
|
||||
.accessToken("at")
|
||||
.refreshToken("rt")
|
||||
.tokenType("Custom")
|
||||
.user(userInfo)
|
||||
.build();
|
||||
|
||||
assertEquals("Custom", response.getTokenType());
|
||||
assertEquals("user@example.com", response.getUser().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userInfoBuilderSetsAllFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
AuthResponse.UserInfo userInfo = AuthResponse.UserInfo.builder()
|
||||
.id(id)
|
||||
.email("avatar@example.com")
|
||||
.name("Avatar User")
|
||||
.avatarUrl("https://example.com/avatar.png")
|
||||
.build();
|
||||
|
||||
assertEquals(id, userInfo.getId());
|
||||
assertEquals("avatar@example.com", userInfo.getEmail());
|
||||
assertEquals("Avatar User", userInfo.getName());
|
||||
assertEquals("https://example.com/avatar.png", userInfo.getAvatarUrl());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LLMConfigRequestTest {
|
||||
|
||||
@Test
|
||||
void compactConstructorSetsProviderAndApiKey() {
|
||||
LLMConfigRequest request = new LLMConfigRequest("gemini", "test-key");
|
||||
|
||||
assertEquals("gemini", request.provider());
|
||||
assertEquals("test-key", request.apiKey());
|
||||
assertNull(request.baseUrl());
|
||||
assertNull(request.model());
|
||||
}
|
||||
|
||||
@Test
|
||||
void threeArgConstructorSetsProviderApiKeyAndBaseUrl() {
|
||||
LLMConfigRequest request = new LLMConfigRequest("openai", "sk-test", "https://api.openai.com");
|
||||
|
||||
assertEquals("openai", request.provider());
|
||||
assertEquals("sk-test", request.apiKey());
|
||||
assertEquals("https://api.openai.com", request.baseUrl());
|
||||
assertNull(request.model());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullConstructorSetsAllFields() {
|
||||
LLMConfigRequest request = new LLMConfigRequest("gemini", "key", "https://custom.url", "gemini-2.0-flash");
|
||||
|
||||
assertEquals("gemini", request.provider());
|
||||
assertEquals("key", request.apiKey());
|
||||
assertEquals("https://custom.url", request.baseUrl());
|
||||
assertEquals("gemini-2.0-flash", request.model());
|
||||
}
|
||||
}
|
||||
30
backend/src/test/java/com/krrishg/dto/LLMOptionsTest.java
Normal file
30
backend/src/test/java/com/krrishg/dto/LLMOptionsTest.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LLMOptionsTest {
|
||||
|
||||
@Test
|
||||
void builderDefaults() {
|
||||
LLMOptions options = LLMOptions.builder().build();
|
||||
|
||||
assertEquals(0.9, options.getTemperature());
|
||||
assertEquals(4096, options.getMaxTokens());
|
||||
assertEquals(60, options.getTimeoutSeconds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderOverridesDefaults() {
|
||||
LLMOptions options = LLMOptions.builder()
|
||||
.temperature(0.5)
|
||||
.maxTokens(2048)
|
||||
.timeoutSeconds(30)
|
||||
.build();
|
||||
|
||||
assertEquals(0.5, options.getTemperature());
|
||||
assertEquals(2048, options.getMaxTokens());
|
||||
assertEquals(30, options.getTimeoutSeconds());
|
||||
}
|
||||
}
|
||||
38
backend/src/test/java/com/krrishg/dto/ReferenceTest.java
Normal file
38
backend/src/test/java/com/krrishg/dto/ReferenceTest.java
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ReferenceTest {
|
||||
|
||||
@Test
|
||||
void builderSetsAllFields() {
|
||||
Reference ref = Reference.builder()
|
||||
.type(Reference.ReferenceType.URL)
|
||||
.url("https://example.com")
|
||||
.altText("Example Site")
|
||||
.build();
|
||||
|
||||
assertEquals(Reference.ReferenceType.URL, ref.getType());
|
||||
assertEquals("https://example.com", ref.getUrl());
|
||||
assertEquals("Example Site", ref.getAltText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderDefaultsToNull() {
|
||||
Reference ref = Reference.builder().build();
|
||||
|
||||
assertNull(ref.getType());
|
||||
assertNull(ref.getUrl());
|
||||
assertNull(ref.getAltText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void referenceTypeEnumValues() {
|
||||
assertArrayEquals(
|
||||
new Reference.ReferenceType[]{Reference.ReferenceType.URL, Reference.ReferenceType.IMAGE},
|
||||
Reference.ReferenceType.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfHostedConfigResponseTest {
|
||||
|
||||
@Test
|
||||
void fromMapsAllFields() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID siteId = UUID.randomUUID();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(id)
|
||||
.siteId(siteId)
|
||||
.domain("example.com")
|
||||
.sshHost("192.168.1.1")
|
||||
.sshPort(2222)
|
||||
.sshUser("deploy")
|
||||
.deployPath("/var/www/html")
|
||||
.nginxSitesPath("/etc/nginx/conf.d")
|
||||
.sslEnabled(true)
|
||||
.sslEmail("admin@example.com")
|
||||
.deployedAt(now)
|
||||
.createdAt(now)
|
||||
.updatedAt(now)
|
||||
.build();
|
||||
|
||||
SelfHostedConfigResponse response = SelfHostedConfigResponse.from(config, "ssh-ed25519 AAA...");
|
||||
|
||||
assertEquals(id, response.getId());
|
||||
assertEquals(siteId, response.getSiteId());
|
||||
assertEquals("example.com", response.getDomain());
|
||||
assertEquals("192.168.1.1", response.getSshHost());
|
||||
assertEquals(2222, response.getSshPort());
|
||||
assertEquals("deploy", response.getSshUser());
|
||||
assertEquals("/var/www/html", response.getDeployPath());
|
||||
assertEquals("/etc/nginx/conf.d", response.getNginxSitesPath());
|
||||
assertTrue(response.isSslEnabled());
|
||||
assertEquals("admin@example.com", response.getSslEmail());
|
||||
assertEquals("ssh-ed25519 AAA...", response.getPublicKey());
|
||||
assertEquals(now, response.getDeployedAt());
|
||||
assertEquals(now, response.getCreatedAt());
|
||||
assertEquals(now, response.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromHandlesNullOptionalFields() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
|
||||
SelfHostedConfigResponse response = SelfHostedConfigResponse.from(config, null);
|
||||
|
||||
assertNull(response.getSslEmail());
|
||||
assertNull(response.getPublicKey());
|
||||
assertNull(response.getDeployedAt());
|
||||
assertFalse(response.isSslEnabled());
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ class SiteResponseTest {
|
||||
.userId(userId)
|
||||
.name("My Site")
|
||||
.description("A test site")
|
||||
.subdomain("mysite")
|
||||
.status(SiteStatus.PUBLISHED)
|
||||
.publishedAt(now)
|
||||
.createdAt(now)
|
||||
@@ -35,7 +34,6 @@ class SiteResponseTest {
|
||||
assertEquals(userId, response.getUserId());
|
||||
assertEquals("My Site", response.getName());
|
||||
assertEquals("A test site", response.getDescription());
|
||||
assertEquals("mysite", response.getSubdomain());
|
||||
assertEquals(SiteStatus.PUBLISHED, response.getStatus());
|
||||
assertEquals(now, response.getPublishedAt());
|
||||
assertEquals(now, response.getCreatedAt());
|
||||
@@ -54,7 +52,6 @@ class SiteResponseTest {
|
||||
SiteResponse response = SiteResponse.from(site);
|
||||
|
||||
assertNull(response.getDescription());
|
||||
assertNull(response.getSubdomain());
|
||||
assertNull(response.getPublishedAt());
|
||||
}
|
||||
}
|
||||
|
||||
57
backend/src/test/java/com/krrishg/dto/SiteStructureTest.java
Normal file
57
backend/src/test/java/com/krrishg/dto/SiteStructureTest.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.krrishg.dto;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SiteStructureTest {
|
||||
|
||||
@Test
|
||||
void builderSetsAllFields() {
|
||||
Map<String, String> colors = Map.of("primary", "#000", "secondary", "#fff");
|
||||
Map<String, String> fonts = Map.of("heading", "Arial");
|
||||
Map<String, String> spacing = Map.of("margin", "16px");
|
||||
|
||||
SiteStructure.ThemeConfig theme = new SiteStructure.ThemeConfig(colors, fonts, spacing);
|
||||
|
||||
SiteStructure.PageStructure page = SiteStructure.PageStructure.builder()
|
||||
.id("home")
|
||||
.title("Home")
|
||||
.slug("/")
|
||||
.seoTitle("Home Page")
|
||||
.seoDescription("Welcome")
|
||||
.orderIndex(0)
|
||||
.rawHtml("<h1>Hello</h1>")
|
||||
.build();
|
||||
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(theme)
|
||||
.pages(List.of(page))
|
||||
.build();
|
||||
|
||||
assertNotNull(structure.getTheme());
|
||||
assertEquals("#000", structure.getTheme().getColors().get("primary"));
|
||||
assertEquals("Arial", structure.getTheme().getFonts().get("heading"));
|
||||
assertEquals("16px", structure.getTheme().getSpacing().get("margin"));
|
||||
|
||||
assertEquals(1, structure.getPages().size());
|
||||
assertEquals("home", structure.getPages().get(0).getId());
|
||||
assertEquals("Home", structure.getPages().get(0).getTitle());
|
||||
assertEquals("/", structure.getPages().get(0).getSlug());
|
||||
assertEquals("Home Page", structure.getPages().get(0).getSeoTitle());
|
||||
assertEquals("Welcome", structure.getPages().get(0).getSeoDescription());
|
||||
assertEquals(0, structure.getPages().get(0).getOrderIndex());
|
||||
assertEquals("<h1>Hello</h1>", structure.getPages().get(0).getRawHtml());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderHandlesNullOptionalFields() {
|
||||
SiteStructure structure = SiteStructure.builder().build();
|
||||
|
||||
assertNull(structure.getTheme());
|
||||
assertNull(structure.getPages());
|
||||
}
|
||||
}
|
||||
48
backend/src/test/java/com/krrishg/engine/JsBundleTest.java
Normal file
48
backend/src/test/java/com/krrishg/engine/JsBundleTest.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.krrishg.engine;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class JsBundleTest {
|
||||
|
||||
private final JsBundle jsBundle = new JsBundle();
|
||||
|
||||
@Test
|
||||
void compileReturnsNonEmptyJs() {
|
||||
String js = jsBundle.compile();
|
||||
assertNotNull(js);
|
||||
assertFalse(js.isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsDomContentLoaded() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("DOMContentLoaded"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsNavToggle() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("nav-toggle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsSmoothScroll() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("scrollIntoView"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsLightboxHandler() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("lightbox"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsContactFormHandler() {
|
||||
String js = jsBundle.compile();
|
||||
assertTrue(js.contains("data-static"));
|
||||
assertTrue(js.contains("Message sent"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.krrishg.engine;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class StyleCompilerTest {
|
||||
|
||||
private final StyleCompiler compiler = new StyleCompiler();
|
||||
|
||||
@Test
|
||||
void compileReturnsNonEmptyCss() {
|
||||
String css = compiler.compile();
|
||||
assertNotNull(css);
|
||||
assertFalse(css.isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsCssVariables() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.contains("--color-text"));
|
||||
assertTrue(css.contains("--color-background"));
|
||||
assertTrue(css.contains("--font-body"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsContainerMaxWidth() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.contains("--spacing-containerWidth"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileContainsResponsiveQueries() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.contains("@media (max-width: 768px)"));
|
||||
assertTrue(css.contains("@media (max-width: 480px)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileStartsWithUniversalReset() {
|
||||
String css = compiler.compile();
|
||||
assertTrue(css.startsWith("* { margin: 0; padding: 0; box-sizing: border-box; }"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthProviderTest {
|
||||
|
||||
@Test
|
||||
void enumValues() {
|
||||
assertArrayEquals(
|
||||
new AuthProvider[]{AuthProvider.LOCAL},
|
||||
AuthProvider.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GenerationVersionTest {
|
||||
|
||||
@Test
|
||||
void builderSetsAllFields() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.id("mongo-id")
|
||||
.siteId(siteId)
|
||||
.userId(userId)
|
||||
.versionNumber(1)
|
||||
.prompt("Build a landing page")
|
||||
.fullStructure("{\"theme\":{}}")
|
||||
.createdAt(now)
|
||||
.build();
|
||||
|
||||
assertEquals("mongo-id", version.getId());
|
||||
assertEquals(siteId, version.getSiteId());
|
||||
assertEquals(userId, version.getUserId());
|
||||
assertEquals(1, version.getVersionNumber());
|
||||
assertEquals("Build a landing page", version.getPrompt());
|
||||
assertEquals("{\"theme\":{}}", version.getFullStructure());
|
||||
assertEquals(now, version.getCreatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSupportsReferenceEntries() {
|
||||
GenerationVersion.ReferenceEntry entry = GenerationVersion.ReferenceEntry.builder()
|
||||
.type("IMAGE")
|
||||
.url("https://example.com/img.png")
|
||||
.altText("Screenshot")
|
||||
.build();
|
||||
|
||||
GenerationVersion version = GenerationVersion.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.userId(UUID.randomUUID())
|
||||
.versionNumber(2)
|
||||
.prompt("With references")
|
||||
.references(List.of(entry))
|
||||
.build();
|
||||
|
||||
assertEquals(1, version.getReferences().size());
|
||||
assertEquals("IMAGE", version.getReferences().get(0).getType());
|
||||
assertEquals("https://example.com/img.png", version.getReferences().get(0).getUrl());
|
||||
assertEquals("Screenshot", version.getReferences().get(0).getAltText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noArgsConstructorCreatesEmptyObject() {
|
||||
GenerationVersion version = new GenerationVersion();
|
||||
assertNull(version.getId());
|
||||
assertNull(version.getSiteId());
|
||||
assertEquals(0, version.getVersionNumber());
|
||||
}
|
||||
}
|
||||
70
backend/src/test/java/com/krrishg/model/GitRemoteTest.java
Normal file
70
backend/src/test/java/com/krrishg/model/GitRemoteTest.java
Normal file
@@ -0,0 +1,70 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class GitRemoteTest {
|
||||
|
||||
@Test
|
||||
void onCreateSetsIdAndTimestamps() {
|
||||
GitRemote remote = new GitRemote();
|
||||
remote.onCreate();
|
||||
|
||||
assertNotNull(remote.getId());
|
||||
assertNotNull(remote.getCreatedAt());
|
||||
assertNotNull(remote.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onCreateDoesNotOverrideExistingId() {
|
||||
java.util.UUID existingId = java.util.UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.id(existingId)
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
|
||||
remote.onCreate();
|
||||
|
||||
assertEquals(existingId, remote.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onUpdateSetsUpdatedAt() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
remote.onCreate();
|
||||
|
||||
java.time.LocalDateTime before = remote.getUpdatedAt();
|
||||
remote.onUpdate();
|
||||
|
||||
assertTrue(remote.getUpdatedAt().isAfter(before) || remote.getUpdatedAt().equals(before));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSetsDefaults() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
|
||||
assertEquals("main", remote.getDefaultBranch());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitProviderEnumValues() {
|
||||
assertArrayEquals(
|
||||
new GitRemote.GitProvider[]{GitRemote.GitProvider.GITHUB, GitRemote.GitProvider.GITLAB},
|
||||
GitRemote.GitProvider.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfHostedConfigTest {
|
||||
|
||||
@Test
|
||||
void prePersistSetsIdAndTimestamps() {
|
||||
SelfHostedConfig config = new SelfHostedConfig();
|
||||
config.setSiteId(UUID.randomUUID());
|
||||
config.setDomain("example.com");
|
||||
config.setSshHost("192.168.1.1");
|
||||
config.setSshUser("deploy");
|
||||
|
||||
assertNull(config.getId());
|
||||
assertNull(config.getCreatedAt());
|
||||
assertNull(config.getUpdatedAt());
|
||||
|
||||
config.onCreate();
|
||||
|
||||
assertNotNull(config.getId());
|
||||
assertNotNull(config.getCreatedAt());
|
||||
assertNotNull(config.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prePersistDoesNotOverrideExistingId() {
|
||||
UUID existingId = UUID.randomUUID();
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.id(existingId)
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
|
||||
config.onCreate();
|
||||
|
||||
assertEquals(existingId, config.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preUpdateSetsUpdatedAt() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
config.onCreate();
|
||||
|
||||
LocalDateTime original = config.getUpdatedAt();
|
||||
|
||||
config.onUpdate();
|
||||
|
||||
assertTrue(config.getUpdatedAt().isAfter(original));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builderSetsDefaults() {
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.domain("example.com")
|
||||
.sshHost("host")
|
||||
.sshUser("user")
|
||||
.build();
|
||||
|
||||
assertEquals(22, config.getSshPort());
|
||||
assertEquals("/var/www/{domain}/public", config.getDeployPath());
|
||||
assertEquals("/etc/nginx/sites-available", config.getNginxSitesPath());
|
||||
assertFalse(config.isSslEnabled());
|
||||
}
|
||||
}
|
||||
16
backend/src/test/java/com/krrishg/model/SiteStatusTest.java
Normal file
16
backend/src/test/java/com/krrishg/model/SiteStatusTest.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.krrishg.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SiteStatusTest {
|
||||
|
||||
@Test
|
||||
void enumValues() {
|
||||
assertArrayEquals(
|
||||
new SiteStatus[]{SiteStatus.DRAFT, SiteStatus.PUBLISHED},
|
||||
SiteStatus.values()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ class UserTest {
|
||||
User user = new User();
|
||||
user.setEmail("test@example.com");
|
||||
user.setName("Test User");
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
|
||||
assertNull(user.getId());
|
||||
assertNull(user.getCreatedAt());
|
||||
@@ -33,7 +32,6 @@ class UserTest {
|
||||
.id(UUID.randomUUID())
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
UUID existingId = user.getId();
|
||||
@@ -47,7 +45,6 @@ class UserTest {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
user.onCreate();
|
||||
|
||||
@@ -63,9 +60,9 @@ class UserTest {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
assertFalse(user.isEmailVerified());
|
||||
assertEquals(AuthProvider.LOCAL, user.getProvider());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.GitRemote;
|
||||
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 GitRemoteRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private GitRemoteRepository repository;
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsRemoteWhenExists() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
repository.save(remote);
|
||||
|
||||
Optional<GitRemote> found = repository.findBySiteId(siteId);
|
||||
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals(siteId, found.get().getSiteId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySiteIdReturnsEmptyWhenNotExists() {
|
||||
Optional<GitRemote> found = repository.findBySiteId(UUID.randomUUID());
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsTrueWhenExists() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
repository.save(remote);
|
||||
|
||||
assertTrue(repository.existsBySiteId(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySiteIdReturnsFalseWhenNotExists() {
|
||||
assertFalse(repository.existsBySiteId(UUID.randomUUID()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteBySiteIdRemovesRemote() {
|
||||
UUID siteId = UUID.randomUUID();
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(siteId)
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
repository.save(remote);
|
||||
|
||||
repository.deleteBySiteId(siteId);
|
||||
|
||||
assertFalse(repository.existsBySiteId(siteId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedRemoteHasIdAndTimestamps() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/user/repo.git")
|
||||
.encryptedToken("encrypted-token")
|
||||
.build();
|
||||
GitRemote saved = repository.save(remote);
|
||||
|
||||
assertNotNull(saved.getId());
|
||||
assertNotNull(saved.getCreatedAt());
|
||||
assertNotNull(saved.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -60,30 +60,4 @@ class SiteRepositoryTest {
|
||||
assertTrue(sites.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findBySubdomain() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Site")
|
||||
.subdomain("mysubdomain")
|
||||
.build();
|
||||
siteRepository.save(site);
|
||||
|
||||
Optional<Site> found = siteRepository.findBySubdomain("mysubdomain");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("Site", found.get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsBySubdomain() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("Site")
|
||||
.subdomain("exists-sub")
|
||||
.build();
|
||||
siteRepository.save(site);
|
||||
|
||||
assertTrue(siteRepository.existsBySubdomain("exists-sub"));
|
||||
assertFalse(siteRepository.existsBySubdomain("no-sub"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.krrishg.repository;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.model.User;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -25,7 +24,6 @@ class UserRepositoryTest {
|
||||
User user = User.builder()
|
||||
.email("test@example.com")
|
||||
.name("Test User")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
|
||||
User saved = userRepository.save(user);
|
||||
@@ -42,7 +40,6 @@ class UserRepositoryTest {
|
||||
User user = User.builder()
|
||||
.email("find@example.com")
|
||||
.name("Find Me")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
@@ -57,27 +54,11 @@ class UserRepositoryTest {
|
||||
assertTrue(found.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByProviderAndProviderId() {
|
||||
User user = User.builder()
|
||||
.email("oauth@example.com")
|
||||
.name("OAuth User")
|
||||
.provider(AuthProvider.GOOGLE)
|
||||
.providerId("google-123")
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
Optional<User> found = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("oauth@example.com", found.get().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsByEmail() {
|
||||
User user = User.builder()
|
||||
.email("exists@example.com")
|
||||
.name("Exists")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
|
||||
@@ -4,60 +4,110 @@ import com.krrishg.dto.AuthResponse;
|
||||
import com.krrishg.dto.LoginRequest;
|
||||
import com.krrishg.dto.RefreshTokenRequest;
|
||||
import com.krrishg.dto.RegisterRequest;
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.dto.RegistrationResponse;
|
||||
import com.krrishg.dto.ResendVerificationRequest;
|
||||
import com.krrishg.dto.SetPasswordRequest;
|
||||
import com.krrishg.dto.VerifyEmailRequest;
|
||||
import com.krrishg.dto.VerifyEmailResponse;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.model.VerificationToken;
|
||||
import com.krrishg.support.FakeJwtConfig;
|
||||
import com.krrishg.support.FakeUserRepository;
|
||||
import com.krrishg.support.FakeVerificationTokenRepository;
|
||||
import com.krrishg.support.StubEmailService;
|
||||
import com.krrishg.support.StubPasswordEncoder;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AuthServiceTest {
|
||||
|
||||
private FakeUserRepository userRepository;
|
||||
private FakeVerificationTokenRepository tokenRepository;
|
||||
private StubEmailService emailService;
|
||||
private AuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = new FakeUserRepository();
|
||||
authService = new AuthService(userRepository, new StubPasswordEncoder(), new FakeJwtConfig());
|
||||
tokenRepository = new FakeVerificationTokenRepository();
|
||||
emailService = new StubEmailService();
|
||||
authService = new AuthService(userRepository, new StubPasswordEncoder(),
|
||||
new FakeJwtConfig(), tokenRepository, emailService, 1440);
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesUserAndReturnsTokens() {
|
||||
void registerCreatesUserAndSendsVerification() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("new@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("New User");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
RegistrationResponse response = authService.register(request);
|
||||
|
||||
assertNotNull(response.getAccessToken());
|
||||
assertNotNull(response.getRefreshToken());
|
||||
assertEquals("Bearer", response.getTokenType());
|
||||
assertEquals("new@example.com", response.getUser().getEmail());
|
||||
assertEquals("New User", response.getUser().getName());
|
||||
assertEquals(AuthProvider.LOCAL, response.getUser().getProvider());
|
||||
assertEquals("If this email is available, a verification link has been sent.", response.getMessage());
|
||||
assertTrue(userRepository.findByEmail("new@example.com").isPresent());
|
||||
assertFalse(userRepository.findByEmail("new@example.com").get().isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerThrowsOnDuplicateEmail() {
|
||||
void registerSendsVerificationEmail() {
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("dup@example.com");
|
||||
request.setEmail("emailtest@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("User");
|
||||
request.setName("Email Test");
|
||||
|
||||
authService.register(request);
|
||||
|
||||
RegisterRequest duplicate = new RegisterRequest();
|
||||
duplicate.setEmail("dup@example.com");
|
||||
duplicate.setPassword("password456");
|
||||
duplicate.setName("Other");
|
||||
assertEquals("emailtest@example.com", emailService.getLastTo());
|
||||
assertNotNull(emailService.getLastToken());
|
||||
assertEquals(1, emailService.getSendCount());
|
||||
}
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.register(duplicate));
|
||||
assertEquals("Email already registered", ex.getMessage());
|
||||
@Test
|
||||
void registerReturnsGenericMessageForExistingVerifiedEmail() {
|
||||
User user = User.builder()
|
||||
.email("verified@example.com")
|
||||
.name("Verified")
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("verified@example.com");
|
||||
request.setPassword("password123");
|
||||
request.setName("Should Not Matter");
|
||||
|
||||
RegistrationResponse response = authService.register(request);
|
||||
|
||||
assertEquals("If this email is available, a verification link has been sent.", response.getMessage());
|
||||
assertEquals(0, emailService.getSendCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerResendsForExistingUnverifiedEmail() {
|
||||
User user = User.builder()
|
||||
.email("unverified@example.com")
|
||||
.name("Unverified")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
RegisterRequest request = new RegisterRequest();
|
||||
request.setEmail("unverified@example.com");
|
||||
request.setPassword("newpassword");
|
||||
request.setName("New Name");
|
||||
|
||||
RegistrationResponse response = authService.register(request);
|
||||
|
||||
assertEquals("If this email is available, a verification link has been sent.", response.getMessage());
|
||||
assertEquals(1, emailService.getSendCount());
|
||||
assertEquals("unverified@example.com", emailService.getLastTo());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,19 +117,23 @@ class AuthServiceTest {
|
||||
request.setPassword("password123");
|
||||
request.setName("User");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
authService.register(request);
|
||||
|
||||
assertEquals("uppercase@example.com", response.getUser().getEmail());
|
||||
assertTrue(userRepository.findByEmail("uppercase@example.com").isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginWithValidCredentialsReturnsTokens() {
|
||||
void loginWithValidCredentialsAndVerifiedEmailReturnsTokens() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("login@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Login User");
|
||||
authService.register(reg);
|
||||
|
||||
User user = userRepository.findByEmail("login@example.com").get();
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("login@example.com");
|
||||
login.setPassword("password123");
|
||||
@@ -91,6 +145,23 @@ class AuthServiceTest {
|
||||
assertEquals("login@example.com", response.getUser().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginThrowsWhenEmailNotVerified() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
reg.setEmail("unverified@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Unverified");
|
||||
authService.register(reg);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("unverified@example.com");
|
||||
login.setPassword("password123");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.login(login));
|
||||
assertEquals("Please verify your email address before logging in", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginThrowsOnWrongPassword() {
|
||||
RegisterRequest reg = new RegisterRequest();
|
||||
@@ -99,6 +170,10 @@ class AuthServiceTest {
|
||||
reg.setName("User");
|
||||
authService.register(reg);
|
||||
|
||||
User user = userRepository.findByEmail("wrongpw@example.com").get();
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("wrongpw@example.com");
|
||||
login.setPassword("wrongpw");
|
||||
@@ -120,22 +195,285 @@ class AuthServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginThrowsOnOAuthUserTryingPasswordLogin() {
|
||||
User oauthUser = User.builder()
|
||||
.email("oauth@example.com")
|
||||
.name("OAuth User")
|
||||
.provider(AuthProvider.GOOGLE)
|
||||
.providerId("google-123")
|
||||
void verifyEmailMarksUserVerifiedAndReturnsSetupToken() {
|
||||
User user = User.builder()
|
||||
.email("verify@example.com")
|
||||
.name("Verify")
|
||||
.password("encoded")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
userRepository.save(oauthUser);
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
LoginRequest login = new LoginRequest();
|
||||
login.setEmail("oauth@example.com");
|
||||
login.setPassword("anypassword");
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("valid-token-123")
|
||||
.expiryDate(LocalDateTime.now().plusHours(24))
|
||||
.used(false)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("valid-token-123");
|
||||
|
||||
VerifyEmailResponse response = authService.verifyEmail(request);
|
||||
|
||||
assertEquals("Email verified successfully", response.getMessage());
|
||||
assertNotNull(response.getSetupToken());
|
||||
|
||||
User updatedUser = userRepository.findById(user.getId()).get();
|
||||
assertTrue(updatedUser.isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailThrowsForInvalidToken() {
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("nonexistent-token");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.login(login));
|
||||
assertEquals("Please sign in with google", ex.getMessage());
|
||||
() -> authService.verifyEmail(request));
|
||||
assertEquals("Invalid verification token", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailThrowsForUsedToken() {
|
||||
User user = User.builder()
|
||||
.email("usedtoken@example.com")
|
||||
.name("Used")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("used-token")
|
||||
.expiryDate(LocalDateTime.now().plusHours(24))
|
||||
.used(true)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("used-token");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.verifyEmail(request));
|
||||
assertEquals("Verification token has already been used", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyEmailThrowsForExpiredToken() {
|
||||
User user = User.builder()
|
||||
.email("expired@example.com")
|
||||
.name("Expired")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("expired-token")
|
||||
.expiryDate(LocalDateTime.now().minusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
VerifyEmailRequest request = new VerifyEmailRequest();
|
||||
request.setToken("expired-token");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.verifyEmail(request));
|
||||
assertEquals("Verification token has expired", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordUpdatesPassword() {
|
||||
User user = User.builder()
|
||||
.email("setpw@example.com")
|
||||
.name("SetPw")
|
||||
.password("old-encoded")
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("verify-token")
|
||||
.setupToken("setup-token-123")
|
||||
.setupTokenUsed(false)
|
||||
.expiryDate(LocalDateTime.now().plusHours(24))
|
||||
.used(true)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("setup-token-123");
|
||||
request.setPassword("new-password");
|
||||
|
||||
authService.setPassword(request);
|
||||
|
||||
User updatedUser = userRepository.findById(user.getId()).get();
|
||||
assertTrue(updatedUser.getPassword().contains("new-password"));
|
||||
|
||||
VerificationToken updatedToken = tokenRepository.findByToken("verify-token").get();
|
||||
assertTrue(updatedToken.isSetupTokenUsed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordThrowsForInvalidSetupToken() {
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("invalid-setup-token");
|
||||
request.setPassword("new-password");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.setPassword(request));
|
||||
assertEquals("Invalid setup token", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordThrowsWhenEmailNotVerifiedFirst() {
|
||||
User user = User.builder()
|
||||
.email("notverified@example.com")
|
||||
.name("NotVerified")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("unused-verify-token")
|
||||
.setupToken("setup-token-bad")
|
||||
.setupTokenUsed(false)
|
||||
.expiryDate(LocalDateTime.now().plusHours(24))
|
||||
.used(false)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("setup-token-bad");
|
||||
request.setPassword("new-password");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.setPassword(request));
|
||||
assertEquals("Email must be verified first", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordThrowsForAlreadyUsedSetupToken() {
|
||||
User user = User.builder()
|
||||
.email("alreadyset@example.com")
|
||||
.name("AlreadySet")
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("used-verify-token")
|
||||
.setupToken("already-used-setup")
|
||||
.setupTokenUsed(true)
|
||||
.expiryDate(LocalDateTime.now().plusHours(24))
|
||||
.used(true)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("already-used-setup");
|
||||
request.setPassword("new-password");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.setPassword(request));
|
||||
assertEquals("Setup token has already been used", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setPasswordThrowsForExpiredSetupToken() {
|
||||
User user = User.builder()
|
||||
.email("expiredsetup@example.com")
|
||||
.name("ExpiredSetup")
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
VerificationToken token = VerificationToken.builder()
|
||||
.userId(user.getId())
|
||||
.token("expired-verify-token")
|
||||
.setupToken("expired-setup-token")
|
||||
.setupTokenUsed(false)
|
||||
.expiryDate(LocalDateTime.now().minusHours(1))
|
||||
.used(true)
|
||||
.build();
|
||||
token.onCreate();
|
||||
tokenRepository.save(token);
|
||||
|
||||
SetPasswordRequest request = new SetPasswordRequest();
|
||||
request.setSetupToken("expired-setup-token");
|
||||
request.setPassword("new-password");
|
||||
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> authService.setPassword(request));
|
||||
assertEquals("Setup token has expired", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resendVerificationSendsNewToken() {
|
||||
User user = User.builder()
|
||||
.email("resend@example.com")
|
||||
.name("Resend")
|
||||
.emailVerified(false)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
ResendVerificationRequest request = new ResendVerificationRequest();
|
||||
request.setEmail("resend@example.com");
|
||||
|
||||
RegistrationResponse response = authService.resendVerification(request);
|
||||
|
||||
assertEquals("If this email is available, a verification link has been sent.", response.getMessage());
|
||||
assertEquals(1, emailService.getSendCount());
|
||||
assertEquals("resend@example.com", emailService.getLastTo());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resendVerificationReturnsGenericForNonExistentEmail() {
|
||||
ResendVerificationRequest request = new ResendVerificationRequest();
|
||||
request.setEmail("nonexistent@example.com");
|
||||
|
||||
RegistrationResponse response = authService.resendVerification(request);
|
||||
|
||||
assertEquals("If this email is available, a verification link has been sent.", response.getMessage());
|
||||
assertEquals(0, emailService.getSendCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resendVerificationReturnsGenericForAlreadyVerifiedEmail() {
|
||||
User user = User.builder()
|
||||
.email("alreadyverified@example.com")
|
||||
.name("AlreadyVerified")
|
||||
.emailVerified(true)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
ResendVerificationRequest request = new ResendVerificationRequest();
|
||||
request.setEmail("alreadyverified@example.com");
|
||||
|
||||
RegistrationResponse response = authService.resendVerification(request);
|
||||
|
||||
assertEquals("If this email is available, a verification link has been sent.", response.getMessage());
|
||||
assertEquals(0, emailService.getSendCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -144,10 +482,17 @@ class AuthServiceTest {
|
||||
reg.setEmail("refresh@example.com");
|
||||
reg.setPassword("password123");
|
||||
reg.setName("Refresh User");
|
||||
AuthResponse initial = authService.register(reg);
|
||||
authService.register(reg);
|
||||
|
||||
User user = userRepository.findByEmail("refresh@example.com").get();
|
||||
user.setEmailVerified(true);
|
||||
userRepository.save(user);
|
||||
|
||||
FakeJwtConfig jwtConfig = new FakeJwtConfig();
|
||||
String token = jwtConfig.generateRefreshToken(user);
|
||||
|
||||
RefreshTokenRequest refreshRequest = new RefreshTokenRequest();
|
||||
refreshRequest.setRefreshToken(initial.getRefreshToken());
|
||||
refreshRequest.setRefreshToken(token);
|
||||
|
||||
AuthResponse response = authService.refreshToken(refreshRequest);
|
||||
|
||||
@@ -171,8 +516,8 @@ class AuthServiceTest {
|
||||
User user = User.builder()
|
||||
.email("deleted@example.com")
|
||||
.name("Deleted")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.build();
|
||||
user.onCreate();
|
||||
userRepository.save(user);
|
||||
|
||||
FakeJwtConfig jwtConfig = new FakeJwtConfig();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.SelfHostedConfig;
|
||||
import com.krrishg.model.Site;
|
||||
import com.krrishg.model.SiteStatus;
|
||||
import com.krrishg.support.FakeSelfHostedConfigRepository;
|
||||
import com.krrishg.support.FakeSiteRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DomainServiceTest {
|
||||
|
||||
private FakeSiteRepository siteRepository;
|
||||
private FakeSelfHostedConfigRepository selfHostedConfigRepository;
|
||||
private DomainService domainService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
siteRepository = new FakeSiteRepository();
|
||||
selfHostedConfigRepository = new FakeSelfHostedConfigRepository();
|
||||
domainService = new DomainService(siteRepository, selfHostedConfigRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveSiteReturnsSiteForSelfHostedDomain() {
|
||||
Site site = Site.builder()
|
||||
.userId(UUID.randomUUID())
|
||||
.name("My Site")
|
||||
.status(SiteStatus.DRAFT)
|
||||
.build();
|
||||
siteRepository.save(site);
|
||||
|
||||
SelfHostedConfig config = SelfHostedConfig.builder()
|
||||
.siteId(site.getId())
|
||||
.domain("www.example.com")
|
||||
.build();
|
||||
selfHostedConfigRepository.save(config);
|
||||
|
||||
Optional<Site> result = domainService.resolveSite("www.example.com");
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals(site.getId(), result.get().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveSiteReturnsEmptyForUnknownHostname() {
|
||||
Optional<Site> result = domainService.resolveSite("unknown.example.com");
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveSiteReturnsEmptyWhenHostnameIsNull() {
|
||||
Optional<Site> result = domainService.resolveSite(null);
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveSiteReturnsEmptyWhenHostnameIsBlank() {
|
||||
Optional<Site> result = domainService.resolveSite(" ");
|
||||
assertTrue(result.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ExportServiceTest {
|
||||
|
||||
private final RenderService renderService = new RenderService(
|
||||
new com.krrishg.engine.StyleCompiler(),
|
||||
new com.krrishg.engine.JsBundle());
|
||||
private final ExportService exportService = new ExportService(renderService);
|
||||
|
||||
@Test
|
||||
void exportSiteReturnsNonEmptyZip() throws Exception {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder()
|
||||
.colors(Map.of("primary", "#4f46e5"))
|
||||
.build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<h1>Hello</h1>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
byte[] result = exportService.exportSite(structure);
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.length > 0);
|
||||
assertTrue(isValidZip(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportSiteZipContainsExpectedFiles() throws Exception {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<h1>Hello</h1>")
|
||||
.build(),
|
||||
SiteStructure.PageStructure.builder()
|
||||
.id("p2")
|
||||
.title("About")
|
||||
.slug("about")
|
||||
.rawHtml("<p>About</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
byte[] result = exportService.exportSite(structure);
|
||||
|
||||
var entries = getZipEntries(result);
|
||||
assertTrue(entries.contains("style.css"));
|
||||
assertTrue(entries.contains("script.js"));
|
||||
assertTrue(entries.contains("index.html"));
|
||||
assertTrue(entries.contains("about/index.html"));
|
||||
assertTrue(entries.contains("404.html"));
|
||||
}
|
||||
|
||||
private boolean isValidZip(byte[] data) {
|
||||
return data.length > 22
|
||||
&& data[0] == 'P' && data[1] == 'K';
|
||||
}
|
||||
|
||||
private java.util.Set<String> getZipEntries(byte[] data) throws Exception {
|
||||
java.util.Set<String> names = new java.util.LinkedHashSet<>();
|
||||
try (java.util.zip.ZipInputStream zis =
|
||||
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(data))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zis.getNextEntry()) != null) {
|
||||
names.add(entry.getName());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package com.krrishg.service;
|
||||
import com.krrishg.dto.Reference;
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.model.GenerationVersion;
|
||||
import com.krrishg.service.llm.LLMFactory;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.support.FakeEncryptionService;
|
||||
import com.krrishg.support.FakeLLMProvider;
|
||||
import com.krrishg.support.FakeProviderFactory;
|
||||
import com.krrishg.support.FakeUserRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -19,9 +22,9 @@ import org.springframework.data.mongodb.core.query.Query;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -37,11 +40,12 @@ class LLMServiceTest {
|
||||
@Mock
|
||||
private ResourceLoader resourceLoader;
|
||||
|
||||
private FakeLLMProvider activeProvider;
|
||||
private FakeLLMProvider fallbackProvider;
|
||||
private LLMFactory llmFactory;
|
||||
private FakeLLMProvider geminiProvider;
|
||||
private FakeProviderFactory providerFactory;
|
||||
private LLMResponseParser responseParser;
|
||||
private FakeReferenceService referenceService;
|
||||
private FakeUserRepository userRepository;
|
||||
private FakeEncryptionService encryptionService;
|
||||
private LLMService llmService;
|
||||
|
||||
private UUID userId;
|
||||
@@ -52,15 +56,22 @@ class LLMServiceTest {
|
||||
userId = UUID.randomUUID();
|
||||
siteId = UUID.randomUUID();
|
||||
|
||||
activeProvider = new FakeLLMProvider("gemini");
|
||||
fallbackProvider = new FakeLLMProvider("openai");
|
||||
llmFactory = new LLMFactory(List.of(activeProvider, fallbackProvider), "gemini", "openai");
|
||||
Method init = LLMFactory.class.getDeclaredMethod("init");
|
||||
init.setAccessible(true);
|
||||
init.invoke(llmFactory);
|
||||
geminiProvider = new FakeLLMProvider("gemini");
|
||||
providerFactory = new FakeProviderFactory()
|
||||
.withProvider("gemini", geminiProvider);
|
||||
|
||||
responseParser = new LLMResponseParser(new ObjectMapper());
|
||||
referenceService = new FakeReferenceService();
|
||||
userRepository = new FakeUserRepository();
|
||||
encryptionService = new FakeEncryptionService();
|
||||
|
||||
User user = User.builder()
|
||||
.id(userId)
|
||||
.email("user@test.com")
|
||||
.name("Test User")
|
||||
.llmApiKeys(Map.of("gemini", encryptionService.encrypt("test-key")))
|
||||
.build();
|
||||
userRepository.save(user);
|
||||
|
||||
Resource mockResource = mock(Resource.class);
|
||||
when(mockResource.exists()).thenReturn(true);
|
||||
@@ -68,14 +79,15 @@ class LLMServiceTest {
|
||||
new ByteArrayInputStream("You are a site generator.".getBytes(StandardCharsets.UTF_8)));
|
||||
when(resourceLoader.getResource(anyString())).thenReturn(mockResource);
|
||||
|
||||
llmService = new LLMService(llmFactory, responseParser, referenceService,
|
||||
mongoTemplate, resourceLoader, "classpath:prompts/site-generator.txt",
|
||||
llmService = new LLMService(providerFactory, responseParser, referenceService,
|
||||
mongoTemplate, userRepository, encryptionService,
|
||||
resourceLoader, "classpath:prompts/site-generator.txt",
|
||||
"classpath:prompts/site-refiner.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSiteReturnsParsedStructure() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<h1>Welcome</h1>"}
|
||||
@@ -85,7 +97,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null);
|
||||
SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null, "gemini", null);
|
||||
|
||||
assertEquals(1, result.getPages().size());
|
||||
assertEquals("Home", result.getPages().get(0).getTitle());
|
||||
@@ -94,7 +106,7 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void generateSiteSavesVersionWithCorrectData() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "About", "slug": "about", "rawHtml": "<p>About</p>"}
|
||||
@@ -104,7 +116,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
llmService.generateSite(userId, siteId, "Build about page", null);
|
||||
llmService.generateSite(userId, siteId, "Build about page", null, "gemini", null);
|
||||
|
||||
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
|
||||
verify(mongoTemplate).save(captor.capture());
|
||||
@@ -119,7 +131,7 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void generateSiteIncrementsVersionNumber() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||
@@ -134,7 +146,7 @@ class LLMServiceTest {
|
||||
.build();
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(existing);
|
||||
|
||||
llmService.generateSite(userId, siteId, "Build a site", null);
|
||||
llmService.generateSite(userId, siteId, "Build a site", null, "gemini", null);
|
||||
|
||||
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
|
||||
verify(mongoTemplate).save(captor.capture());
|
||||
@@ -144,7 +156,7 @@ class LLMServiceTest {
|
||||
|
||||
@Test
|
||||
void generateSiteWithReferences() {
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||
@@ -158,7 +170,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
llmService.generateSite(userId, siteId, "Build a site", references);
|
||||
llmService.generateSite(userId, siteId, "Build a site", references, "gemini", null);
|
||||
|
||||
ArgumentCaptor<GenerationVersion> captor = ArgumentCaptor.forClass(GenerationVersion.class);
|
||||
verify(mongoTemplate).save(captor.capture());
|
||||
@@ -169,31 +181,30 @@ class LLMServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSiteFallsBackOnProviderFailure() {
|
||||
activeProvider.setFailure(new RuntimeException("API error"));
|
||||
fallbackProvider.withContent("""
|
||||
void generateSiteUsesProvidedApiKeyOverStored() {
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Fallback", "slug": "fallback", "rawHtml": "<p>Hero</p>"}
|
||||
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.generateSite(userId, siteId, "Build", null);
|
||||
llmService.generateSite(userId, siteId, "Build", null, "gemini", "override-key");
|
||||
|
||||
assertEquals("Fallback", result.getPages().get(0).getTitle());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSiteThrowsWhenAllProvidersFail() {
|
||||
activeProvider.setFailure(new RuntimeException("API error"));
|
||||
fallbackProvider.setFailure(new RuntimeException("Fallback also failed"));
|
||||
void generateSiteThrowsWhenNoKeyConfigured() {
|
||||
User user = userRepository.findById(userId).orElseThrow();
|
||||
user.setLlmApiKeys(null);
|
||||
userRepository.save(user);
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> llmService.generateSite(userId, siteId, "Build", null));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> llmService.generateSite(userId, siteId, "Build", null, "gemini", null));
|
||||
verify(mongoTemplate, never()).save(any());
|
||||
}
|
||||
|
||||
@@ -209,7 +220,7 @@ class LLMServiceTest {
|
||||
))
|
||||
.build();
|
||||
|
||||
activeProvider.withContent("""
|
||||
geminiProvider.withContent("""
|
||||
{
|
||||
"pages": [
|
||||
{"title": "Refined", "slug": "refined", "rawHtml": "<p>Hero</p>"}
|
||||
@@ -219,7 +230,7 @@ class LLMServiceTest {
|
||||
|
||||
when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null);
|
||||
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null);
|
||||
SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null);
|
||||
|
||||
assertEquals("Refined", result.getPages().get(0).getTitle());
|
||||
verify(mongoTemplate).save(any(GenerationVersion.class));
|
||||
|
||||
149
backend/src/test/java/com/krrishg/service/MediaServiceTest.java
Normal file
149
backend/src/test/java/com/krrishg/service/MediaServiceTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.model.AuthProvider;
|
||||
import com.krrishg.model.User;
|
||||
import com.krrishg.support.FakeUserRepository;
|
||||
import com.krrishg.support.TestOAuth2User;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OAuth2UserServiceTest {
|
||||
|
||||
private FakeUserRepository userRepository;
|
||||
private OAuth2UserService oAuth2UserService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userRepository = new FakeUserRepository();
|
||||
oAuth2UserService = new OAuth2UserService(userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProviderIdForGoogleReturnsSub() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("sub", "google-123"));
|
||||
assertEquals("google-123", oAuth2UserService.getProviderId(oAuth2User, AuthProvider.GOOGLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProviderIdForGitHubReturnsId() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("id", 456));
|
||||
assertEquals("456", oAuth2UserService.getProviderId(oAuth2User, AuthProvider.GITHUB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvatarUrlForGoogleReturnsPicture() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("picture", "https://example.com/avatar.jpg"));
|
||||
assertEquals("https://example.com/avatar.jpg",
|
||||
oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.GOOGLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvatarUrlForGitHubReturnsAvatarUrl() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("avatar_url", "https://github.com/avatar.png"));
|
||||
assertEquals("https://github.com/avatar.png",
|
||||
oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.GITHUB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAvatarUrlForOpenIdReturnsNull() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of());
|
||||
assertNull(oAuth2UserService.getAvatarUrl(oAuth2User, AuthProvider.OPENID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttributeReturnsStringValue() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of("email", "test@example.com"));
|
||||
assertEquals("test@example.com", oAuth2UserService.getAttribute(oAuth2User, "email"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAttributeReturnsNullWhenMissing() {
|
||||
TestOAuth2User oAuth2User = new TestOAuth2User(Map.of());
|
||||
assertNull(oAuth2UserService.getAttribute(oAuth2User, "missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserCreatesNewUser() {
|
||||
User user = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GOOGLE, "google-789",
|
||||
"new@example.com", "New User", "https://example.com/avatar.jpg");
|
||||
|
||||
assertNotNull(user.getId());
|
||||
assertEquals("new@example.com", user.getEmail());
|
||||
assertEquals("New User", user.getName());
|
||||
assertEquals(AuthProvider.GOOGLE, user.getProvider());
|
||||
assertEquals("google-789", user.getProviderId());
|
||||
assertTrue(user.isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserUpdatesExistingOAuthUser() {
|
||||
User existing = User.builder()
|
||||
.email("existing@example.com")
|
||||
.name("Old Name")
|
||||
.provider(AuthProvider.GOOGLE)
|
||||
.providerId("google-456")
|
||||
.build();
|
||||
userRepository.save(existing);
|
||||
|
||||
User updated = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GOOGLE, "google-456",
|
||||
"existing@example.com", "Updated Name", "https://example.com/new-avatar.jpg");
|
||||
|
||||
assertEquals("Updated Name", updated.getName());
|
||||
assertEquals("https://example.com/new-avatar.jpg", updated.getAvatarUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserLinksLocalUserToOAuth() {
|
||||
User localUser = User.builder()
|
||||
.email("link@example.com")
|
||||
.name("Link User")
|
||||
.provider(AuthProvider.LOCAL)
|
||||
.password("hashedpw")
|
||||
.build();
|
||||
userRepository.save(localUser);
|
||||
|
||||
User linked = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GITHUB, "github-789",
|
||||
"link@example.com", "Link User", "https://github.com/avatar.png");
|
||||
|
||||
assertEquals(AuthProvider.GITHUB, linked.getProvider());
|
||||
assertEquals("github-789", linked.getProviderId());
|
||||
assertTrue(linked.isEmailVerified());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateUserGeneratesEmailWhenMissing() {
|
||||
User user = oAuth2UserService.findOrCreateUser(
|
||||
AuthProvider.GITHUB, "github-nomail",
|
||||
null, "No Email", null);
|
||||
|
||||
assertTrue(user.getEmail().contains("github-nomail"));
|
||||
assertTrue(user.getEmail().endsWith("@github.indie"));
|
||||
}
|
||||
}
|
||||
124
backend/src/test/java/com/krrishg/service/PagesProviderTest.java
Normal file
124
backend/src/test/java/com/krrishg/service/PagesProviderTest.java
Normal file
@@ -0,0 +1,124 @@
|
||||
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 PagesProviderTest {
|
||||
|
||||
private final GitRemote gitHubRemote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/username/my-site.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
|
||||
private final GitRemote gitLabRemote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("https://gitlab.com/username/my-site.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
|
||||
@Test
|
||||
void forRemoteReturnsGitHubPagesForGitHubUrl() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitHubRemote);
|
||||
assertInstanceOf(GitHubPagesProvider.class, provider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forRemoteReturnsGitLabPagesForGitLabUrl() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitLabRemote);
|
||||
assertInstanceOf(GitLabPagesProvider.class, provider);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitHubBranchNameIsGhPages() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitHubRemote);
|
||||
assertEquals("gh-pages", provider.branchName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitLabBranchNameIsGlPages() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitLabRemote);
|
||||
assertEquals("gl-pages", provider.branchName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitHubContentRootIsTempDir() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitHubRemote);
|
||||
Path tempDir = Path.of("/tmp/test");
|
||||
assertEquals(tempDir, provider.contentRoot(tempDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitLabContentRootIsPublicSubdir() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitLabRemote);
|
||||
Path tempDir = Path.of("/tmp/test");
|
||||
assertEquals(tempDir.resolve("public"), provider.contentRoot(tempDir));
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitHubWritesNoJekyllFile(@TempDir Path tempDir) throws Exception {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitHubRemote);
|
||||
provider.writeProviderFiles(tempDir);
|
||||
|
||||
assertTrue(Files.exists(tempDir.resolve(".nojekyll")));
|
||||
assertTrue(Files.readString(tempDir.resolve(".nojekyll")).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitLabWritesCiYmlFile(@TempDir Path tempDir) throws Exception {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitLabRemote);
|
||||
provider.writeProviderFiles(tempDir);
|
||||
|
||||
Path ciYml = tempDir.resolve(".gitlab-ci.yml");
|
||||
assertTrue(Files.exists(ciYml));
|
||||
String content = Files.readString(ciYml);
|
||||
assertTrue(content.contains("gl-pages"));
|
||||
assertTrue(content.contains("public"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitHubBuildPagesUrl() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitHubRemote);
|
||||
String url = provider.buildPagesUrl(gitHubRemote);
|
||||
assertEquals("https://username.github.io/my-site/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitLabBuildPagesUrl() {
|
||||
PagesProvider provider = PagesProvider.forRemote(gitLabRemote);
|
||||
String url = provider.buildPagesUrl(gitLabRemote);
|
||||
assertEquals("https://username.gitlab.io/my-site/", url);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitHubBuildPagesUrlHandlesTrailingSlash() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITHUB)
|
||||
.remoteUrl("https://github.com/username/my-site/")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
PagesProvider provider = PagesProvider.forRemote(remote);
|
||||
assertEquals("https://username.github.io/my-site/", provider.buildPagesUrl(remote));
|
||||
}
|
||||
|
||||
@Test
|
||||
void gitLabBuildPagesUrlHandlesSshUrl() {
|
||||
GitRemote remote = GitRemote.builder()
|
||||
.siteId(java.util.UUID.randomUUID())
|
||||
.provider(GitRemote.GitProvider.GITLAB)
|
||||
.remoteUrl("git@gitlab.com:username/my-site.git")
|
||||
.encryptedToken("token")
|
||||
.build();
|
||||
PagesProvider provider = PagesProvider.forRemote(remote);
|
||||
assertEquals("https://username.gitlab.io/my-site/", provider.buildPagesUrl(remote));
|
||||
}
|
||||
}
|
||||
203
backend/src/test/java/com/krrishg/service/RenderServiceTest.java
Normal file
203
backend/src/test/java/com/krrishg/service/RenderServiceTest.java
Normal file
@@ -0,0 +1,203 @@
|
||||
package com.krrishg.service;
|
||||
|
||||
import com.krrishg.dto.SiteStructure;
|
||||
import com.krrishg.engine.JsBundle;
|
||||
import com.krrishg.engine.StyleCompiler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RenderServiceTest {
|
||||
|
||||
private final StyleCompiler styleCompiler = new StyleCompiler();
|
||||
private final JsBundle jsBundle = new JsBundle();
|
||||
private final RenderService renderService = new RenderService(styleCompiler, jsBundle);
|
||||
|
||||
@Test
|
||||
void renderSiteReturnsOutputWithCssAndJs() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder()
|
||||
.colors(Map.of("primary", "#4f46e5", "background", "#ffffff"))
|
||||
.fonts(Map.of("body", "Inter"))
|
||||
.spacing(Map.of("containerWidth", "1100px"))
|
||||
.build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<h1>Hello</h1>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
assertNotNull(output.css());
|
||||
assertNotNull(output.js());
|
||||
assertEquals(1, output.pages().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSiteHandlesNullPages() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
assertNotNull(output.pages());
|
||||
assertTrue(output.pages().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderSiteHandlesNullTheme() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<h1>Hello</h1>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
assertNotNull(output.css());
|
||||
assertEquals(1, output.pages().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderPageUsesSeoTitleWhenPresent() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.seoTitle("Custom SEO Title")
|
||||
.rawHtml("<p>Content</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
String html = output.pages().get("home");
|
||||
assertTrue(html.contains("<title>Custom SEO Title</title>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderPageFallsBackToTitleWhenNoSeoTitle() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Fallback Title")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Content</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
String html = output.pages().get("home");
|
||||
assertTrue(html.contains("<title>Fallback Title</title>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderPageIncludesCssVars() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder()
|
||||
.colors(Map.of("primary", "#4f46e5"))
|
||||
.fonts(Map.of("body", "Inter"))
|
||||
.spacing(Map.of("containerWidth", "1100px"))
|
||||
.build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Content</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
|
||||
String html = output.pages().get("home");
|
||||
assertTrue(html.contains("--color-primary: #4f46e5"));
|
||||
assertTrue(html.contains("--font-body: Inter"));
|
||||
assertTrue(html.contains("--spacing-containerWidth: 1100px"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void siteOutputAsFileMapIncludesStyleAndScript() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Content</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
Map<String, byte[]> files = output.asFileMap();
|
||||
|
||||
assertTrue(files.containsKey("style.css"));
|
||||
assertTrue(files.containsKey("script.js"));
|
||||
assertTrue(files.containsKey("404.html"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void siteOutputAsFileMapPutsHomePageAsIndexHtml() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("Home")
|
||||
.slug("home")
|
||||
.rawHtml("<p>Content</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
Map<String, byte[]> files = output.asFileMap();
|
||||
|
||||
assertTrue(files.containsKey("index.html"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void siteOutputAsFileMapPutsNonHomePageInSubdir() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of(SiteStructure.PageStructure.builder()
|
||||
.id("p1")
|
||||
.title("About")
|
||||
.slug("about")
|
||||
.rawHtml("<p>About us</p>")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
Map<String, byte[]> files = output.asFileMap();
|
||||
|
||||
assertTrue(files.containsKey("about/index.html"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void siteOutputAsFileMapContains404Html() {
|
||||
SiteStructure structure = SiteStructure.builder()
|
||||
.theme(SiteStructure.ThemeConfig.builder().build())
|
||||
.pages(List.of())
|
||||
.build();
|
||||
|
||||
RenderService.SiteOutput output = renderService.renderSite(structure);
|
||||
Map<String, byte[]> files = output.asFileMap();
|
||||
|
||||
assertTrue(files.containsKey("404.html"));
|
||||
String content = new String(files.get("404.html"));
|
||||
assertTrue(content.contains("404"));
|
||||
assertTrue(content.contains("Go Home"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user