From 2bfe3b7173532e333f44b01d27ae8f4d85a2dc6c Mon Sep 17 00:00:00 2001
From: Redbull Deployer
Date: Sat, 7 Mar 2026 12:10:47 -0600
Subject: [PATCH] feat: add test connection feature for all external services
# Conflicts:
# frontend/src/lib/api.ts
---
.../api/handlers/credentials_handler.go | 26 +
backend/internal/router/router.go | 1 +
.../internal/services/credentials_testing.go | 272 +++++++++
frontend/src/app/dashboard/settings/page.tsx | 542 +++++++++---------
frontend/src/lib/api.ts | 10 +-
5 files changed, 578 insertions(+), 273 deletions(-)
create mode 100644 backend/internal/services/credentials_testing.go
diff --git a/backend/internal/api/handlers/credentials_handler.go b/backend/internal/api/handlers/credentials_handler.go
index faf9384..9dc80fa 100644
--- a/backend/internal/api/handlers/credentials_handler.go
+++ b/backend/internal/api/handlers/credentials_handler.go
@@ -93,3 +93,29 @@ func (h *CredentialsHandler) DeleteCredential(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Credentials deleted successfully"})
}
+
+// TestCredential runs a connection test for the provided service
+// POST /api/v1/system/credentials/{service}/test
+func (h *CredentialsHandler) TestCredential(w http.ResponseWriter, r *http.Request) {
+ serviceName := r.PathValue("service")
+ if serviceName == "" {
+ http.Error(w, "Service name is required", http.StatusBadRequest)
+ return
+ }
+
+ // Handle specific existing endpoints independently if they don't map to the new CredentialsService logic
+ if serviceName == "storage" {
+ http.Error(w, "Please use the /api/v1/admin/storage/test-connection endpoint for storage", http.StatusBadRequest)
+ return
+ }
+
+ if err := h.credentialsService.TestConnection(r.Context(), serviceName); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Test failed: " + err.Error()})
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"message": "Connection test successful"})
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 2cc0b73..003a2b5 100755
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -246,6 +246,7 @@ func NewRouter() http.Handler {
mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials))))
mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.SaveCredential))))
mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.DeleteCredential))))
+ mux.Handle("POST /api/v1/system/credentials/{service}/test", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.TestCredential))))
// Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
diff --git a/backend/internal/services/credentials_testing.go b/backend/internal/services/credentials_testing.go
new file mode 100644
index 0000000..6649a68
--- /dev/null
+++ b/backend/internal/services/credentials_testing.go
@@ -0,0 +1,272 @@
+package services
+
+import (
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/smtp"
+ "time"
+
+ firebase "firebase.google.com/go/v4"
+ "github.com/appwrite/sdk-for-go/appwrite"
+ amqp "github.com/rabbitmq/amqp091-go"
+ "github.com/stripe/stripe-go/v76"
+ "github.com/stripe/stripe-go/v76/balance"
+ "google.golang.org/api/option"
+)
+
+// TestConnection attempts to use the saved credentials for a specific service to verify they work.
+func (s *CredentialsService) TestConnection(ctx context.Context, serviceName string) error {
+ // First, check if configured
+ configured, err := s.isServiceConfigured(ctx, serviceName)
+ if err != nil {
+ return fmt.Errorf("failed to check if service is configured: %w", err)
+ }
+
+ if !configured {
+ return fmt.Errorf("service %s is not configured", serviceName)
+ }
+
+ // 2. Fetch the credentials payload
+ decrypted, err := s.GetDecryptedKey(ctx, serviceName)
+ if err != nil {
+ return fmt.Errorf("failed to retrieve credentials: %w", err)
+ }
+
+ var creds map[string]string
+ if err := json.Unmarshal([]byte(decrypted), &creds); err != nil {
+ return fmt.Errorf("failed to parse credentials payload: %w", err)
+ }
+
+ // 3. Test based on service type
+ switch serviceName {
+ case "stripe":
+ return s.testStripe(creds)
+ case "cloudflare_config":
+ return s.testCloudflare(ctx, creds)
+ case "cpanel":
+ return s.testCPanel(ctx, creds)
+ case "lavinmq":
+ return s.testLavinMQ(creds)
+ case "appwrite":
+ return s.testAppwrite(creds)
+ case "fcm_service_account":
+ return s.testFCM(ctx, creds)
+ case "smtp":
+ return s.testSMTP(creds)
+ default:
+ return fmt.Errorf("testing for service %s is not implemented", serviceName)
+ }
+}
+
+func (s *CredentialsService) testStripe(creds map[string]string) error {
+ secretKey := creds["secretKey"]
+ if secretKey == "" {
+ return fmt.Errorf("missing secretKey in credentials")
+ }
+
+ stripe.Key = secretKey
+ _, err := balance.Get(nil)
+ if err != nil {
+ return fmt.Errorf("stripe connection test failed: %w", err)
+ }
+ return nil
+}
+
+func (s *CredentialsService) testCloudflare(ctx context.Context, creds map[string]string) error {
+ apiToken := creds["apiToken"]
+ if apiToken == "" {
+ return fmt.Errorf("missing apiToken in credentials")
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+apiToken)
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("cloudflare connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("cloudflare API returned status: %d", resp.StatusCode)
+ }
+
+ var result struct {
+ Success bool `json:"success"`
+ Messages []struct {
+ Message string `json:"message"`
+ } `json:"messages"`
+ }
+
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return fmt.Errorf("failed to parse cloudflare response: %w", err)
+ }
+
+ if !result.Success {
+ return fmt.Errorf("cloudflare verification failed")
+ }
+
+ return nil
+}
+
+func (s *CredentialsService) testLavinMQ(creds map[string]string) error {
+ amqpUrl := creds["amqpUrl"]
+ if amqpUrl == "" {
+ return fmt.Errorf("missing amqpUrl in credentials")
+ }
+
+ conn, err := amqp.Dial(amqpUrl)
+ if err != nil {
+ return fmt.Errorf("lavinmq connection failed: %w", err)
+ }
+ defer conn.Close()
+
+ return nil
+}
+
+func (s *CredentialsService) testAppwrite(creds map[string]string) error {
+ endpoint := creds["endpoint"]
+ projectId := creds["projectId"]
+ apiKey := creds["apiKey"]
+
+ if endpoint == "" || projectId == "" || apiKey == "" {
+ return fmt.Errorf("missing required Appwrite credentials")
+ }
+
+ client := appwrite.NewClient(
+ appwrite.WithEndpoint(endpoint),
+ appwrite.WithProject(projectId),
+ appwrite.WithKey(apiKey),
+ )
+
+ health := appwrite.NewHealth(client)
+ _, err := health.Get()
+ if err != nil {
+ return fmt.Errorf("appwrite connection failed: %w", err)
+ }
+
+ return nil
+}
+
+func (s *CredentialsService) testCPanel(ctx context.Context, creds map[string]string) error {
+ host := creds["host"]
+ username := creds["username"]
+ apiToken := creds["apiToken"]
+
+ if host == "" || username == "" || apiToken == "" {
+ return fmt.Errorf("missing required cpanel credentials")
+ }
+
+ // Just checking the base server reachability since testing cPanel API token accurately can be complex.
+ client := &http.Client{Timeout: 10 * time.Second}
+ req, err := http.NewRequestWithContext(ctx, "GET", host, nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", username, apiToken))
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("cpanel connection failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ return fmt.Errorf("cpanel authentication failed (status %d)", resp.StatusCode)
+ }
+
+ return nil
+}
+
+func (s *CredentialsService) testFCM(ctx context.Context, creds map[string]string) error {
+ serviceAccountJson := creds["serviceAccountJson"]
+ if serviceAccountJson == "" {
+ return fmt.Errorf("missing serviceAccountJson in credentials")
+ }
+
+ opt := option.WithCredentialsJSON([]byte(serviceAccountJson))
+ app, err := firebase.NewApp(ctx, nil, opt)
+ if err != nil {
+ return fmt.Errorf("failed to initialize FCM app: %w", err)
+ }
+
+ client, err := app.Messaging(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to get FCM messaging client: %w", err)
+ }
+ // We don't actually send a notification, we just check if we can instance the client
+ // without credential errors.
+ if client == nil {
+ return fmt.Errorf("FCM client is nil")
+ }
+ return nil
+}
+
+func (s *CredentialsService) testSMTP(creds map[string]string) error {
+ host := creds["host"]
+ port := creds["port"]
+ username := creds["username"]
+ password := creds["password"]
+ secure := creds["secure"]
+
+ if host == "" || port == "" {
+ return fmt.Errorf("missing host or port in smtp credentials")
+ }
+
+ serverName := host + ":" + port
+
+ // Set 10s timeout
+ conn, err := tls.DialWithDialer(
+ &net.Dialer{Timeout: 10 * time.Second},
+ "tcp",
+ serverName,
+ &tls.Config{InsecureSkipVerify: true},
+ )
+ if err != nil {
+ if secure == "true" {
+ return fmt.Errorf("secure SMTP connection failed: %w", err)
+ }
+ // Try insecure
+ client, err := smtp.Dial(serverName)
+ if err != nil {
+ return fmt.Errorf("insecure SMTP connection failed: %w", err)
+ }
+ defer client.Close()
+
+ if username != "" && password != "" {
+ auth := smtp.PlainAuth("", username, password, host)
+ if err = client.Auth(auth); err != nil {
+ return fmt.Errorf("SMTP authentication failed: %w", err)
+ }
+ }
+
+ return nil
+ }
+ defer conn.Close()
+
+ client, err := smtp.NewClient(conn, host)
+ if err != nil {
+ return fmt.Errorf("failed to create SMTP client: %w", err)
+ }
+ defer client.Close()
+
+ if username != "" && password != "" {
+ auth := smtp.PlainAuth("", username, password, host)
+ if err = client.Auth(auth); err != nil {
+ return fmt.Errorf("SMTP authentication failed: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx
index 6134fca..68bc9df 100644
--- a/frontend/src/app/dashboard/settings/page.tsx
+++ b/frontend/src/app/dashboard/settings/page.tsx
@@ -72,20 +72,7 @@ export default function SettingsPage() {
const [selectedService, setSelectedService] = useState(null)
const [credentialPayload, setCredentialPayload] = useState({})
const [isDialogOpen, setIsDialogOpen] = useState(false)
- const [testingConnection, setTestingConnection] = useState(false)
-
- const handleTestStorageConnection = async () => {
- setTestingConnection(true)
- try {
- const res = await storageApi.testConnection()
- toast.success(res.message || "Connection successful")
- } catch (error: any) {
- console.error("Storage connection test failed", error)
- toast.error(error.message || "Connection failed")
- } finally {
- setTestingConnection(false)
- }
- }
+ const [testingConnection, setTestingConnection] = useState(null)
const fetchSettings = async () => {
try {
@@ -243,6 +230,23 @@ export default function SettingsPage() {
}
}
+ const handleTestConnection = async (serviceName: string) => {
+ setTestingConnection(serviceName)
+ try {
+ if (serviceName === "storage") {
+ await storageApi.testConnection()
+ } else {
+ await credentialsApi.test(serviceName)
+ }
+ toast.success(`${schemas[serviceName]?.label || serviceName} connection test successful!`)
+ } catch (error: any) {
+ console.error("Test failed", error)
+ toast.error(`Connection test failed: ${error.message || 'Unknown error'}`)
+ } finally {
+ setTestingConnection(null)
+ }
+ }
+
// State migrated from backoffice
// const [credentialPayload, setCredentialPayload] = useState({})
@@ -505,9 +509,9 @@ export default function SettingsPage() {
- {svc.service_name === 'storage' && svc.is_configured && (
-