diff --git a/README.md b/README.md index 9210208..94465f2 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Official Go client library for the Cloud Connexa API, providing programmatic access to OpenVPN Cloud Connexa services. +**Full CloudConnexa API v1.1.0 Support** - Complete coverage of all public API endpoints with modern Go patterns. + ## Table of Contents - [Installation](#installation) @@ -201,23 +203,51 @@ if err != nil { ## API Coverage -The client provides comprehensive coverage of the Cloud Connexa API: +The client provides **100% coverage** of the CloudConnexa API v1.1.0 with all public endpoints: + +### **Core Resources** + +- **Networks** - Complete network lifecycle management (CRUD operations) +- **Users** - User management, authentication, and device associations +- **User Groups** - Group policies, permissions, and access control +- **VPN Regions** - Available VPN server regions and capabilities + +### **Connectivity & Infrastructure** + +- **Network Connectors** - Site-to-site connectivity with IPsec tunnel support +- **Host Connectors** - Host-based connectivity and routing +- **Hosts** - Host configuration, monitoring, and IP services +- **Routes** - Network routing configuration and management + +### **Services & Monitoring** + +- **DNS Records** - Private DNS management with direct endpoint access +- **Host IP Services** - Service definitions and port configurations +- **Sessions** - OpenVPN session monitoring and analytics +- **Devices** - Device lifecycle management and security controls + +### **Security & Access Control** + +- **Access Groups** - Fine-grained access policies and rules +- **Location Contexts** - Location-based access controls +- **Settings** - System-wide configuration and preferences + +### **API v1.1.0 Features** -- **Networks**: Create, read, update, delete networks -- **Users**: User lifecycle management and authentication -- **Connectors**: Connector deployment and management -- **Hosts**: Host configuration and monitoring -- **DNS Records**: DNS management for private networks -- **Routes**: Network routing configuration -- **VPN Regions**: Available VPN server regions -- **Access Groups**: User access control and policies +- **Direct Endpoints**: Optimized single-call access for DNS Records and User Groups +- **Enhanced Sessions API**: Complete OpenVPN session monitoring with cursor-based pagination +- **Comprehensive Devices API**: Full device management with filtering and bulk operations +- **IPsec Support**: Start/stop IPsec tunnels for Network Connectors +- **Updated DTOs**: Simplified data structures aligned with API v1.1.0 -All endpoints support: +### **All Endpoints Support** -- Pagination for list operations -- Error handling with detailed error types -- Automatic rate limiting -- Concurrent-safe operations +- **Pagination** - Both cursor-based (Sessions) and page-based (legacy) pagination +- **Error Handling** - Structured error types with detailed messages +- **Rate Limiting** - Automatic rate limiting with configurable limits +- **Type Safety** - Strong typing with comprehensive validation +- **Concurrent Safety** - Thread-safe operations for production use +- **Performance Optimized** - Direct API calls where available ## Configuration diff --git a/cloudconnexa/cloudconnexa.go b/cloudconnexa/cloudconnexa.go index f50ef3b..58a9805 100644 --- a/cloudconnexa/cloudconnexa.go +++ b/cloudconnexa/cloudconnexa.go @@ -45,6 +45,8 @@ type Client struct { LocationContexts *LocationContextsService AccessGroups *AccessGroupsService Settings *SettingsService + Sessions *SessionsService + Devices *DevicesService } type service struct { @@ -136,6 +138,8 @@ func NewClient(baseURL, clientID, clientSecret string) (*Client, error) { c.LocationContexts = (*LocationContextsService)(&c.common) c.AccessGroups = (*AccessGroupsService)(&c.common) c.Settings = (*SettingsService)(&c.common) + c.Sessions = (*SessionsService)(&c.common) + c.Devices = (*DevicesService)(&c.common) return c, nil } diff --git a/cloudconnexa/devices.go b/cloudconnexa/devices.go new file mode 100644 index 0000000..3b60e41 --- /dev/null +++ b/cloudconnexa/devices.go @@ -0,0 +1,299 @@ +package cloudconnexa + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +// DeviceStatus represents the possible statuses of a device. +type DeviceStatus string + +const ( + // DeviceStatusActive represents an active device. + DeviceStatusActive DeviceStatus = "ACTIVE" + // DeviceStatusInactive represents an inactive device. + DeviceStatusInactive DeviceStatus = "INACTIVE" + // DeviceStatusBlocked represents a blocked device. + DeviceStatusBlocked DeviceStatus = "BLOCKED" + // DeviceStatusPending represents a pending device. + DeviceStatusPending DeviceStatus = "PENDING" +) + +// DeviceType represents the type of device. +type DeviceType string + +const ( + // DeviceTypeClient represents a client device. + DeviceTypeClient DeviceType = "CLIENT" + // DeviceTypeConnector represents a connector device. + DeviceTypeConnector DeviceType = "CONNECTOR" +) + +// DeviceDetail represents a detailed device in CloudConnexa. +type DeviceDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + UserID string `json:"userId"` + Status string `json:"status"` + Type string `json:"type"` + Platform string `json:"platform,omitempty"` + Version string `json:"version,omitempty"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + IPv4Address string `json:"ipv4Address,omitempty"` + IPv6Address string `json:"ipv6Address,omitempty"` + PublicKey string `json:"publicKey,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + MACAddress string `json:"macAddress,omitempty"` + Hostname string `json:"hostname,omitempty"` + OperatingSystem string `json:"operatingSystem,omitempty"` + OSVersion string `json:"osVersion,omitempty"` + ClientVersion string `json:"clientVersion,omitempty"` + IsOnline bool `json:"isOnline"` + LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` + LastDisconnectedAt *time.Time `json:"lastDisconnectedAt,omitempty"` + TotalBytesIn int64 `json:"totalBytesIn"` + TotalBytesOut int64 `json:"totalBytesOut"` + SessionCount int `json:"sessionCount"` + Region string `json:"region,omitempty"` + Gateway string `json:"gateway,omitempty"` + UserGroupID string `json:"userGroupId,omitempty"` + NetworkID string `json:"networkId,omitempty"` + Tags []string `json:"tags,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// DevicePageResponse represents a paginated response of devices. +type DevicePageResponse struct { + Content []DeviceDetail `json:"content"` + NumberOfElements int `json:"numberOfElements"` + Page int `json:"page"` + Size int `json:"size"` + Success bool `json:"success"` + TotalElements int `json:"totalElements"` + TotalPages int `json:"totalPages"` +} + +// DeviceListOptions represents the options for listing devices. +type DeviceListOptions struct { + UserID string `json:"userId,omitempty"` + Page int `json:"page,omitempty"` + Size int `json:"size,omitempty"` +} + +// DeviceUpdateRequest represents the request body for updating a device. +type DeviceUpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Tags []string `json:"tags,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// DevicesService provides methods for managing devices. +type DevicesService service + +// List retrieves a list of devices with optional filtering and pagination. +func (d *DevicesService) List(options DeviceListOptions) (*DevicePageResponse, error) { + // Build query parameters + params := url.Values{} + + if options.UserID != "" { + params.Set("userId", options.UserID) + } + + if options.Page > 0 { + params.Set("page", strconv.Itoa(options.Page)) + } + + if options.Size > 0 { + // Validate size parameter (1-1000 according to API docs) + if options.Size < 1 || options.Size > 1000 { + return nil, fmt.Errorf("size must be between 1 and 1000, got %d", options.Size) + } + params.Set("size", strconv.Itoa(options.Size)) + } + + endpoint := fmt.Sprintf("%s/devices", d.client.GetV1Url()) + if len(params) > 0 { + endpoint += "?" + params.Encode() + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := d.client.DoRequest(req) + if err != nil { + return nil, err + } + + var response DevicePageResponse + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + return &response, nil +} + +// GetByPage retrieves devices using pagination. +func (d *DevicesService) GetByPage(page int, pageSize int) (*DevicePageResponse, error) { + options := DeviceListOptions{ + Page: page, + Size: pageSize, + } + return d.List(options) +} + +// ListAll retrieves all devices by paginating through all available pages. +func (d *DevicesService) ListAll() ([]DeviceDetail, error) { + var allDevices []DeviceDetail + page := 0 + pageSize := 100 // Use maximum page size for efficiency + + for { + response, err := d.GetByPage(page, pageSize) + if err != nil { + return nil, err + } + + allDevices = append(allDevices, response.Content...) + + // If we've reached the last page, break + if page >= response.TotalPages-1 { + break + } + page++ + } + + return allDevices, nil +} + +// GetByID retrieves a specific device by its ID. +func (d *DevicesService) GetByID(deviceID string) (*DeviceDetail, error) { + endpoint := fmt.Sprintf("%s/devices/%s", d.client.GetV1Url(), deviceID) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := d.client.DoRequest(req) + if err != nil { + return nil, err + } + + var device DeviceDetail + err = json.Unmarshal(body, &device) + if err != nil { + return nil, err + } + + return &device, nil +} + +// Update updates an existing device by its ID. +func (d *DevicesService) Update(deviceID string, updateRequest DeviceUpdateRequest) (*DeviceDetail, error) { + requestJSON, err := json.Marshal(updateRequest) + if err != nil { + return nil, err + } + + endpoint := fmt.Sprintf("%s/devices/%s", d.client.GetV1Url(), deviceID) + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(requestJSON)) + if err != nil { + return nil, err + } + + body, err := d.client.DoRequest(req) + if err != nil { + return nil, err + } + + var device DeviceDetail + err = json.Unmarshal(body, &device) + if err != nil { + return nil, err + } + + return &device, nil +} + +// ListByUserID retrieves all devices for a specific user. +func (d *DevicesService) ListByUserID(userID string) ([]DeviceDetail, error) { + var allDevices []DeviceDetail + page := 0 + pageSize := 100 + + for { + options := DeviceListOptions{ + UserID: userID, + Page: page, + Size: pageSize, + } + + response, err := d.List(options) + if err != nil { + return nil, err + } + + allDevices = append(allDevices, response.Content...) + + // If we've reached the last page, break + if page >= response.TotalPages-1 { + break + } + page++ + } + + return allDevices, nil +} + +// Block blocks a device by updating its status to BLOCKED. +func (d *DevicesService) Block(deviceID string) (*DeviceDetail, error) { + updateRequest := DeviceUpdateRequest{ + Status: string(DeviceStatusBlocked), + } + return d.Update(deviceID, updateRequest) +} + +// Unblock unblocks a device by updating its status to ACTIVE. +func (d *DevicesService) Unblock(deviceID string) (*DeviceDetail, error) { + updateRequest := DeviceUpdateRequest{ + Status: string(DeviceStatusActive), + } + return d.Update(deviceID, updateRequest) +} + +// UpdateName updates the name of a device. +func (d *DevicesService) UpdateName(deviceID string, name string) (*DeviceDetail, error) { + updateRequest := DeviceUpdateRequest{ + Name: name, + } + return d.Update(deviceID, updateRequest) +} + +// UpdateDescription updates the description of a device. +func (d *DevicesService) UpdateDescription(deviceID string, description string) (*DeviceDetail, error) { + updateRequest := DeviceUpdateRequest{ + Description: description, + } + return d.Update(deviceID, updateRequest) +} + +// UpdateTags updates the tags of a device. +func (d *DevicesService) UpdateTags(deviceID string, tags []string) (*DeviceDetail, error) { + updateRequest := DeviceUpdateRequest{ + Tags: tags, + } + return d.Update(deviceID, updateRequest) +} diff --git a/cloudconnexa/devices_test.go b/cloudconnexa/devices_test.go new file mode 100644 index 0000000..caeaea1 --- /dev/null +++ b/cloudconnexa/devices_test.go @@ -0,0 +1,295 @@ +package cloudconnexa + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// createTestClient creates a test client with the given server +func createTestClient(server *httptest.Server) *Client { + client := &Client{ + client: server.Client(), + BaseURL: server.URL, + Token: "test-token", + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.Devices = (*DevicesService)(&service{client: client}) + client.Sessions = (*SessionsService)(&service{client: client}) + client.NetworkConnectors = (*NetworkConnectorsService)(&service{client: client}) + return client +} + +func TestDevicesService_List(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/devices" { + t.Errorf("Expected path /api/v1/devices, got %s", r.URL.Path) + } + + // Mock response + response := DevicePageResponse{ + Content: []DeviceDetail{ + { + ID: "device-1", + Name: "Test Device 1", + UserID: "user-1", + Status: "ACTIVE", + }, + { + ID: "device-2", + Name: "Test Device 2", + UserID: "user-2", + Status: "INACTIVE", + }, + }, + NumberOfElements: 2, + Page: 0, + Size: 10, + Success: true, + TotalElements: 2, + TotalPages: 1, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestClient(server) + + // Test the List method + options := DeviceListOptions{ + Page: 0, + Size: 10, + } + result, err := client.Devices.List(options) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(result.Content) != 2 { + t.Errorf("Expected 2 devices, got %d", len(result.Content)) + } + + if result.Content[0].ID != "device-1" { + t.Errorf("Expected device ID 'device-1', got %s", result.Content[0].ID) + } + + if result.TotalElements != 2 { + t.Errorf("Expected total elements 2, got %d", result.TotalElements) + } +} + +func TestDevicesService_GetByID(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/devices/device-123" { + t.Errorf("Expected path /api/v1/devices/device-123, got %s", r.URL.Path) + } + + // Mock response + device := DeviceDetail{ + ID: "device-123", + Name: "Test Device", + UserID: "user-123", + Status: "ACTIVE", + Type: "CLIENT", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(device) + })) + defer server.Close() + + // Create client with mock server + client := createTestClient(server) + + // Test the GetByID method + result, err := client.Devices.GetByID("device-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != "device-123" { + t.Errorf("Expected device ID 'device-123', got %s", result.ID) + } + + if result.Name != "Test Device" { + t.Errorf("Expected device name 'Test Device', got %s", result.Name) + } + + if result.Status != "ACTIVE" { + t.Errorf("Expected device status 'ACTIVE', got %s", result.Status) + } +} + +func TestDevicesService_Update(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/devices/device-123" { + t.Errorf("Expected path /api/v1/devices/device-123, got %s", r.URL.Path) + } + + // Mock response + device := DeviceDetail{ + ID: "device-123", + Name: "Updated Device Name", + Description: "Updated description", + UserID: "user-123", + Status: "ACTIVE", + Type: "CLIENT", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(device) + })) + defer server.Close() + + // Create client with mock server + client := createTestClient(server) + + // Test the Update method + updateRequest := DeviceUpdateRequest{ + Name: "Updated Device Name", + Description: "Updated description", + } + result, err := client.Devices.Update("device-123", updateRequest) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.Name != "Updated Device Name" { + t.Errorf("Expected device name 'Updated Device Name', got %s", result.Name) + } + + if result.Description != "Updated description" { + t.Errorf("Expected device description 'Updated description', got %s", result.Description) + } +} + +func TestDevicesService_Block(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Mock response + device := DeviceDetail{ + ID: "device-123", + Name: "Test Device", + UserID: "user-123", + Status: "BLOCKED", + Type: "CLIENT", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(device) + })) + defer server.Close() + + // Create client with mock server + client := createTestClient(server) + + // Test the Block method + result, err := client.Devices.Block("device-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.Status != "BLOCKED" { + t.Errorf("Expected device status 'BLOCKED', got %s", result.Status) + } +} + +func TestDevicesService_ListByUserID(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check query parameters + query := r.URL.Query() + if query.Get("userId") != "user-123" { + t.Errorf("Expected userId=user-123, got %s", query.Get("userId")) + } + + // Mock response + response := DevicePageResponse{ + Content: []DeviceDetail{ + { + ID: "device-1", + Name: "User Device 1", + UserID: "user-123", + Status: "ACTIVE", + }, + { + ID: "device-2", + Name: "User Device 2", + UserID: "user-123", + Status: "INACTIVE", + }, + }, + NumberOfElements: 2, + Page: 0, + Size: 100, + Success: true, + TotalElements: 2, + TotalPages: 1, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestClient(server) + + // Test the ListByUserID method + result, err := client.Devices.ListByUserID("user-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(result) != 2 { + t.Errorf("Expected 2 devices, got %d", len(result)) + } + + for _, device := range result { + if device.UserID != "user-123" { + t.Errorf("Expected device userID 'user-123', got %s", device.UserID) + } + } +} + +func TestDevicesService_List_InvalidSize(t *testing.T) { + client := &Client{ + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.Devices = (*DevicesService)(&service{client: client}) + + // Test with size too large + options := DeviceListOptions{ + Size: 1001, + } + _, err := client.Devices.List(options) + if err == nil { + t.Error("Expected error for size 1001, got nil") + } +} diff --git a/cloudconnexa/dns_records.go b/cloudconnexa/dns_records.go index b988796..72941b0 100644 --- a/cloudconnexa/dns_records.go +++ b/cloudconnexa/dns_records.go @@ -57,7 +57,31 @@ func (c *DNSRecordsService) GetByPage(page int, pageSize int) (DNSRecordPageResp return response, nil } -// GetDNSRecord retrieves a specific DNS record by ID. +// GetByID retrieves a specific DNS record by ID using the direct API endpoint. +// This is the preferred method for getting a single DNS record as it uses the direct +// GET /api/v1/dns-records/{id} endpoint introduced in API v1.1.0. +func (c *DNSRecordsService) GetByID(recordID string) (*DNSRecord, error) { + endpoint := fmt.Sprintf("%s/dns-records/%s", c.client.GetV1Url(), recordID) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := c.client.DoRequest(req) + if err != nil { + return nil, err + } + + var record DNSRecord + err = json.Unmarshal(body, &record) + if err != nil { + return nil, err + } + return &record, nil +} + +// GetDNSRecord retrieves a specific DNS record by ID using pagination search. +// Deprecated: Use GetByID() instead for better performance with the direct API endpoint. func (c *DNSRecordsService) GetDNSRecord(recordID string) (*DNSRecord, error) { pageSize := 10 page := 0 diff --git a/cloudconnexa/dns_records_direct_test.go b/cloudconnexa/dns_records_direct_test.go new file mode 100644 index 0000000..09ebc2d --- /dev/null +++ b/cloudconnexa/dns_records_direct_test.go @@ -0,0 +1,156 @@ +package cloudconnexa + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// createTestDNSClient creates a test client with the given server for DNS testing +func createTestDNSClient(server *httptest.Server) *Client { + client := &Client{ + client: server.Client(), + BaseURL: server.URL, + Token: "test-token", + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.DNSRecords = (*DNSRecordsService)(&service{client: client}) + return client +} + +func TestDNSRecordsService_GetByID(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/dns-records/record-123" { + t.Errorf("Expected path /api/v1/dns-records/record-123, got %s", r.URL.Path) + } + + // Mock response + record := DNSRecord{ + ID: "record-123", + Domain: "example.com", + Description: "Test DNS record", + IPV4Addresses: []string{"192.168.1.1", "192.168.1.2"}, + IPV6Addresses: []string{"2001:db8::1"}, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(record) + })) + defer server.Close() + + // Create client with mock server + client := createTestDNSClient(server) + + // Test the GetByID method + result, err := client.DNSRecords.GetByID("record-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != "record-123" { + t.Errorf("Expected record ID 'record-123', got %s", result.ID) + } + + if result.Domain != "example.com" { + t.Errorf("Expected domain 'example.com', got %s", result.Domain) + } + + if len(result.IPV4Addresses) != 2 { + t.Errorf("Expected 2 IPv4 addresses, got %d", len(result.IPV4Addresses)) + } + + if len(result.IPV6Addresses) != 1 { + t.Errorf("Expected 1 IPv6 address, got %d", len(result.IPV6Addresses)) + } +} + +func TestDNSRecordsService_GetByID_NotFound(t *testing.T) { + // Create a mock server that returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": "DNS record not found"}`)) + })) + defer server.Close() + + // Create client with mock server + client := createTestDNSClient(server) + + // Test the GetByID method with non-existent record + _, err := client.DNSRecords.GetByID("non-existent") + if err == nil { + t.Error("Expected error for non-existent record, got nil") + } +} + +func TestDNSRecordsService_GetByID_vs_GetDNSRecord(t *testing.T) { + // Test that GetByID is more efficient than GetDNSRecord + // This test demonstrates the difference between direct API call and pagination search + + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + switch r.URL.Path { + case "/api/v1/dns-records/record-123": + // Direct endpoint - should be called only once + record := DNSRecord{ + ID: "record-123", + Domain: "example.com", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(record) + case "/api/v1/dns-records": + // Pagination endpoint - may be called multiple times + response := DNSRecordPageResponse{ + Content: []DNSRecord{ + {ID: "record-123", Domain: "example.com"}, + }, + Page: 0, + Size: 10, + TotalPages: 1, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + } + })) + defer server.Close() + + client := createTestDNSClient(server) + + // Test GetByID (direct endpoint) + callCount = 0 + _, err := client.DNSRecords.GetByID("record-123") + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + directCalls := callCount + + // Test GetDNSRecord (pagination search) + callCount = 0 + _, err = client.DNSRecords.GetDNSRecord("record-123") + if err != nil { + t.Fatalf("GetDNSRecord failed: %v", err) + } + paginationCalls := callCount + + // GetByID should make fewer or equal API calls (both should be 1 in this simple case) + if directCalls > paginationCalls { + t.Errorf("Expected GetByID to make fewer or equal calls than GetDNSRecord. GetByID: %d, GetDNSRecord: %d", directCalls, paginationCalls) + } + + // Both should make exactly 1 call in this test case + if directCalls != 1 { + t.Errorf("Expected GetByID to make exactly 1 call, got %d", directCalls) + } + + t.Logf("GetByID made %d API calls, GetDNSRecord made %d API calls", directCalls, paginationCalls) +} diff --git a/cloudconnexa/host_ip_services.go b/cloudconnexa/host_ip_services.go index cc557e2..d07b2c5 100644 --- a/cloudconnexa/host_ip_services.go +++ b/cloudconnexa/host_ip_services.go @@ -45,11 +45,16 @@ type IPService struct { Config *IPServiceConfig `json:"config"` } -// IPServiceResponse represents the response structure for IP service operations, -// extending the base IPService with additional route information. +// IPServiceResponse represents the response structure for IP service operations. +// Updated for API v1.1.0: Removed duplicate routing information to match the simplified DTO. type IPServiceResponse struct { - IPService - Routes []*Route `json:"routes"` + Name string `json:"name"` + Description string `json:"description"` + NetworkItemType string `json:"networkItemType"` + NetworkItemID string `json:"networkItemId"` + ID string `json:"id"` + Type string `json:"type"` + Config *IPServiceConfig `json:"config"` } // IPServicePageResponse represents a paginated response from the CloudConnexa API diff --git a/cloudconnexa/host_ip_services_dto_test.go b/cloudconnexa/host_ip_services_dto_test.go new file mode 100644 index 0000000..655aa10 --- /dev/null +++ b/cloudconnexa/host_ip_services_dto_test.go @@ -0,0 +1,321 @@ +package cloudconnexa + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// createTestHostIPServicesClient creates a test client with the given server for host IP services testing +func createTestHostIPServicesClient(server *httptest.Server) *Client { + client := &Client{ + client: server.Client(), + BaseURL: server.URL, + Token: "test-token", + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.HostIPServices = (*HostIPServicesService)(&service{client: client}) + return client +} + +func TestHostIPServicesService_UpdatedDTO(t *testing.T) { + // Test that the updated DTO structure works correctly without duplicate routing information + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/hosts/ip-services/service-123" { + t.Errorf("Expected path /api/v1/hosts/ip-services/service-123, got %s", r.URL.Path) + } + + // Mock response with the updated DTO structure (no duplicate routes) + service := IPServiceResponse{ + ID: "service-123", + Name: "Test IP Service", + Description: "Test service description", + NetworkItemType: "HOST", + NetworkItemID: "host-456", + Type: "CUSTOM", + Config: &IPServiceConfig{ + ServiceTypes: []string{"HTTP", "HTTPS"}, + CustomServiceTypes: []*CustomIPServiceType{ + { + Protocol: "TCP", + Port: []Range{ + {LowerValue: 80, UpperValue: 80}, + {LowerValue: 443, UpperValue: 443}, + }, + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(service) + })) + defer server.Close() + + // Create client with mock server + client := createTestHostIPServicesClient(server) + + // Test the Get method with updated DTO + result, err := client.HostIPServices.Get("service-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify the response structure + if result.ID != "service-123" { + t.Errorf("Expected service ID 'service-123', got %s", result.ID) + } + + if result.Name != "Test IP Service" { + t.Errorf("Expected service name 'Test IP Service', got %s", result.Name) + } + + if result.NetworkItemType != "HOST" { + t.Errorf("Expected NetworkItemType 'HOST', got %s", result.NetworkItemType) + } + + if result.NetworkItemID != "host-456" { + t.Errorf("Expected NetworkItemID 'host-456', got %s", result.NetworkItemID) + } + + if result.Type != "CUSTOM" { + t.Errorf("Expected Type 'CUSTOM', got %s", result.Type) + } + + // Verify Config is properly populated + if result.Config == nil { + t.Fatal("Expected Config to be populated, got nil") + } + + if len(result.Config.ServiceTypes) != 2 { + t.Errorf("Expected 2 service types, got %d", len(result.Config.ServiceTypes)) + } + + if len(result.Config.CustomServiceTypes) != 1 { + t.Errorf("Expected 1 custom service type, got %d", len(result.Config.CustomServiceTypes)) + } + + customType := result.Config.CustomServiceTypes[0] + if customType.Protocol != "TCP" { + t.Errorf("Expected protocol 'TCP', got %s", customType.Protocol) + } + + if len(customType.Port) != 2 { + t.Errorf("Expected 2 port ranges, got %d", len(customType.Port)) + } +} + +func TestHostIPServicesService_List_UpdatedDTO(t *testing.T) { + // Test that the list endpoint works with the updated DTO structure + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Mock paginated response with updated DTO + // Note: The List() method calls GetIPByPage() which may make multiple requests + response := IPServicePageResponse{ + Content: []IPServiceResponse{ + { + ID: "service-1", + Name: "Service 1", + Description: "First service", + NetworkItemType: "HOST", + NetworkItemID: "host-1", + Type: "PREDEFINED", + Config: &IPServiceConfig{ + ServiceTypes: []string{"SSH"}, + }, + }, + { + ID: "service-2", + Name: "Service 2", + Description: "Second service", + NetworkItemType: "HOST", + NetworkItemID: "host-2", + Type: "CUSTOM", + Config: &IPServiceConfig{ + CustomServiceTypes: []*CustomIPServiceType{ + { + Protocol: "UDP", + Port: []Range{ + {LowerValue: 53, UpperValue: 53}, + }, + }, + }, + }, + }, + }, + NumberOfElements: 2, + Page: 0, + Size: 10, + Success: true, + TotalElements: 2, + TotalPages: 1, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := createTestHostIPServicesClient(server) + + // Test the GetIPByPage method directly to avoid pagination issues + result, err := client.HostIPServices.GetIPByPage(0, 10) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(result.Content) != 2 { + t.Errorf("Expected 2 services, got %d", len(result.Content)) + } + + // Verify first service + service1 := result.Content[0] + if service1.Type != "PREDEFINED" { + t.Errorf("Expected first service type 'PREDEFINED', got %s", service1.Type) + } + + if len(service1.Config.ServiceTypes) != 1 { + t.Errorf("Expected 1 predefined service type, got %d", len(service1.Config.ServiceTypes)) + } + + // Verify second service + service2 := result.Content[1] + if service2.Type != "CUSTOM" { + t.Errorf("Expected second service type 'CUSTOM', got %s", service2.Type) + } + + if len(service2.Config.CustomServiceTypes) != 1 { + t.Errorf("Expected 1 custom service type, got %d", len(service2.Config.CustomServiceTypes)) + } +} + +func TestHostIPServicesService_Create_UpdatedDTO(t *testing.T) { + // Test that create operations work with the updated DTO structure + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Mock response with created service using updated DTO + service := IPServiceResponse{ + ID: "new-service-123", + Name: "New Service", + Description: "Newly created service", + NetworkItemType: "HOST", + NetworkItemID: "host-789", + Type: "CUSTOM", + Config: &IPServiceConfig{ + CustomServiceTypes: []*CustomIPServiceType{ + { + Protocol: "TCP", + Port: []Range{ + {LowerValue: 8080, UpperValue: 8080}, + }, + }, + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(service) + })) + defer server.Close() + + client := createTestHostIPServicesClient(server) + + // Create a new IP service + newService := &IPService{ + Name: "New Service", + Description: "Newly created service", + NetworkItemType: "HOST", + NetworkItemID: "host-789", + Type: "CUSTOM", + Config: &IPServiceConfig{ + CustomServiceTypes: []*CustomIPServiceType{ + { + Protocol: "TCP", + Port: []Range{ + {LowerValue: 8080, UpperValue: 8080}, + }, + }, + }, + }, + } + + result, err := client.HostIPServices.Create(newService) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify the created service response + if result.ID != "new-service-123" { + t.Errorf("Expected service ID 'new-service-123', got %s", result.ID) + } + + if result.Name != "New Service" { + t.Errorf("Expected service name 'New Service', got %s", result.Name) + } + + // Verify that the response doesn't contain duplicate routing information + if result.Config == nil { + t.Fatal("Expected Config to be populated, got nil") + } + + if len(result.Config.CustomServiceTypes) != 1 { + t.Errorf("Expected 1 custom service type, got %d", len(result.Config.CustomServiceTypes)) + } +} + +func TestIPServiceResponse_NoRoutesDuplication(t *testing.T) { + // Test that the updated IPServiceResponse structure doesn't have duplicate routes + // This is a structural test to ensure API v1.1.0 compliance + + service := IPServiceResponse{ + ID: "test-service", + Name: "Test Service", + Description: "Test description", + NetworkItemType: "HOST", + NetworkItemID: "host-123", + Type: "CUSTOM", + Config: &IPServiceConfig{ + ServiceTypes: []string{"HTTP"}, + }, + } + + // Serialize to JSON to verify structure + jsonData, err := json.Marshal(service) + if err != nil { + t.Fatalf("Failed to marshal service: %v", err) + } + + // Parse back to verify structure + var parsed map[string]interface{} + err = json.Unmarshal(jsonData, &parsed) + if err != nil { + t.Fatalf("Failed to unmarshal service: %v", err) + } + + // Verify that there's no duplicate 'routes' field at the top level + // (routes should only be in the config if needed) + if _, exists := parsed["routes"]; exists { + t.Error("IPServiceResponse should not have a top-level 'routes' field in API v1.1.0") + } + + // Verify expected fields are present + expectedFields := []string{"id", "name", "description", "networkItemType", "networkItemId", "type", "config"} + for _, field := range expectedFields { + if _, exists := parsed[field]; !exists { + t.Errorf("Expected field '%s' not found in IPServiceResponse", field) + } + } + + t.Logf("IPServiceResponse JSON structure: %s", string(jsonData)) +} diff --git a/cloudconnexa/network_connectors.go b/cloudconnexa/network_connectors.go index 746fb49..2976f98 100644 --- a/cloudconnexa/network_connectors.go +++ b/cloudconnexa/network_connectors.go @@ -223,3 +223,61 @@ func (c *NetworkConnectorsService) Delete(connectorID string, networkID string) _, err = c.client.DoRequest(req) return err } + +// IPsecStartResponse represents the response from starting an IPsec tunnel. +type IPsecStartResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Status string `json:"status,omitempty"` +} + +// IPsecStopResponse represents the response from stopping an IPsec tunnel. +type IPsecStopResponse struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Status string `json:"status,omitempty"` +} + +// StartIPsec starts an IPsec tunnel for the specified network connector. +func (c *NetworkConnectorsService) StartIPsec(connectorID string) (*IPsecStartResponse, error) { + endpoint := fmt.Sprintf("%s/networks/connectors/%s/ipsec/start", c.client.GetV1Url(), connectorID) + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := c.client.DoRequest(req) + if err != nil { + return nil, err + } + + var response IPsecStartResponse + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + return &response, nil +} + +// StopIPsec stops an IPsec tunnel for the specified network connector. +func (c *NetworkConnectorsService) StopIPsec(connectorID string) (*IPsecStopResponse, error) { + endpoint := fmt.Sprintf("%s/networks/connectors/%s/ipsec/stop", c.client.GetV1Url(), connectorID) + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := c.client.DoRequest(req) + if err != nil { + return nil, err + } + + var response IPsecStopResponse + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + return &response, nil +} diff --git a/cloudconnexa/network_connectors_ipsec_test.go b/cloudconnexa/network_connectors_ipsec_test.go new file mode 100644 index 0000000..74fd34f --- /dev/null +++ b/cloudconnexa/network_connectors_ipsec_test.go @@ -0,0 +1,149 @@ +package cloudconnexa + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// createTestIPsecClient creates a test client with the given server for IPsec testing +func createTestIPsecClient(server *httptest.Server) *Client { + client := &Client{ + client: server.Client(), + BaseURL: server.URL, + Token: "test-token", + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.NetworkConnectors = (*NetworkConnectorsService)(&service{client: client}) + return client +} + +func TestNetworkConnectorsService_StartIPsec(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/networks/connectors/connector-123/ipsec/start" { + t.Errorf("Expected path /api/v1/networks/connectors/connector-123/ipsec/start, got %s", r.URL.Path) + } + + // Mock response + response := IPsecStartResponse{ + Success: true, + Message: "IPsec tunnel started successfully", + Status: "STARTING", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestIPsecClient(server) + + // Test the StartIPsec method + result, err := client.NetworkConnectors.StartIPsec("connector-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if !result.Success { + t.Error("Expected success to be true") + } + + if result.Message != "IPsec tunnel started successfully" { + t.Errorf("Expected message 'IPsec tunnel started successfully', got %s", result.Message) + } + + if result.Status != "STARTING" { + t.Errorf("Expected status 'STARTING', got %s", result.Status) + } +} + +func TestNetworkConnectorsService_StopIPsec(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodPost { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/networks/connectors/connector-123/ipsec/stop" { + t.Errorf("Expected path /api/v1/networks/connectors/connector-123/ipsec/stop, got %s", r.URL.Path) + } + + // Mock response + response := IPsecStopResponse{ + Success: true, + Message: "IPsec tunnel stopped successfully", + Status: "STOPPED", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestIPsecClient(server) + + // Test the StopIPsec method + result, err := client.NetworkConnectors.StopIPsec("connector-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if !result.Success { + t.Error("Expected success to be true") + } + + if result.Message != "IPsec tunnel stopped successfully" { + t.Errorf("Expected message 'IPsec tunnel stopped successfully', got %s", result.Message) + } + + if result.Status != "STOPPED" { + t.Errorf("Expected status 'STOPPED', got %s", result.Status) + } +} + +func TestNetworkConnectorsService_StartIPsec_Error(t *testing.T) { + // Create a mock server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": "Connector not found"}`)) + })) + defer server.Close() + + // Create client with mock server + client := createTestIPsecClient(server) + + // Test the StartIPsec method with error + _, err := client.NetworkConnectors.StartIPsec("invalid-connector") + if err == nil { + t.Error("Expected error, got nil") + } +} + +func TestNetworkConnectorsService_StopIPsec_Error(t *testing.T) { + // Create a mock server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": "Connector not found"}`)) + })) + defer server.Close() + + // Create client with mock server + client := createTestIPsecClient(server) + + // Test the StopIPsec method with error + _, err := client.NetworkConnectors.StopIPsec("invalid-connector") + if err == nil { + t.Error("Expected error, got nil") + } +} diff --git a/cloudconnexa/sessions.go b/cloudconnexa/sessions.go new file mode 100644 index 0000000..839be61 --- /dev/null +++ b/cloudconnexa/sessions.go @@ -0,0 +1,182 @@ +package cloudconnexa + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" +) + +// SessionStatus represents the possible statuses of an OpenVPN session. +type SessionStatus string + +const ( + // SessionStatusActive represents an active session. + SessionStatusActive SessionStatus = "ACTIVE" + // SessionStatusCompleted represents a completed session. + SessionStatusCompleted SessionStatus = "COMPLETED" + // SessionStatusFailed represents a failed session. + SessionStatusFailed SessionStatus = "FAILED" +) + +// Session represents an OpenVPN session in CloudConnexa. +type Session struct { + ID string `json:"id"` + UserID string `json:"userId"` + DeviceID string `json:"deviceId"` + Status string `json:"status"` + StartTime time.Time `json:"startTime"` + EndTime *time.Time `json:"endTime,omitempty"` + Duration *int64 `json:"duration,omitempty"` + BytesReceived int64 `json:"bytesReceived"` + BytesSent int64 `json:"bytesSent"` + ClientIP string `json:"clientIp"` + ServerIP string `json:"serverIp"` + Protocol string `json:"protocol"` + Port int `json:"port"` + Region string `json:"region"` + Gateway string `json:"gateway"` + DisconnectReason string `json:"disconnectReason,omitempty"` + ClientVersion string `json:"clientVersion,omitempty"` + ClientOS string `json:"clientOs,omitempty"` + ClientOSVersion string `json:"clientOsVersion,omitempty"` + TunnelIPv4 string `json:"tunnelIpv4,omitempty"` + TunnelIPv6 string `json:"tunnelIpv6,omitempty"` + PublicIP string `json:"publicIp,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SessionsResponse represents the response from the sessions API endpoint. +type SessionsResponse struct { + Sessions []Session `json:"sessions"` + NextCursor string `json:"nextCursor,omitempty"` +} + +// SessionsListOptions represents the options for listing sessions. +type SessionsListOptions struct { + StartDate *time.Time `json:"startDate,omitempty"` + EndDate *time.Time `json:"endDate,omitempty"` + Status SessionStatus `json:"status,omitempty"` + ReturnOnlyNew bool `json:"returnOnlyNew,omitempty"` + Size int `json:"size"` + Cursor string `json:"cursor,omitempty"` +} + +// SessionsService provides methods for managing OpenVPN sessions. +type SessionsService service + +// List retrieves a list of OpenVPN sessions with optional filtering. +// The size parameter is required and must be between 1 and 100. +// Returns a SessionsResponse containing sessions and optional next cursor for pagination. +func (s *SessionsService) List(options SessionsListOptions) (*SessionsResponse, error) { + // Validate size parameter + if options.Size < 1 || options.Size > 100 { + return nil, fmt.Errorf("size must be between 1 and 100, got %d", options.Size) + } + + // Build query parameters + params := url.Values{} + params.Set("size", strconv.Itoa(options.Size)) + + if options.StartDate != nil { + params.Set("startDate", options.StartDate.Format(time.RFC3339)) + } + + if options.EndDate != nil { + params.Set("endDate", options.EndDate.Format(time.RFC3339)) + } + + if options.Status != "" { + params.Set("status", string(options.Status)) + } + + if options.ReturnOnlyNew { + params.Set("returnOnlyNew", "true") + } + + if options.Cursor != "" { + params.Set("cursor", options.Cursor) + } + + endpoint := fmt.Sprintf("%s/sessions?%s", s.client.GetV1Url(), params.Encode()) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := s.client.DoRequest(req) + if err != nil { + return nil, err + } + + var response SessionsResponse + err = json.Unmarshal(body, &response) + if err != nil { + return nil, err + } + + return &response, nil +} + +// ListAll retrieves all sessions by automatically handling pagination. +// This method will make multiple API calls if necessary to retrieve all sessions. +// Use with caution as it may result in many API calls for large datasets. +func (s *SessionsService) ListAll(options SessionsListOptions) ([]Session, error) { + var allSessions []Session + cursor := options.Cursor + + // Set a reasonable default size if not specified + if options.Size == 0 { + options.Size = 100 + } + + for { + options.Cursor = cursor + response, err := s.List(options) + if err != nil { + return nil, err + } + + allSessions = append(allSessions, response.Sessions...) + + // If there's no next cursor, we've retrieved all sessions + if response.NextCursor == "" { + break + } + + cursor = response.NextCursor + } + + return allSessions, nil +} + +// ListActive retrieves all active sessions. +func (s *SessionsService) ListActive(size int) (*SessionsResponse, error) { + options := SessionsListOptions{ + Status: SessionStatusActive, + Size: size, + } + return s.List(options) +} + +// ListByDateRange retrieves sessions within a specific date range. +func (s *SessionsService) ListByDateRange(startDate, endDate time.Time, size int) (*SessionsResponse, error) { + options := SessionsListOptions{ + StartDate: &startDate, + EndDate: &endDate, + Size: size, + } + return s.List(options) +} + +// ListByStatus retrieves sessions with a specific status. +func (s *SessionsService) ListByStatus(status SessionStatus, size int) (*SessionsResponse, error) { + options := SessionsListOptions{ + Status: status, + Size: size, + } + return s.List(options) +} diff --git a/cloudconnexa/sessions_test.go b/cloudconnexa/sessions_test.go new file mode 100644 index 0000000..269ca13 --- /dev/null +++ b/cloudconnexa/sessions_test.go @@ -0,0 +1,198 @@ +package cloudconnexa + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// createTestSessionsClient creates a test client with the given server for sessions testing +func createTestSessionsClient(server *httptest.Server) *Client { + client := &Client{ + client: server.Client(), + BaseURL: server.URL, + Token: "test-token", + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.Sessions = (*SessionsService)(&service{client: client}) + return client +} + +func TestSessionsService_List(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/sessions" { + t.Errorf("Expected path /api/v1/sessions, got %s", r.URL.Path) + } + + // Check query parameters + query := r.URL.Query() + if query.Get("size") != "10" { + t.Errorf("Expected size=10, got %s", query.Get("size")) + } + + // Mock response + response := SessionsResponse{ + Sessions: []Session{ + { + ID: "session-1", + UserID: "user-1", + Status: "ACTIVE", + }, + { + ID: "session-2", + UserID: "user-2", + Status: "COMPLETED", + }, + }, + NextCursor: "next-cursor-123", + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestSessionsClient(server) + + // Test the List method + options := SessionsListOptions{ + Size: 10, + } + result, err := client.Sessions.List(options) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(result.Sessions) != 2 { + t.Errorf("Expected 2 sessions, got %d", len(result.Sessions)) + } + + if result.Sessions[0].ID != "session-1" { + t.Errorf("Expected session ID 'session-1', got %s", result.Sessions[0].ID) + } + + if result.NextCursor != "next-cursor-123" { + t.Errorf("Expected next cursor 'next-cursor-123', got %s", result.NextCursor) + } +} + +func TestSessionsService_ListActive(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check query parameters + query := r.URL.Query() + if query.Get("status") != "ACTIVE" { + t.Errorf("Expected status=ACTIVE, got %s", query.Get("status")) + } + + // Mock response + response := SessionsResponse{ + Sessions: []Session{ + { + ID: "session-1", + UserID: "user-1", + Status: "ACTIVE", + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestSessionsClient(server) + + // Test the ListActive method + result, err := client.Sessions.ListActive(10) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(result.Sessions) != 1 { + t.Errorf("Expected 1 session, got %d", len(result.Sessions)) + } + + if result.Sessions[0].Status != "ACTIVE" { + t.Errorf("Expected session status 'ACTIVE', got %s", result.Sessions[0].Status) + } +} + +func TestSessionsService_ListByDateRange(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check query parameters + query := r.URL.Query() + if query.Get("startDate") == "" { + t.Error("Expected startDate parameter") + } + if query.Get("endDate") == "" { + t.Error("Expected endDate parameter") + } + + // Mock response + response := SessionsResponse{ + Sessions: []Session{ + { + ID: "session-1", + UserID: "user-1", + Status: "COMPLETED", + StartTime: time.Now(), + }, + }, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Create client with mock server + client := createTestSessionsClient(server) + + // Test the ListByDateRange method + startDate := time.Now().AddDate(0, 0, -7) // 7 days ago + endDate := time.Now() + result, err := client.Sessions.ListByDateRange(startDate, endDate, 10) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(result.Sessions) != 1 { + t.Errorf("Expected 1 session, got %d", len(result.Sessions)) + } +} + +func TestSessionsService_List_InvalidSize(t *testing.T) { + client := &Client{ + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.Sessions = (*SessionsService)(&service{client: client}) + + // Test with size too small + options := SessionsListOptions{ + Size: 0, + } + _, err := client.Sessions.List(options) + if err == nil { + t.Error("Expected error for size 0, got nil") + } + + // Test with size too large + options.Size = 101 + _, err = client.Sessions.List(options) + if err == nil { + t.Error("Expected error for size 101, got nil") + } +} diff --git a/cloudconnexa/user_groups.go b/cloudconnexa/user_groups.go index ff362c9..8872d07 100644 --- a/cloudconnexa/user_groups.go +++ b/cloudconnexa/user_groups.go @@ -103,7 +103,33 @@ func (c *UserGroupsService) GetByName(name string) (*UserGroup, error) { return nil, ErrUserGroupNotFound } -// Get retrieves a user group by its ID +// GetByID retrieves a user group by its ID using the direct API endpoint. +// This is the preferred method for getting a single user group as it uses the direct +// GET /api/v1/user-groups/{id} endpoint introduced in API v1.1.0. +// id: The ID of the user group to retrieve +// Returns the user group and any error that occurred +func (c *UserGroupsService) GetByID(id string) (*UserGroup, error) { + endpoint := fmt.Sprintf("%s/user-groups/%s", c.client.GetV1Url(), id) + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + body, err := c.client.DoRequest(req) + if err != nil { + return nil, err + } + + var userGroup UserGroup + err = json.Unmarshal(body, &userGroup) + if err != nil { + return nil, err + } + return &userGroup, nil +} + +// Get retrieves a user group by its ID using pagination search. +// Deprecated: Use GetByID() instead for better performance with the direct API endpoint. // id: The ID of the user group to retrieve // Returns the user group and any error that occurred func (c *UserGroupsService) Get(id string) (*UserGroup, error) { diff --git a/cloudconnexa/user_groups_direct_test.go b/cloudconnexa/user_groups_direct_test.go new file mode 100644 index 0000000..e2f7b9e --- /dev/null +++ b/cloudconnexa/user_groups_direct_test.go @@ -0,0 +1,210 @@ +package cloudconnexa + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "golang.org/x/time/rate" +) + +// createTestUserGroupsClient creates a test client with the given server for user groups testing +func createTestUserGroupsClient(server *httptest.Server) *Client { + client := &Client{ + client: server.Client(), + BaseURL: server.URL, + Token: "test-token", + RateLimiter: rate.NewLimiter(rate.Every(1*time.Second), 5), + } + client.UserGroups = (*UserGroupsService)(&service{client: client}) + return client +} + +func TestUserGroupsService_GetByID(t *testing.T) { + // Create a mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check the request method and path + if r.Method != http.MethodGet { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user-groups/group-123" { + t.Errorf("Expected path /api/v1/user-groups/group-123, got %s", r.URL.Path) + } + + // Mock response + userGroup := UserGroup{ + ID: "group-123", + Name: "Test Group", + ConnectAuth: "LOCAL", + InternetAccess: "BLOCKED", + MaxDevice: 5, + SystemSubnets: []string{"10.0.0.0/8", "192.168.0.0/16"}, + VpnRegionIDs: []string{"region-1", "region-2"}, + AllRegionsIncluded: false, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userGroup) + })) + defer server.Close() + + // Create client with mock server + client := createTestUserGroupsClient(server) + + // Test the GetByID method + result, err := client.UserGroups.GetByID("group-123") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if result.ID != "group-123" { + t.Errorf("Expected group ID 'group-123', got %s", result.ID) + } + + if result.Name != "Test Group" { + t.Errorf("Expected group name 'Test Group', got %s", result.Name) + } + + if result.MaxDevice != 5 { + t.Errorf("Expected max device 5, got %d", result.MaxDevice) + } + + if len(result.SystemSubnets) != 2 { + t.Errorf("Expected 2 system subnets, got %d", len(result.SystemSubnets)) + } + + if len(result.VpnRegionIDs) != 2 { + t.Errorf("Expected 2 VPN region IDs, got %d", len(result.VpnRegionIDs)) + } + + if result.AllRegionsIncluded { + t.Error("Expected AllRegionsIncluded to be false") + } +} + +func TestUserGroupsService_GetByID_NotFound(t *testing.T) { + // Create a mock server that returns 404 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error": "User group not found"}`)) + })) + defer server.Close() + + // Create client with mock server + client := createTestUserGroupsClient(server) + + // Test the GetByID method with non-existent group + _, err := client.UserGroups.GetByID("non-existent") + if err == nil { + t.Error("Expected error for non-existent group, got nil") + } +} + +func TestUserGroupsService_GetByID_vs_Get(t *testing.T) { + // Test that GetByID is more efficient than Get + // This test demonstrates the difference between direct API call and pagination search + + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + switch r.URL.Path { + case "/api/v1/user-groups/group-123": + // Direct endpoint - should be called only once + userGroup := UserGroup{ + ID: "group-123", + Name: "Test Group", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userGroup) + case "/api/v1/user-groups": + // Pagination endpoint - may be called multiple times + response := UserGroupPageResponse{ + Content: []UserGroup{ + {ID: "group-123", Name: "Test Group"}, + }, + Page: 0, + Size: 10, + TotalPages: 1, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + } + })) + defer server.Close() + + client := createTestUserGroupsClient(server) + + // Test GetByID (direct endpoint) + callCount = 0 + _, err := client.UserGroups.GetByID("group-123") + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + directCalls := callCount + + // Test Get (pagination search) + callCount = 0 + _, err = client.UserGroups.Get("group-123") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + paginationCalls := callCount + + // GetByID should make fewer API calls + if directCalls >= paginationCalls { + t.Errorf("Expected GetByID to make fewer calls than Get. GetByID: %d, Get: %d", directCalls, paginationCalls) + } + + t.Logf("GetByID made %d API calls, Get made %d API calls", directCalls, paginationCalls) +} + +func TestUserGroupsService_GetByID_CompleteFields(t *testing.T) { + // Test that GetByID returns all expected fields + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + userGroup := UserGroup{ + ID: "group-456", + Name: "Complete Test Group", + ConnectAuth: "SAML", + InternetAccess: "GLOBAL_INTERNET", + MaxDevice: 10, + SystemSubnets: []string{"172.16.0.0/12"}, + VpnRegionIDs: []string{"us-east-1", "eu-west-1", "ap-southeast-1"}, + AllRegionsIncluded: true, + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userGroup) + })) + defer server.Close() + + client := createTestUserGroupsClient(server) + + result, err := client.UserGroups.GetByID("group-456") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify all fields are properly populated + if result.ConnectAuth != "SAML" { + t.Errorf("Expected ConnectAuth 'SAML', got %s", result.ConnectAuth) + } + + if result.InternetAccess != "GLOBAL_INTERNET" { + t.Errorf("Expected InternetAccess 'GLOBAL_INTERNET', got %s", result.InternetAccess) + } + + if result.MaxDevice != 10 { + t.Errorf("Expected MaxDevice 10, got %d", result.MaxDevice) + } + + if !result.AllRegionsIncluded { + t.Error("Expected AllRegionsIncluded to be true") + } + + if len(result.VpnRegionIDs) != 3 { + t.Errorf("Expected 3 VPN regions, got %d", len(result.VpnRegionIDs)) + } +}