Compare commits

...

10 Commits

Author SHA1 Message Date
Krrish Ghimire
77c4cfc777 add email verification after account registration 2026-07-08 22:55:22 +05:45
Krrish Ghimire
212785725f remove google and oauth login 2026-07-06 23:13:47 +05:45
Krrish Ghimire
708cac6848 remove subdomain feature 2026-07-06 23:04:16 +05:45
Krrish Ghimire
ea69080710 remove subdomain feature 2026-07-06 23:03:42 +05:45
Krrish Ghimire
ad15ff8f2e add tests 2026-07-06 22:55:21 +05:45
Krrish Ghimire
25db5b2970 add bring your own key support 2026-07-06 22:45:24 +05:45
Krrish Ghimire
a6a9ac6e7e add ssl certificates and tests 2026-07-06 14:49:44 +05:45
Krrish Ghimire
a498d55e8c publish site to custom server 2026-07-06 11:15:03 +05:45
Krrish Ghimire
51d887a005 add tests 2026-07-06 01:39:18 +05:45
Krrish Ghimire
f70fe81432 fix token issues 2026-07-06 00:58:11 +05:45
134 changed files with 8115 additions and 1142 deletions

284
PLAN-byok.md Normal file
View File

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

425
PLAN-selfhosted.md Normal file
View 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
View File

@@ -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 |
---

View File

@@ -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`)

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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);
}
}
}

View File

@@ -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());

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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\"")

View File

@@ -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}")

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -11,5 +11,5 @@ public class SiteRequest {
private String description;
private String subdomain;
private String deploymentTarget;
}

View File

@@ -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())

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,5 +1,5 @@
package com.krrishg.model;
public enum AuthProvider {
LOCAL, GOOGLE, GITHUB, OPENID
LOCAL
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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()

View File

@@ -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);
}
});
}
}
}

View File

@@ -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;
}
}

View 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);
}

View File

@@ -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);

View File

@@ -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();
}
}

View 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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View 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);
}
});
}
}
}
}

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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) {}
}

View File

@@ -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

View File

@@ -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);
}
}

View 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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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}"));
}
}

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}

View File

@@ -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\""));
}
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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"));
}
}

View File

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

View File

@@ -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"));
}
}

View File

@@ -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());
}
}

View 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());
}
}

View File

@@ -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());
}
}

View 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());
}
}

View 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()
);
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View 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());
}
}

View 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"));
}
}

View File

@@ -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; }"));
}
}

View File

@@ -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()
);
}
}

View File

@@ -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());
}
}

View 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()
);
}
}

View File

@@ -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());
}
}

View 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()
);
}
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

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

View File

@@ -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"));
}
}

View File

@@ -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);

View File

@@ -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();

View File

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

View File

@@ -0,0 +1,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());
}
}

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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));

View File

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

View File

@@ -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"));
}
}

View 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));
}
}

View 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