feat: add test connection feature for all external services

# Conflicts:
#	frontend/src/lib/api.ts
This commit is contained in:
Redbull Deployer 2026-03-07 12:10:47 -06:00
parent aae5cbefb5
commit 2bfe3b7173
5 changed files with 578 additions and 273 deletions

View file

@ -93,3 +93,29 @@ func (h *CredentialsHandler) DeleteCredential(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Credentials deleted successfully"}) 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"})
}

View file

@ -246,6 +246,7 @@ func NewRouter() http.Handler {
mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials)))) 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("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("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) // Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))

View file

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

View file

@ -72,20 +72,7 @@ export default function SettingsPage() {
const [selectedService, setSelectedService] = useState<string | null>(null) const [selectedService, setSelectedService] = useState<string | null>(null)
const [credentialPayload, setCredentialPayload] = useState<any>({}) const [credentialPayload, setCredentialPayload] = useState<any>({})
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [testingConnection, setTestingConnection] = useState(false) const [testingConnection, setTestingConnection] = useState<string | null>(null)
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 fetchSettings = async () => { const fetchSettings = async () => {
try { 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 // State migrated from backoffice
// const [credentialPayload, setCredentialPayload] = useState<any>({}) // const [credentialPayload, setCredentialPayload] = useState<any>({})
@ -505,9 +509,9 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
<div className="flex flex-col gap-2 mt-auto"> <div className="flex flex-col gap-2 mt-auto">
{svc.service_name === 'storage' && svc.is_configured && ( {svc.is_configured && (
<Button variant="secondary" size="sm" className="w-full" onClick={handleTestStorageConnection} disabled={testingConnection}> <Button variant="secondary" size="sm" className="w-full" onClick={() => handleTestConnection(svc.service_name)} disabled={testingConnection === svc.service_name}>
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"} {testingConnection === svc.service_name ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
</Button> </Button>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -68,7 +68,6 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
(error as any).silent = true; (error as any).silent = true;
throw error; throw error;
} }
throw new Error(await getErrorMessage(response)); throw new Error(await getErrorMessage(response));
} }
@ -1017,6 +1016,9 @@ export const credentialsApi = {
delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, { delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, {
method: "DELETE", method: "DELETE",
}), }),
test: (serviceName: string) => apiRequest<{ message: string }>(`/api/v1/system/credentials/${serviceName}/test`, {
method: "POST",
}),
}; };