feat: add internal cluster admin endpoints
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package clusteradmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"proxui/backend/internal/cluster"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
UpsertCluster(ctx context.Context, cluster cluster.Cluster) (cluster.Cluster, error)
|
||||
SetClusterStatus(ctx context.Context, id string, status string) (cluster.Cluster, bool, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func NewHandler(repository Repository) Handler {
|
||||
return Handler{repository: repository}
|
||||
}
|
||||
|
||||
func (h Handler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
var request upsertClusterRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
saved, err := h.repository.UpsertCluster(r.Context(), cluster.Cluster{
|
||||
Name: request.Name,
|
||||
APIEndpoint: request.APIEndpoint,
|
||||
TLSFingerprint: request.TLSFingerprint,
|
||||
TokenID: request.TokenID,
|
||||
TokenSecret: request.TokenSecret,
|
||||
Status: request.Status,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "cluster_upsert_failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, clusterResponseFromCluster(saved))
|
||||
}
|
||||
|
||||
func (h Handler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
clusterID := r.PathValue("clusterID")
|
||||
if clusterID == "" {
|
||||
writeError(w, http.StatusBadRequest, "cluster_id_required")
|
||||
return
|
||||
}
|
||||
|
||||
var request upsertClusterRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
saved, err := h.repository.UpsertCluster(r.Context(), cluster.Cluster{
|
||||
ID: clusterID,
|
||||
Name: request.Name,
|
||||
APIEndpoint: request.APIEndpoint,
|
||||
TLSFingerprint: request.TLSFingerprint,
|
||||
TokenID: request.TokenID,
|
||||
TokenSecret: request.TokenSecret,
|
||||
Status: request.Status,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "cluster_upsert_failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, clusterResponseFromCluster(saved))
|
||||
}
|
||||
|
||||
func (h Handler) SetClusterStatus(w http.ResponseWriter, r *http.Request) {
|
||||
clusterID := r.PathValue("clusterID")
|
||||
if clusterID == "" {
|
||||
writeError(w, http.StatusBadRequest, "cluster_id_required")
|
||||
return
|
||||
}
|
||||
|
||||
var request setStatusRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.Status) == "" {
|
||||
writeError(w, http.StatusBadRequest, "status_required")
|
||||
return
|
||||
}
|
||||
|
||||
updated, found, err := h.repository.SetClusterStatus(r.Context(), clusterID, request.Status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "cluster_status_update_failed")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "cluster_not_found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, clusterResponseFromCluster(updated))
|
||||
}
|
||||
|
||||
type upsertClusterRequest struct {
|
||||
Name string `json:"name"`
|
||||
APIEndpoint string `json:"api_endpoint"`
|
||||
TLSFingerprint string `json:"tls_fingerprint"`
|
||||
TokenID string `json:"token_id"`
|
||||
TokenSecret string `json:"token_secret"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type setStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type clusterResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
APIEndpoint string `json:"api_endpoint"`
|
||||
TLSFingerprint string `json:"tls_fingerprint"`
|
||||
TokenID string `json:"token_id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func clusterResponseFromCluster(cluster cluster.Cluster) clusterResponse {
|
||||
return clusterResponse{
|
||||
ID: cluster.ID,
|
||||
Name: cluster.Name,
|
||||
APIEndpoint: cluster.APIEndpoint,
|
||||
TLSFingerprint: cluster.TLSFingerprint,
|
||||
TokenID: cluster.TokenID,
|
||||
Status: cluster.Status,
|
||||
CreatedAt: cluster.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_json")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package clusteradmin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"proxui/backend/internal/cluster"
|
||||
)
|
||||
|
||||
func TestCreateClusterDoesNotReturnTokenSecret(t *testing.T) {
|
||||
repository := newStubRepository()
|
||||
handler := NewHandler(repository)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/internal/clusters", strings.NewReader(`{
|
||||
"name": "Lab",
|
||||
"api_endpoint": "https://pve.example.test:8006/api2/json",
|
||||
"tls_fingerprint": "AA:BB",
|
||||
"token_id": "root@pam!proxui",
|
||||
"token_secret": "secret-token"
|
||||
}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.CreateCluster(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
|
||||
}
|
||||
if repository.saved.TokenSecret != "secret-token" {
|
||||
t.Fatalf("saved token secret = %q", repository.saved.TokenSecret)
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("secret-token")) {
|
||||
t.Fatal("response contains token secret")
|
||||
}
|
||||
|
||||
var response clusterResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if response.TokenID != "root@pam!proxui" {
|
||||
t.Fatalf("TokenID = %q, want root@pam!proxui", response.TokenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateClusterUsesPathIDAndRotatesToken(t *testing.T) {
|
||||
repository := newStubRepository()
|
||||
handler := NewHandler(repository)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/internal/clusters/cluster-1", strings.NewReader(`{
|
||||
"name": "Lab",
|
||||
"api_endpoint": "https://pve.example.test:8006/api2/json",
|
||||
"tls_fingerprint": "AA:BB",
|
||||
"token_id": "root@pam!proxui",
|
||||
"token_secret": "rotated-token",
|
||||
"status": "active"
|
||||
}`))
|
||||
req.SetPathValue("clusterID", "cluster-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.UpdateCluster(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if repository.saved.ID != "cluster-1" {
|
||||
t.Fatalf("saved ID = %q, want cluster-1", repository.saved.ID)
|
||||
}
|
||||
if repository.saved.TokenSecret != "rotated-token" {
|
||||
t.Fatalf("saved token secret = %q", repository.saved.TokenSecret)
|
||||
}
|
||||
if bytes.Contains(rec.Body.Bytes(), []byte("rotated-token")) {
|
||||
t.Fatal("response contains rotated token secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetClusterStatus(t *testing.T) {
|
||||
repository := newStubRepository()
|
||||
handler := NewHandler(repository)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/internal/clusters/cluster-1/status", strings.NewReader(`{"status":"disabled"}`))
|
||||
req.SetPathValue("clusterID", "cluster-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.SetClusterStatus(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if repository.statusID != "cluster-1" {
|
||||
t.Fatalf("status ID = %q, want cluster-1", repository.statusID)
|
||||
}
|
||||
if repository.status != "disabled" {
|
||||
t.Fatalf("status = %q, want disabled", repository.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetClusterStatusReturnsNotFound(t *testing.T) {
|
||||
repository := newStubRepository()
|
||||
repository.statusFound = false
|
||||
handler := NewHandler(repository)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/internal/clusters/missing/status", strings.NewReader(`{"status":"disabled"}`))
|
||||
req.SetPathValue("clusterID", "missing")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.SetClusterStatus(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
type stubRepository struct {
|
||||
saved cluster.Cluster
|
||||
statusID string
|
||||
status string
|
||||
statusFound bool
|
||||
}
|
||||
|
||||
func newStubRepository() *stubRepository {
|
||||
return &stubRepository{statusFound: true}
|
||||
}
|
||||
|
||||
func (s *stubRepository) UpsertCluster(_ context.Context, cluster cluster.Cluster) (cluster.Cluster, error) {
|
||||
s.saved = cluster
|
||||
if cluster.ID == "" {
|
||||
cluster.ID = "cluster-1"
|
||||
}
|
||||
if cluster.Status == "" {
|
||||
cluster.Status = "active"
|
||||
}
|
||||
cluster.CreatedAt = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
func (s *stubRepository) SetClusterStatus(_ context.Context, id string, status string) (cluster.Cluster, bool, error) {
|
||||
s.statusID = id
|
||||
s.status = status
|
||||
if !s.statusFound {
|
||||
return cluster.Cluster{}, false, nil
|
||||
}
|
||||
return cluster.Cluster{
|
||||
ID: id,
|
||||
Name: "Lab",
|
||||
APIEndpoint: "https://pve.example.test:8006/api2/json",
|
||||
TLSFingerprint: "AA:BB",
|
||||
TokenID: "root@pam!proxui",
|
||||
Status: status,
|
||||
CreatedAt: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC),
|
||||
}, true, nil
|
||||
}
|
||||
Reference in New Issue
Block a user