diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 9219c12..0e555db 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -1,16 +1,31 @@ -name: "CodeQL" +name: CodeQL on: push: - branches: ["main"] + branches: + - main + paths: + - cmd + - pkg + - go.mod + - go.sum + - .github/workflows/codeql.yaml pull_request: - branches: ["main"] - schedule: - - cron: "42 5 * * 1" + branches: + - main + paths: + - cmd + - pkg + - go.mod + - go.sum + - .github/workflows/codeql.yaml + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: jobs: analyze: name: Analyze + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest permissions: actions: read @@ -18,7 +33,7 @@ jobs: security-events: write steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v3 - name: Initialize CodeQL @@ -26,10 +41,13 @@ jobs: with: languages: "go" - - name: Build code + - name: Build run: make cli - - name: Perform CodeQL Analysis + - name: Test + run: go test -v -race ./... + + - name: CodeQL Analysis uses: github/codeql-action/analyze@v2 with: category: "/language:go" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4e5735c..8d542c6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -47,7 +47,7 @@ jobs: - name: Install Go dependencies run: go mod download - - name: Build application + - name: Build env: GOOS: ${{ matrix.os }} GOARCH: ${{ matrix.arch }} @@ -55,6 +55,15 @@ jobs: LDFLAGS: -s -w -X github.com/arvancloud/cdn-go/internal/pkg/version.version=${{ steps.slug.outputs.version }} run: go build -trimpath -ldflags "$LDFLAGS" -o "./${{ steps.values.outputs.binary-name }}" cmd/*.go + - name: Test + run: go test -v -race ./... -json > TestResults.json + + - name: Upload test results + uses: actions/upload-artifact@v3 + with: + name: Test-results + path: TestResults.json + - name: Run UPX uses: crazy-max/ghaction-upx@v2 with: @@ -63,7 +72,7 @@ jobs: ./${{ steps.values.outputs.binary-name }} args: --best --lzma - - name: Upload binaries to release + - name: Upload binaries uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ github.token }} @@ -99,7 +108,7 @@ jobs: id: date run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - name: Build & Push Docker image + - name: Build & Push image uses: docker/build-push-action@v4 with: context: . @@ -113,7 +122,7 @@ jobs: r1cloud/cdn:${{ steps.slug.outputs.version }} r1cloud/cdn:latest - - name: Run Trivy vulnerability scanner + - name: Run Trivy uses: aquasecurity/trivy-action@master with: image-ref: r1cloud/cdn:latest @@ -125,7 +134,7 @@ jobs: template: "@/contrib/sarif.tpl" output: "trivy-results.sarif" - - name: Upload Trivy scan results to GitHub Security tab + - name: Upload Trivy scan results uses: github/codeql-action/upload-sarif@v2 with: sarif_file: "trivy-results.sarif" diff --git a/.vscode/settings.json b/.vscode/settings.json index 802d64f..96ada72 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,13 @@ { - "cSpell.words": ["ANAME", "gcli", "gookit", "iodef", "issuewild", "TLSA"] + "cSpell.words": [ + "ANAME", + "backoff", + "gcli", + "gookit", + "iodef", + "issuewild", + "stretchr", + "TLSA", + "unmarshalling" + ] } diff --git a/pkg/arvancloud_test.go b/pkg/arvancloud_test.go new file mode 100644 index 0000000..a0d0121 --- /dev/null +++ b/pkg/arvancloud_test.go @@ -0,0 +1,73 @@ +package arvancloud + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + // HTTP request multiplexer + mux *http.ServeMux + + // API client for test + client *API + + // HTTP server used for mocking + server *httptest.Server +) + +func setup(opts ...Option) { + // Create a test server + mux = http.NewServeMux() + server = httptest.NewServer(mux) + + // Disable rate limits and retries for test purpose + opts = append([]Option{UsingRateLimit(100000), UsingRetryPolicy(0, 0, 0)}, opts...) + + // Configure client + client, _ = New("deadbeef", opts...) + client.BaseURL = server.URL +} + +func teardown() { + server.Close() +} + +func TestHeaders(t *testing.T) { + // Default + setup() + mux.HandleFunc("/domains/"+testDomain+"/dns-records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + teardown() + + // Override defaults + headers := make(http.Header) + headers.Set("Content-Type", "application/graphql") + headers.Add("H1", "V1") + headers.Add("H2", "V2") + setup(Headers(headers)) + mux.HandleFunc("/domains/"+testDomain+"/dns-records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "application/graphql", r.Header.Get("Content-Type")) + assert.Equal(t, "V1", r.Header.Get("H1")) + assert.Equal(t, "V2", r.Header.Get("H2")) + }) + teardown() + + // Authentication + setup() + client, err := New("TEST_TOKEN") + assert.NoError(t, err) + client.BaseURL = server.URL + mux.HandleFunc("/domains/"+testDomain+"/dns-records", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "Bearer TEST_TOKEN", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + teardown() +} diff --git a/pkg/consts.go b/pkg/consts.go new file mode 100644 index 0000000..85fc94b --- /dev/null +++ b/pkg/consts.go @@ -0,0 +1,6 @@ +package arvancloud + +const ( + // Testing + testDomain = "test.ir" +) diff --git a/pkg/dns_entity.go b/pkg/dns_entity.go index ee14419..6b1ab8e 100644 --- a/pkg/dns_entity.go +++ b/pkg/dns_entity.go @@ -2,19 +2,17 @@ package arvancloud // DSNRecord is a DSN record structure for a domain type DNSRecord struct { - ID string `json:"id,omitempty"` - Type string `json:"type,omitempty"` - Name string `json:"name,omitempty"` - Value interface{} `json:"value,omitempty"` - TTL int `json:"ttl,omitempty"` - Cloud bool `json:"cloud,omitempty"` - UpstreamHTTPS string `json:"upstream_https,omitempty"` - IPFilterMode interface{} `json:"ip_filter_mode,omitempty"` - IsProtected bool `json:"is_protected,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - MonitoringStatus string `json:"monitoring_status,omitempty"` - HealthCheck interface{} `json:"health_check,omitempty"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value interface{} `json:"value,omitempty"` + TTL int `json:"ttl,omitempty"` + Cloud bool `json:"cloud,omitempty"` + UpstreamHTTPS string `json:"upstream_https,omitempty"` + IPFilterMode interface{} `json:"ip_filter_mode,omitempty"` + IsProtected bool `json:"is_protected,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } // DNSRecord_Response is response structure contains diff --git a/pkg/dns_test.go b/pkg/dns_test.go new file mode 100644 index 0000000..f29c94a --- /dev/null +++ b/pkg/dns_test.go @@ -0,0 +1,523 @@ +package arvancloud + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDNSRecord(t *testing.T) { + setup() + defer teardown() + + input := CreateDNSRecordParams{ + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.4", + "port": 1.0, + "weight": 10.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.8", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + }, + TTL: 120, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + var p CreateDNSRecordParams + err := json.NewDecoder(r.Body).Decode(&p) + require.NoError(t, err) + assert.Equal(t, input, p) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.4", + "port": 1, + "weight": 10, + "country": "" + }, + { + "ip": "5.6.7.8", + "port": 2, + "weight": 20, + "country": "" + } + ], + "ttl": 120, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T08:57:51+00:00" + }, + "message": "DNS record created successfully" + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records", handler) + + want := &CreateDNSRecord_Response{ + Data: map[string]interface{}{ + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": input.Type, + "name": input.Name, + "value": input.Value, + "ttl": float64(input.TTL), + "cloud": false, + "upstream_https": input.UpstreamHTTPS, + "ip_filter_mode": input.IPFilterMode, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T08:57:51+00:00", + }, + Message: "DNS record created successfully", + } + + _, err := client.CreateDNSRecord(context.Background(), ResourceDomain(""), CreateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDomain) + + actual, err := client.CreateDNSRecord(context.Background(), ResourceDomain(testDomain), input) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestGetDNSRecord(t *testing.T) { + setup() + defer teardown() + + recordID := "714009ff-a43c-43c5-80e2-0b3ffc1344a4" + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.4", + "port": 1, + "weight": 10, + "country": "" + }, + { + "ip": "5.6.7.8", + "port": 2, + "weight": 20, + "country": "" + } + ], + "ttl": 120, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T08:57:51+00:00" + } + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records/"+recordID, handler) + + want := &DNSRecord{ + ID: recordID, + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.4", + "port": 1.0, + "weight": 10.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.8", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + }, + TTL: 120, + Cloud: false, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + CreatedAt: "2023-03-31T08:57:51+00:00", + UpdatedAt: "2023-03-31T08:57:51+00:00", + } + + _, err := client.GetDNSRecord(context.Background(), ResourceDomain(""), recordID) + assert.ErrorIs(t, err, ErrMissingDomain) + + _, err = client.GetDNSRecord(context.Background(), ResourceDomain(testDomain), "") + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.GetDNSRecord(context.Background(), ResourceDomain(testDomain), recordID) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestListDNSRecords(t *testing.T) { + setup() + defer teardown() + + input := ListDNSRecordsParams{ + Search: "b", + Type: "A", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, input.Search, r.URL.Query().Get("search")) + assert.Equal(t, input.Type, r.URL.Query().Get("type")) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": [ + { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.4", + "port": 1, + "weight": 10, + "country": "" + }, + { + "ip": "5.6.7.8", + "port": 2, + "weight": 20, + "country": "" + } + ], + "ttl": 180, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T15:41:26+00:00" + }, + { + "id": "ee29f172-0b0d-4f2d-ba12-9587291936e3", + "type": "a", + "name": "b", + "value": [ + { + "ip": "3.1.8.1", + "port": 80, + "weight": 100, + "country": "" + }, + { + "ip": "4.8.1.2", + "port": 80, + "weight": 100, + "country": "" + } + ], + "ttl": 120, + "cloud": false, + "upstream_https": "https", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "created_at": "2023-03-31T11:40:15+00:00", + "updated_at": "2023-03-31T11:40:15+00:00" + } + ], + "links": { + "first": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records?page=1", + "last": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [ + { + "url": null, + "label": "Previous", + "active": false + }, + { + "url": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records?page=1", + "label": "1", + "active": true + }, + { + "url": null, + "label": "Next", + "active": false + } + ], + "path": "https://napi.arvancloud.ir/4.0/domains/wkmag.ir/dns-records", + "per_page": 300, + "to": 2, + "total": 2 + } + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records", handler) + + want := []DNSRecord{{ + ID: "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.4", + "port": 1.0, + "weight": 10.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.8", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + }, + TTL: 180, + Cloud: false, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + CreatedAt: "2023-03-31T08:57:51+00:00", + UpdatedAt: "2023-03-31T15:41:26+00:00", + }, + { + ID: "ee29f172-0b0d-4f2d-ba12-9587291936e3", + Type: "a", + Name: "b", + Value: []interface{}{ + map[string]interface{}{ + "ip": "3.1.8.1", + "port": 80.0, + "weight": 100.0, + "country": "", + }, + map[string]interface{}{ + "ip": "4.8.1.2", + "port": 80.0, + "weight": 100.0, + "country": "", + }, + }, + TTL: 120, + Cloud: false, + UpstreamHTTPS: "https", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + CreatedAt: "2023-03-31T11:40:15+00:00", + UpdatedAt: "2023-03-31T11:40:15+00:00", + }, + } + + _, err := client.ListDNSRecords(context.Background(), ResourceDomain(""), ListDNSRecordsParams{}) + assert.ErrorIs(t, err, ErrMissingDomain) + + actual, err := client.ListDNSRecords(context.Background(), ResourceDomain(testDomain), input) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestUpdateDNSRecord(t *testing.T) { + setup() + defer teardown() + + recordID := "714009ff-a43c-43c5-80e2-0b3ffc1344a4" + + input := UpdateDNSRecordParams{ + Type: "a", + Name: "@", + Value: []interface{}{ + map[string]interface{}{ + "ip": "1.2.3.5", + "port": 2.0, + "weight": 20.0, + "country": "", + }, + map[string]interface{}{ + "ip": "5.6.7.9", + "port": 3.0, + "weight": 30.0, + "country": "", + }, + }, + TTL: 180, + UpstreamHTTPS: "http", + IPFilterMode: map[string]interface{}{ + "count": "multi", + "order": "weighted", + "geo_filter": "none", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + var p UpdateDNSRecordParams + err := json.NewDecoder(r.Body).Decode(&p) + require.NoError(t, err) + assert.Equal(t, input, p) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "data": { + "id": "714009ff-a43c-43c5-80e2-0b3ffc1344a4", + "type": "a", + "name": "@", + "value": [ + { + "ip": "1.2.3.5", + "port": 2, + "weight": 20, + "country": "" + }, + { + "ip": "5.6.7.9", + "port": 3, + "weight": 30, + "country": "" + } + ], + "ttl": 180, + "cloud": false, + "upstream_https": "http", + "ip_filter_mode": { + "count": "multi", + "order": "weighted", + "geo_filter": "none" + }, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T15:41:26+00:00" + }, + "message": "DNS record updated" + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records/"+recordID, handler) + + want := &UpdateDNSRecord_Response{ + Data: map[string]interface{}{ + "id": recordID, + "type": input.Type, + "name": input.Name, + "value": input.Value, + "ttl": float64(input.TTL), + "cloud": false, + "upstream_https": input.UpstreamHTTPS, + "ip_filter_mode": input.IPFilterMode, + "is_protected": false, + "created_at": "2023-03-31T08:57:51+00:00", + "updated_at": "2023-03-31T15:41:26+00:00", + }, + Message: "DNS record updated", + } + + _, err := client.UpdateDNSRecord(context.Background(), ResourceDomain(""), recordID, UpdateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDomain) + + _, err = client.UpdateDNSRecord(context.Background(), ResourceDomain(testDomain), "", UpdateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.UpdateDNSRecord(context.Background(), ResourceDomain(testDomain), recordID, input) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestDeleteDNSRecord(t *testing.T) { + setup() + defer teardown() + + recordID := "714009ff-a43c-43c5-80e2-0b3ffc1344a4" + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "message": "DNS record deleted" + }`) + } + + mux.HandleFunc("/domains/"+testDomain+"/dns-records/"+recordID, handler) + + want := &DeleteDNSRecord_Response{ + Message: "DNS record deleted", + } + + _, err := client.DeleteDNSRecord(context.Background(), ResourceDomain(""), recordID) + assert.ErrorIs(t, err, ErrMissingDomain) + + _, err = client.DeleteDNSRecord(context.Background(), ResourceDomain(testDomain), "") + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.DeleteDNSRecord(context.Background(), ResourceDomain(testDomain), recordID) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} diff --git a/pkg/entity.go b/pkg/entity.go index a2abe4d..3799392 100644 --- a/pkg/entity.go +++ b/pkg/entity.go @@ -3,3 +3,9 @@ package arvancloud type Resource struct { Domain string } + +func ResourceDomain(domain string) Resource { + return Resource{ + Domain: domain, + } +} diff --git a/pkg/options_test.go b/pkg/options_test.go new file mode 100644 index 0000000..2f3d2df --- /dev/null +++ b/pkg/options_test.go @@ -0,0 +1,45 @@ +package arvancloud + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" +) + +func TestParseOptions(t *testing.T) { + headers := make(http.Header) + headers.Add("H1", "V1") + headers.Add("H2", "V2") + headers.Add("H3", "V3") + + api, err := New( + "TEST_TOKEN", + Debug(true), + UserAgent("UA-test"), + Headers(headers), + UsingRateLimit(1.5), + UsingRetryPolicy(1, 2, 3), + ) + + if err != nil { + t.Errorf("Error ocurred: %v", err.Error()) + } + + assert.Equal(t, true, api.Debug) + + assert.Equal(t, "UA-test", api.UserAgent) + + assert.Equal(t, "V1", api.headers.Get("H1")) + assert.Equal(t, "V2", api.headers.Get("H2")) + assert.Equal(t, "V3", api.headers.Get("H3")) + + assert.Equal(t, 1, api.rateLimiter.Burst()) + assert.Equal(t, rate.Limit(1.5), api.rateLimiter.Limit()) + + assert.Equal(t, 1, api.retryPolicy.MaxRetries) + assert.Equal(t, time.Duration(2000000000), api.retryPolicy.MinRetryDelay) + assert.Equal(t, time.Duration(3000000000), api.retryPolicy.MaxRetryDelay) +}