Add backend API for projects & snapshots

Introduce full backend API surface for projects, snapshots and user pubkeys: controllers, DTOs, JPA entities, repositories, services, and tests. Add SecurityConfig improvements (JWT decoder, audience/issuer/timestamp validation and CORS), a GlobalExceptionHandler for consistent error responses, and remove a stray test call from BackendApplication. Also update README (architecture, docs, CLI/Frontend/Backend notes), add CLAUDE.md, VSCode settings, and multiple frontend/CLI Rust changes. Small housekeeping: add .DS_Store and test adjustments.
This commit is contained in:
Aryan Singh committed 2026-03-14 18:30:10 -05:00
1 parent 3d72c53262
commit 473e8e01fa
57 files changed
+3888 -281

No files matched your search

@@ -8,10 +8,6 @@ public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
//call the test
test x = new test();
}
@@ -0,0 +1,75 @@
package com.envsync.backend;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value",
(a, b) -> a
));
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", HttpStatus.BAD_REQUEST.value());
body.put("error", "Validation Failed");
body.put("fields", fieldErrors);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", ex.getStatusCode().value());
body.put("error", ex.getReason() != null ? ex.getReason() : ex.getMessage());
return ResponseEntity.status(ex.getStatusCode()).body(body);
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", HttpStatus.FORBIDDEN.value());
body.put("error", "Access denied");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
logger.error("Unhandled exception", ex);
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", Instant.now());
body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
body.put("error", "Internal server error");
return ResponseEntity.internalServerError().body(body);
}
}
@@ -1,25 +1,98 @@
package com.envsync.backend;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Value("${app.security.jwt.audience}")
private String audience;
@Value("${app.security.jwt.clock-skew-seconds:60}")
private long clockSkewSeconds;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Stateless REST API — no CSRF needed (bearer-token auth, no cookies)
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
// Timestamp validator with configurable clock skew
JwtTimestampValidator timestampValidator =
new JwtTimestampValidator(java.time.Duration.ofSeconds(clockSkewSeconds));
// Issuer validator
OAuth2TokenValidator<Jwt> issuerValidator =
new JwtIssuerValidator(issuerUri);
// Permissive audience validator — Keycloak may omit aud for some grant types
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
List<String> audiences = token.getAudience();
if (audiences == null || audiences.isEmpty() || audiences.contains(audience)) {
return org.springframework.security.oauth2.core.OAuth2TokenValidatorResult.success();
}
return org.springframework.security.oauth2.core.OAuth2TokenValidatorResult.failure(
new org.springframework.security.oauth2.core.OAuth2Error("invalid_token",
"Token audience does not include: " + audience, null));
};
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
timestampValidator, issuerValidator, audienceValidator));
return decoder;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of(
"http://localhost:3000",
"http://localhost:3001",
"https://*"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
@@ -0,0 +1,57 @@
package com.envsync.backend.controller;
import com.envsync.backend.dto.CreateProjectRequest;
import com.envsync.backend.dto.ProjectResponse;
import com.envsync.backend.model.Project;
import com.envsync.backend.service.ProjectService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
private final ProjectService projectService;
public ProjectController(ProjectService projectService) {
this.projectService = projectService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProjectResponse create(
@Valid @RequestBody CreateProjectRequest req,
@AuthenticationPrincipal Jwt jwt) {
Project project = projectService.create(req.name(), req.environment(), jwt.getSubject());
return ProjectResponse.from(project);
}
@GetMapping
public List<ProjectResponse> list(@AuthenticationPrincipal Jwt jwt) {
return projectService.listForUser(jwt.getSubject())
.stream()
.map(ProjectResponse::from)
.toList();
}
@GetMapping("/{id}")
public ProjectResponse get(
@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) {
return ProjectResponse.from(projectService.getForUser(id, jwt.getSubject()));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(
@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) {
projectService.delete(id, jwt.getSubject());
}
}
@@ -0,0 +1,72 @@
package com.envsync.backend.controller;
import com.envsync.backend.dto.PushSnapshotRequest;
import com.envsync.backend.dto.SnapshotFullResponse;
import com.envsync.backend.dto.SnapshotMetaResponse;
import com.envsync.backend.model.Project;
import com.envsync.backend.model.Snapshot;
import com.envsync.backend.service.ProjectService;
import com.envsync.backend.service.SnapshotService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/projects/{projectId}/snapshots")
public class SnapshotController {
private final ProjectService projectService;
private final SnapshotService snapshotService;
public SnapshotController(ProjectService projectService, SnapshotService snapshotService) {
this.projectService = projectService;
this.snapshotService = snapshotService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public SnapshotMetaResponse push(
@PathVariable UUID projectId,
@Valid @RequestBody PushSnapshotRequest req,
@AuthenticationPrincipal Jwt jwt) {
Project project = projectService.getForUser(projectId, jwt.getSubject());
Snapshot snap = snapshotService.push(
project, req.ciphertext(), req.nonce(), req.message(), jwt.getSubject());
return SnapshotMetaResponse.from(snap);
}
@GetMapping
public List<SnapshotMetaResponse> list(
@PathVariable UUID projectId,
@RequestParam(defaultValue = "20") int limit,
@AuthenticationPrincipal Jwt jwt) {
Project project = projectService.getForUser(projectId, jwt.getSubject());
return snapshotService.list(project, limit)
.stream()
.map(SnapshotMetaResponse::from)
.toList();
}
// Note: this must be declared before /{snapId} so Spring MVC matches "latest" as literal
@GetMapping("/latest")
public SnapshotFullResponse getLatest(
@PathVariable UUID projectId,
@AuthenticationPrincipal Jwt jwt) {
Project project = projectService.getForUser(projectId, jwt.getSubject());
return SnapshotFullResponse.from(snapshotService.getLatest(project));
}
@GetMapping("/{snapId}")
public SnapshotFullResponse getById(
@PathVariable UUID projectId,
@PathVariable UUID snapId,
@AuthenticationPrincipal Jwt jwt) {
Project project = projectService.getForUser(projectId, jwt.getSubject());
return SnapshotFullResponse.from(snapshotService.getById(project, snapId));
}
}
@@ -0,0 +1,28 @@
package com.envsync.backend.controller;
import com.envsync.backend.dto.RegisterPubkeyRequest;
import com.envsync.backend.service.UserPubkeyService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users/me")
public class UserController {
private final UserPubkeyService userPubkeyService;
public UserController(UserPubkeyService userPubkeyService) {
this.userPubkeyService = userPubkeyService;
}
@PutMapping("/pubkey")
public ResponseEntity<Void> registerPubkey(
@Valid @RequestBody RegisterPubkeyRequest req,
@AuthenticationPrincipal Jwt jwt) {
userPubkeyService.upsert(jwt.getSubject(), req.public_key());
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,8 @@
package com.envsync.backend.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateProjectRequest(
@NotBlank String name,
@NotBlank String environment
) {}
@@ -0,0 +1,17 @@
package com.envsync.backend.dto;
import com.envsync.backend.model.Project;
import java.time.Instant;
import java.util.UUID;
public record ProjectResponse(
UUID id,
String name,
String environment,
Instant created_at
) {
public static ProjectResponse from(Project p) {
return new ProjectResponse(p.getId(), p.getName(), p.getEnvironment(), p.getCreatedAt());
}
}
@@ -0,0 +1,9 @@
package com.envsync.backend.dto;
import jakarta.validation.constraints.NotBlank;
public record PushSnapshotRequest(
@NotBlank String ciphertext,
@NotBlank String nonce,
String message
) {}
@@ -0,0 +1,5 @@
package com.envsync.backend.dto;
import jakarta.validation.constraints.NotBlank;
public record RegisterPubkeyRequest(@NotBlank String public_key) {}
@@ -0,0 +1,25 @@
package com.envsync.backend.dto;
import com.envsync.backend.model.Snapshot;
import java.time.Instant;
import java.util.UUID;
public record SnapshotFullResponse(
UUID id,
int version,
String ciphertext,
String nonce,
String message,
String pushed_by,
Instant created_at
) {
public static SnapshotFullResponse from(Snapshot s) {
return new SnapshotFullResponse(
s.getId(), s.getVersion(),
s.getCiphertext(), s.getNonce(),
s.getMessage(), s.getPushedBy(),
s.getCreatedAt()
);
}
}
@@ -0,0 +1,18 @@
package com.envsync.backend.dto;
import com.envsync.backend.model.Snapshot;
import java.time.Instant;
import java.util.UUID;
public record SnapshotMetaResponse(
UUID id,
int version,
String message,
String pushed_by,
Instant created_at
) {
public static SnapshotMetaResponse from(Snapshot s) {
return new SnapshotMetaResponse(s.getId(), s.getVersion(), s.getMessage(), s.getPushedBy(), s.getCreatedAt());
}
}
@@ -0,0 +1,47 @@
package com.envsync.backend.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "projects")
public class Project {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String environment;
/** Keycloak `sub` claim of the project owner. */
@Column(name = "owner_id", nullable = false)
private String ownerId;
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
// ── Getters / Setters ──────────────────────────────────────────────────
public UUID getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }
public String getOwnerId() { return ownerId; }
public void setOwnerId(String ownerId) { this.ownerId = ownerId; }
public Instant getCreatedAt() { return createdAt; }
}
@@ -0,0 +1,69 @@
package com.envsync.backend.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "snapshots")
public class Snapshot {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_id", nullable = false)
private Project project;
/** Monotonically increasing per-project version number (1, 2, 3, …). */
@Column(nullable = false)
private int version;
/** Base64-encoded XSalsa20-Poly1305 ciphertext of the vault. */
@Column(columnDefinition = "TEXT", nullable = false)
private String ciphertext;
/** Base64-encoded 24-byte nonce. */
@Column(nullable = false, length = 64)
private String nonce;
@Column
private String message;
/** Keycloak `sub` of the user who pushed this snapshot. */
@Column(name = "pushed_by")
private String pushedBy;
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
// ── Getters / Setters ──────────────────────────────────────────────────
public UUID getId() { return id; }
public Project getProject() { return project; }
public void setProject(Project project) { this.project = project; }
public int getVersion() { return version; }
public void setVersion(int version) { this.version = version; }
public String getCiphertext() { return ciphertext; }
public void setCiphertext(String ciphertext) { this.ciphertext = ciphertext; }
public String getNonce() { return nonce; }
public void setNonce(String nonce) { this.nonce = nonce; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getPushedBy() { return pushedBy; }
public void setPushedBy(String pushedBy) { this.pushedBy = pushedBy; }
public Instant getCreatedAt() { return createdAt; }
}
@@ -0,0 +1,37 @@
package com.envsync.backend.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "user_pubkeys")
public class UserPubkey {
/** Keycloak `sub` — primary key (one keypair per user). */
@Id
@Column(name = "user_id")
private String userId;
/** Base64-encoded X25519 public key. */
@Column(name = "public_key", nullable = false)
private String publicKey;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
// ── Getters / Setters ──────────────────────────────────────────────────
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getPublicKey() { return publicKey; }
public void setPublicKey(String publicKey) { this.publicKey = publicKey; }
public Instant getUpdatedAt() { return updatedAt; }
}
@@ -0,0 +1,15 @@
package com.envsync.backend.repository;
import com.envsync.backend.model.Project;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ProjectRepository extends JpaRepository<Project, UUID> {
List<Project> findByOwnerId(String ownerId);
Optional<Project> findByIdAndOwnerId(UUID id, String ownerId);
}
@@ -0,0 +1,22 @@
package com.envsync.backend.repository;
import com.envsync.backend.model.Project;
import com.envsync.backend.model.Snapshot;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface SnapshotRepository extends JpaRepository<Snapshot, UUID> {
Optional<Snapshot> findTopByProjectOrderByVersionDesc(Project project);
List<Snapshot> findByProjectOrderByVersionDesc(Project project, Pageable pageable);
@Query("SELECT COALESCE(MAX(s.version), 0) FROM Snapshot s WHERE s.project = :project")
int findMaxVersionByProject(@Param("project") Project project);
}
@@ -0,0 +1,7 @@
package com.envsync.backend.repository;
import com.envsync.backend.model.UserPubkey;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserPubkeyRepository extends JpaRepository<UserPubkey, String> {
}
@@ -0,0 +1,45 @@
package com.envsync.backend.service;
import com.envsync.backend.model.Project;
import com.envsync.backend.repository.ProjectRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.UUID;
@Service
public class ProjectService {
private final ProjectRepository projectRepository;
public ProjectService(ProjectRepository projectRepository) {
this.projectRepository = projectRepository;
}
@Transactional
public Project create(String name, String environment, String ownerId) {
Project project = new Project();
project.setName(name);
project.setEnvironment(environment);
project.setOwnerId(ownerId);
return projectRepository.save(project);
}
public List<Project> listForUser(String ownerId) {
return projectRepository.findByOwnerId(ownerId);
}
public Project getForUser(UUID id, String ownerId) {
return projectRepository.findByIdAndOwnerId(id, ownerId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Project not found"));
}
@Transactional
public void delete(UUID id, String ownerId) {
Project project = getForUser(id, ownerId);
projectRepository.delete(project);
}
}
@@ -0,0 +1,57 @@
package com.envsync.backend.service;
import com.envsync.backend.model.Project;
import com.envsync.backend.model.Snapshot;
import com.envsync.backend.repository.SnapshotRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.UUID;
@Service
public class SnapshotService {
private final SnapshotRepository snapshotRepository;
public SnapshotService(SnapshotRepository snapshotRepository) {
this.snapshotRepository = snapshotRepository;
}
@Transactional
public Snapshot push(Project project, String ciphertext, String nonce,
String message, String pushedBy) {
int nextVersion = snapshotRepository.findMaxVersionByProject(project) + 1;
Snapshot snap = new Snapshot();
snap.setProject(project);
snap.setVersion(nextVersion);
snap.setCiphertext(ciphertext);
snap.setNonce(nonce);
snap.setMessage(message);
snap.setPushedBy(pushedBy);
return snapshotRepository.save(snap);
}
public Snapshot getLatest(Project project) {
return snapshotRepository.findTopByProjectOrderByVersionDesc(project)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No snapshots found"));
}
public List<Snapshot> list(Project project, int limit) {
return snapshotRepository.findByProjectOrderByVersionDesc(
project, PageRequest.of(0, limit));
}
public Snapshot getById(Project project, UUID snapId) {
Snapshot snap = snapshotRepository.findById(snapId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Snapshot not found"));
if (!snap.getProject().getId().equals(project.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Snapshot not found");
}
return snap;
}
}
@@ -0,0 +1,24 @@
package com.envsync.backend.service;
import com.envsync.backend.model.UserPubkey;
import com.envsync.backend.repository.UserPubkeyRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class UserPubkeyService {
private final UserPubkeyRepository repo;
public UserPubkeyService(UserPubkeyRepository repo) {
this.repo = repo;
}
@Transactional
public void upsert(String userId, String publicKey) {
UserPubkey entry = repo.findById(userId).orElseGet(UserPubkey::new);
entry.setUserId(userId);
entry.setPublicKey(publicKey);
repo.save(entry);
}
}
@@ -1,13 +1,18 @@
package com.envsync.backend;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* Integration test — requires the full Docker Compose stack (PostgreSQL, Keycloak).
* Run with: docker compose up -d && ./mvnw test
*/
@SpringBootTest
@Disabled("Requires running Docker Compose stack (PostgreSQL + Keycloak). Run with integration profile.")
class BackendApplicationTests {
@Test
void contextLoads() {
}
@Test
void contextLoads() {
}
}
@@ -0,0 +1,104 @@
package com.envsync.backend.service;
import com.envsync.backend.model.Project;
import com.envsync.backend.repository.ProjectRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ProjectServiceTest {
@Mock
private ProjectRepository projectRepository;
@InjectMocks
private ProjectService projectService;
private Project sampleProject;
private final String ownerId = "user-123";
@BeforeEach
void setUp() {
sampleProject = new Project();
sampleProject.setName("test-app");
sampleProject.setEnvironment("development");
sampleProject.setOwnerId(ownerId);
}
@Test
void create_savesAndReturnsProject() {
when(projectRepository.save(any(Project.class))).thenReturn(sampleProject);
Project result = projectService.create("test-app", "development", ownerId);
assertThat(result.getName()).isEqualTo("test-app");
assertThat(result.getEnvironment()).isEqualTo("development");
assertThat(result.getOwnerId()).isEqualTo(ownerId);
verify(projectRepository, times(1)).save(any(Project.class));
}
@Test
void listForUser_returnsUserProjects() {
when(projectRepository.findByOwnerId(ownerId)).thenReturn(List.of(sampleProject));
List<Project> result = projectService.listForUser(ownerId);
assertThat(result).hasSize(1);
assertThat(result.get(0).getName()).isEqualTo("test-app");
}
@Test
void getForUser_whenFound_returnsProject() {
UUID id = UUID.randomUUID();
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.of(sampleProject));
Project result = projectService.getForUser(id, ownerId);
assertThat(result).isEqualTo(sampleProject);
}
@Test
void getForUser_whenNotFound_throws404() {
UUID id = UUID.randomUUID();
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> projectService.getForUser(id, ownerId))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Project not found");
}
@Test
void delete_whenFound_deletesProject() {
UUID id = UUID.randomUUID();
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.of(sampleProject));
projectService.delete(id, ownerId);
verify(projectRepository, times(1)).delete(sampleProject);
}
@Test
void delete_whenNotFound_throws404() {
UUID id = UUID.randomUUID();
when(projectRepository.findByIdAndOwnerId(id, ownerId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> projectService.delete(id, ownerId))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Project not found");
verify(projectRepository, never()).delete(any());
}
}
@@ -0,0 +1,150 @@
package com.envsync.backend.service;
import com.envsync.backend.model.Project;
import com.envsync.backend.model.Snapshot;
import com.envsync.backend.repository.SnapshotRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Pageable;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SnapshotServiceTest {
@Mock
private SnapshotRepository snapshotRepository;
@InjectMocks
private SnapshotService snapshotService;
private Project project;
@BeforeEach
void setUp() {
project = new Project();
project.setName("test-app");
project.setEnvironment("development");
project.setOwnerId("user-123");
}
@Test
void push_createsSnapshotWithNextVersion() {
when(snapshotRepository.findMaxVersionByProject(project)).thenReturn(5);
Snapshot saved = new Snapshot();
saved.setVersion(6);
when(snapshotRepository.save(any(Snapshot.class))).thenReturn(saved);
Snapshot result = snapshotService.push(project, "ct", "nonce", "init", "user-123");
assertThat(result.getVersion()).isEqualTo(6);
verify(snapshotRepository).save(argThat(s ->
s.getVersion() == 6 &&
s.getCiphertext().equals("ct") &&
s.getNonce().equals("nonce") &&
s.getMessage().equals("init") &&
s.getPushedBy().equals("user-123")
));
}
@Test
void push_firstSnapshot_usesVersion1() {
when(snapshotRepository.findMaxVersionByProject(project)).thenReturn(0);
Snapshot saved = new Snapshot();
saved.setVersion(1);
when(snapshotRepository.save(any(Snapshot.class))).thenReturn(saved);
Snapshot result = snapshotService.push(project, "ct", "nonce", null, "user-123");
assertThat(result.getVersion()).isEqualTo(1);
}
@Test
void getLatest_whenExists_returnsSnapshot() {
Snapshot snap = new Snapshot();
snap.setVersion(3);
when(snapshotRepository.findTopByProjectOrderByVersionDesc(project))
.thenReturn(Optional.of(snap));
Snapshot result = snapshotService.getLatest(project);
assertThat(result.getVersion()).isEqualTo(3);
}
@Test
void getLatest_whenNone_throws404() {
when(snapshotRepository.findTopByProjectOrderByVersionDesc(project))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> snapshotService.getLatest(project))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("No snapshots found");
}
@Test
void getById_whenBelongsToProject_returnsSnapshot() {
UUID snapId = UUID.randomUUID();
UUID projectId = UUID.randomUUID();
// Need to give project an ID for comparison
Project proj = mock(Project.class);
when(proj.getId()).thenReturn(projectId);
Snapshot snap = new Snapshot();
snap.setProject(proj);
when(snapshotRepository.findById(snapId)).thenReturn(Optional.of(snap));
Project requestedProject = mock(Project.class);
when(requestedProject.getId()).thenReturn(projectId);
Snapshot result = snapshotService.getById(requestedProject, snapId);
assertThat(result).isEqualTo(snap);
}
@Test
void getById_whenBelongsToDifferentProject_throws404() {
UUID snapId = UUID.randomUUID();
Project proj = mock(Project.class);
when(proj.getId()).thenReturn(UUID.randomUUID());
Snapshot snap = new Snapshot();
snap.setProject(proj);
when(snapshotRepository.findById(snapId)).thenReturn(Optional.of(snap));
Project otherProject = mock(Project.class);
when(otherProject.getId()).thenReturn(UUID.randomUUID());
assertThatThrownBy(() -> snapshotService.getById(otherProject, snapId))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Snapshot not found");
}
@Test
void list_returnsPagedResults() {
Snapshot s1 = new Snapshot();
s1.setVersion(2);
Snapshot s2 = new Snapshot();
s2.setVersion(1);
when(snapshotRepository.findByProjectOrderByVersionDesc(eq(project), any(Pageable.class)))
.thenReturn(List.of(s1, s2));
List<Snapshot> result = snapshotService.list(project, 10);
assertThat(result).hasSize(2);
assertThat(result.get(0).getVersion()).isEqualTo(2);
}
}