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:
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user