diff --git a/pkg/siteshield/errors.go b/pkg/siteshield/errors.go new file mode 100644 index 00000000..fb1ae26f --- /dev/null +++ b/pkg/siteshield/errors.go @@ -0,0 +1,75 @@ +package siteshield + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" +) + +var ( + // ErrBadRequest is returned when a required parameter is missing + ErrBadRequest = errors.New("missing argument") +) + +type ( + // Error is a appsec error interface + Error struct { + Type string `json:"type"` + Title string `json:"title"` + Detail string `json:"detail"` + Instance string `json:"instance,omitempty"` + BehaviorName string `json:"behaviorName,omitempty"` + ErrorLocation string `json:"errorLocation,omitempty"` + StatusCode int `json:"-"` + } +) + +// Error parses an error from the response +func (s *siteshieldmap) Error(r *http.Response) error { + var e Error + + var body []byte + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + s.Log(r.Request.Context()).Errorf("reading error response body: %s", err) + e.StatusCode = r.StatusCode + e.Title = fmt.Sprintf("Failed to read error body") + e.Detail = err.Error() + return &e + } + + if err := json.Unmarshal(body, &e); err != nil { + s.Log(r.Request.Context()).Errorf("could not unmarshal API error: %s", err) + e.Title = fmt.Sprintf("Failed to unmarshal error body") + e.Detail = err.Error() + } + + e.StatusCode = r.StatusCode + + return &e +} + +func (e *Error) Error() string { + return fmt.Sprintf("Title: %s; Type: %s; Detail: %s", e.Title, e.Type, e.Detail) +} + +// Is handles error comparisons +func (e *Error) Is(target error) bool { + var t *Error + if !errors.As(target, &t) { + return false + } + + if e == t { + return true + } + + if e.StatusCode != t.StatusCode { + return false + } + + return e.Error() == t.Error() +} diff --git a/pkg/siteshield/errors_test.go b/pkg/siteshield/errors_test.go new file mode 100644 index 00000000..23084865 --- /dev/null +++ b/pkg/siteshield/errors_test.go @@ -0,0 +1,68 @@ +package siteshield + +import ( + "context" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v2/pkg/session" + "github.com/stretchr/testify/require" + "github.com/tj/assert" +) + +func TestNewError(t *testing.T) { + sess, err := session.New() + require.NoError(t, err) + + req, err := http.NewRequestWithContext( + context.TODO(), + http.MethodHead, + "/", + nil) + require.NoError(t, err) + + tests := map[string]struct { + response *http.Response + expected *Error + }{ + "valid response, status code 500": { + response: &http.Response{ + Status: "Internal Server Error", + StatusCode: http.StatusInternalServerError, + Body: ioutil.NopCloser(strings.NewReader( + `{"type":"a","title":"b","detail":"c"}`), + ), + Request: req, + }, + expected: &Error{ + Type: "a", + Title: "b", + Detail: "c", + StatusCode: http.StatusInternalServerError, + }, + }, + "invalid response body, assign status code": { + response: &http.Response{ + Status: "Internal Server Error", + StatusCode: http.StatusInternalServerError, + Body: ioutil.NopCloser(strings.NewReader( + `test`), + ), + Request: req, + }, + expected: &Error{ + Title: "Failed to unmarshal error body", + Detail: "invalid character 'e' in literal true (expecting 'r')", + StatusCode: http.StatusInternalServerError, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + res := Client(sess).(*siteshieldmap).Error(test.response) + assert.Equal(t, test.expected, res) + }) + } +} diff --git a/pkg/siteshield/siteshield.go b/pkg/siteshield/siteshield.go new file mode 100644 index 00000000..28944f0d --- /dev/null +++ b/pkg/siteshield/siteshield.go @@ -0,0 +1,43 @@ +// Package siteshield provides access to the Akamai Site Shield APIs +package siteshield + +import ( + "errors" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v2/pkg/session" +) + +var ( + // ErrStructValidation is returned returned when given struct validation failed + ErrStructValidation = errors.New("struct validation") +) + +type ( + // SSMAPS is the siteshieldmap api interface + SSMAPS interface { + SiteShieldMap + } + + siteshieldmap struct { + session.Session + usePrefixes bool + } + + // Option defines a siteshieldmap option + Option func(*siteshieldmap) + + // ClientFunc is a siteshieldmap client new method, this can used for mocking + ClientFunc func(sess session.Session, opts ...Option) SSMAPS +) + +// Client returns a new siteshieldmap Client instance with the specified controller +func Client(sess session.Session, opts ...Option) SSMAPS { + s := &siteshieldmap{ + Session: sess, + } + + for _, opt := range opts { + opt(s) + } + return s +} diff --git a/pkg/siteshield/siteshield_map.go b/pkg/siteshield/siteshield_map.go new file mode 100644 index 00000000..8f2e596a --- /dev/null +++ b/pkg/siteshield/siteshield_map.go @@ -0,0 +1,161 @@ +package siteshield + +import ( + "context" + "fmt" + "net/http" + + validation "github.com/go-ozzo/ozzo-validation" +) + +// SiteShieldMap represents a collection of Site Shield +// +// See: SiteShieldMap.GetSiteShieldMaps() +// API Docs: // site_shield v1 +// +// https://developer.akamai.com/api/cloud_security/site_shield/v1.html + +type ( + // SiteShieldMap contains operations available on SiteShieldMap resource + // See: // site_shield v1 + // + // https://developer.akamai.com/api/cloud_security/site_shield/v1.html#getamap + SiteShieldMap interface { + GetSiteShieldMaps(ctx context.Context) (*GetSiteShieldMapsResponse, error) + GetSiteShieldMap(ctx context.Context, params SiteShieldMapRequest) (*SiteShieldMapResponse, error) + AckSiteShieldMap(ctx context.Context, params SiteShieldMapRequest) (*SiteShieldMapResponse, error) + } + + SiteShieldMapRequest struct { + UniqueID int + } + + GetSiteShieldMapsResponse struct { + SiteShieldMaps []SiteShieldMapResponse `json:"siteShieldMaps"` + } + + SiteShieldMapResponse struct { + Acknowledged bool `json:"acknowledged"` + Contacts []string `json:"contacts"` + CurrentCidrs []string `json:"currentCidrs"` + ProposedCidrs []string `json:"proposedCidrs"` + RuleName string `json:"ruleName"` + Type string `json:"type"` + Service string `json:"service"` + Shared bool `json:"shared"` + AcknowledgeRequiredBy int64 `json:"acknowledgeRequiredBy"` + PreviouslyAcknowledgedOn int64 `json:"previouslyAcknowledgedOn"` + ID int `json:"id,omitempty"` + LatestTicketID int `json:"latestTicketId,omitempty"` + MapAlias string `json:"mapAlias,omitempty"` + McmMapRuleID int `json:"mcmMapRuleId,omitempty"` + } +) + +// Validate validates SiteShieldMapRequest +func (v SiteShieldMapRequest) Validate() error { + return validation.Errors{ + "UniqueID": validation.Validate(v.UniqueID, validation.Required), + }.Filter() +} + +// GetSiteShieldMaps will get a list of SiteShieldMap. +// +// API Docs: // site_shield v1 +// +// https://developer.akamai.com/api/cloud_security/site_shield/v1.html#listmaps + +func (s *siteshieldmap) GetSiteShieldMaps(ctx context.Context) (*GetSiteShieldMapsResponse, error) { + logger := s.Log(ctx) + logger.Debug("GetSiteShieldMaps") + + var rval GetSiteShieldMapsResponse + + uri := "/siteshield/v1/maps" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("failed to create getSiteShieldMaps request: %s", err.Error()) + } + + resp, err := s.Exec(req, &rval) + if err != nil { + return nil, fmt.Errorf("getsiteshieldmaps request failed: %s", err.Error()) + } + + if resp.StatusCode != http.StatusOK { + return nil, s.Error(resp) + } + + return &rval, nil + +} + +// GetSiteShieldMap will get a SiteShieldMap by unique ID. +// +// API Docs: // site_shield v1 +// +// https://developer.akamai.com/api/cloud_security/site_shield/v1.html#getamap + +func (s *siteshieldmap) GetSiteShieldMap(ctx context.Context, params SiteShieldMapRequest) (*SiteShieldMapResponse, error) { + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%w: %s", ErrStructValidation, err.Error()) + } + + logger := s.Log(ctx) + logger.Debug("GetSiteShieldMap") + + var rval SiteShieldMapResponse + + uri := fmt.Sprintf("/siteshield/v1/maps/%d", params.UniqueID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, fmt.Errorf("failed to create getSiteShieldMap request: %s", err.Error()) + } + + resp, err := s.Exec(req, &rval) + if err != nil { + return nil, fmt.Errorf("getSiteShieldMap request failed: %s", err.Error()) + } + + if resp.StatusCode != http.StatusOK { + return nil, s.Error(resp) + } + + return &rval, nil +} + +// AckSiteShieldMap will acknowledge changes to a SiteShieldMap. +// +// API Docs: // site_shield v1 +// +// https://developer.akamai.com/api/cloud_security/site_shield/v1.html#acknowledgeamap + +func (s *siteshieldmap) AckSiteShieldMap(ctx context.Context, params SiteShieldMapRequest) (*SiteShieldMapResponse, error) { + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("%w: %s", ErrStructValidation, err.Error()) + } + + logger := s.Log(ctx) + logger.Debug("AckSiteShieldMap") + + postURL := fmt.Sprintf("/siteshield/v1/maps/%d/acknowledge", params.UniqueID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, postURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create AckSiteShieldMap: %s", err.Error()) + } + + var rval SiteShieldMapResponse + resp, err := s.Exec(req, &rval, params) + if err != nil { + return nil, fmt.Errorf("AckSiteShieldMap request failed: %s", err.Error()) + } + + if resp.StatusCode != http.StatusOK { + return nil, s.Error(resp) + } + + return &rval, nil +} diff --git a/pkg/siteshield/siteshield_map_test.go b/pkg/siteshield/siteshield_map_test.go new file mode 100644 index 00000000..7bc046c8 --- /dev/null +++ b/pkg/siteshield/siteshield_map_test.go @@ -0,0 +1,222 @@ +package siteshield + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v2/pkg/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSiteShield_ListSiteShieldMaps(t *testing.T) { + + result := GetSiteShieldMapsResponse{} + + respData := compactJSON(loadFixtureBytes("testdata/TestSiteShield/SiteShieldMaps.json")) + json.Unmarshal([]byte(respData), &result) + + tests := map[string]struct { + responseStatus int + responseBody string + expectedPath string + expectedResponse *GetSiteShieldMapsResponse + withError error + headers http.Header + }{ + "200 OK": { + headers: http.Header{ + "Content-Type": []string{"application/json"}, + }, + responseStatus: http.StatusOK, + responseBody: respData, + expectedPath: "/siteshield/v1/maps", + expectedResponse: &result, + }, + "500 internal server error": { + headers: http.Header{}, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "detail": "Error fetching siteshieldmap", + "status": 500 +}`, + expectedPath: "/siteshield/v1/maps", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Detail: "Error fetching siteshieldmap", + StatusCode: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.GetSiteShieldMaps( + session.ContextWithOptions( + context.Background(), + session.WithContextHeaders(test.headers), + ), + ) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +// Test SiteShieldMap +func TestSiteShield_GetSiteShieldMap(t *testing.T) { + + result := SiteShieldMapResponse{} + + respData := compactJSON(loadFixtureBytes("testdata/TestSiteShield/SiteShieldMap.json")) + json.Unmarshal([]byte(respData), &result) + + tests := map[string]struct { + params SiteShieldMapRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *SiteShieldMapResponse + withError error + }{ + "200 OK": { + params: SiteShieldMapRequest{UniqueID: 1234}, + responseStatus: http.StatusOK, + responseBody: respData, + expectedPath: "/siteshield/v1/maps/1234", + expectedResponse: &result, + }, + "500 internal server error": { + params: SiteShieldMapRequest{UniqueID: 1234}, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "detail": "Error fetching siteshieldmap" +}`, + expectedPath: "/siteshield/v1/maps/1234", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Detail: "Error fetching siteshieldmap", + StatusCode: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, test.expectedPath, r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + w.WriteHeader(test.responseStatus) + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + })) + client := mockAPIClient(t, mockServer) + result, err := client.GetSiteShieldMap(context.Background(), test.params) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} + +// Test Acknowledgement SiteShieldMap +func TestSiteShield_Acknowledgement(t *testing.T) { + + result := SiteShieldMapResponse{} + + respData := compactJSON(loadFixtureBytes("testdata/TestSiteShield/SiteShieldMap.json")) + json.Unmarshal([]byte(respData), &result) + + req := SiteShieldMapRequest{} + + reqData := compactJSON(loadFixtureBytes("testdata/TestSiteShield/SiteShieldMap.json")) + json.Unmarshal([]byte(reqData), &req) + + tests := map[string]struct { + params SiteShieldMapRequest + prop *SiteShieldMapRequest + responseStatus int + responseBody string + expectedPath string + expectedResponse *SiteShieldMapResponse + withError error + headers http.Header + }{ + "200 OK": { + params: SiteShieldMapRequest{UniqueID: 1234}, + responseStatus: http.StatusOK, + responseBody: respData, + expectedResponse: &result, + expectedPath: "/siteshield/v1/maps/1234/acknowledge", + }, + "500 internal server error": { + params: SiteShieldMapRequest{UniqueID: 1234}, + responseStatus: http.StatusInternalServerError, + responseBody: ` +{ + "type": "internal_error", + "title": "Internal Server Error", + "detail": "Error creating siteshieldmap" +}`, + expectedPath: "/siteshield/v1/maps/1234/acknowledge", + withError: &Error{ + Type: "internal_error", + Title: "Internal Server Error", + Detail: "Error creating siteshieldmap", + StatusCode: http.StatusInternalServerError, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + mockServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, test.expectedPath, r.URL.String()) + w.WriteHeader(test.responseStatus) + if len(test.responseBody) > 0 { + _, err := w.Write([]byte(test.responseBody)) + assert.NoError(t, err) + } + })) + client := mockAPIClient(t, mockServer) + result, err := client.AckSiteShieldMap( + session.ContextWithOptions( + context.Background(), + session.WithContextHeaders(test.headers)), test.params) + if test.withError != nil { + assert.True(t, errors.Is(err, test.withError), "want: %s; got: %s", test.withError, err) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedResponse, result) + }) + } +} diff --git a/pkg/siteshield/siteshield_test.go b/pkg/siteshield/siteshield_test.go new file mode 100644 index 00000000..a70d0c27 --- /dev/null +++ b/pkg/siteshield/siteshield_test.go @@ -0,0 +1,89 @@ +package siteshield + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v2/pkg/edgegrid" + "github.com/akamai/AkamaiOPEN-edgegrid-golang/v2/pkg/session" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockAPIClient(t *testing.T, mockServer *httptest.Server) SSMAPS { + serverURL, err := url.Parse(mockServer.URL) + require.NoError(t, err) + certPool := x509.NewCertPool() + certPool.AddCert(mockServer.Certificate()) + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + }, + }, + } + s, err := session.New(session.WithClient(httpClient), session.WithSigner(&edgegrid.Config{Host: serverURL.Host})) + assert.NoError(t, err) + return Client(s) +} + +func dummyOpt() Option { + return func(*siteshieldmap) { + + } +} + +// loadFixtureBytes returns the entire contents of the given file as a byte slice +func loadFixtureBytes(path string) []byte { + contents, err := ioutil.ReadFile(path) + if err != nil { + panic(err) + } + return contents +} + +// compactJSON converts a JSON-encoded byte slice to a compact form (so our JSON fixtures can be readable) +func compactJSON(encoded []byte) string { + buf := bytes.Buffer{} + if err := json.Compact(&buf, encoded); err != nil { + panic(fmt.Sprintf("%s: %s", err, string(encoded))) + } + + return buf.String() +} + +func TestClient(t *testing.T) { + sess, err := session.New() + require.NoError(t, err) + tests := map[string]struct { + options []Option + expected *siteshieldmap + }{ + "no options provided, return default": { + options: nil, + expected: &siteshieldmap{ + Session: sess, + }, + }, + "dummy option": { + options: []Option{dummyOpt()}, + expected: &siteshieldmap{ + Session: sess, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + res := Client(sess, test.options...) + assert.Equal(t, res, test.expected) + }) + } +} diff --git a/pkg/siteshield/testdata/TestSiteShield/SiteShieldMap.json b/pkg/siteshield/testdata/TestSiteShield/SiteShieldMap.json new file mode 100644 index 00000000..f3fa7b20 --- /dev/null +++ b/pkg/siteshield/testdata/TestSiteShield/SiteShieldMap.json @@ -0,0 +1,21 @@ +{ + "acknowledged": false, + "contacts": [ + "test@akamai.com", + "test2@akamai.com" + ], + "currentCidrs": [ + "131.103.136.0/24", "131.103.137.0/24", + "165.254.127.0/24", "165.254.137.0/24", "184.25.254.0/24" + ], + "proposedCidrs": [ + "107.14.42.0/24", "117.103.188.0/24", "195.59.54.0/24", + "209.211.216.0/24", "216.246.75.0/24" + ], + "ruleName": "a;s36.akamai.net", + "type": "Production", + "service": "S", + "shared": false, + "acknowledgeRequiredBy": 1392154239000, + "previouslyAcknowledgedOn": 1392154239000 +} \ No newline at end of file diff --git a/pkg/siteshield/testdata/TestSiteShield/SiteShieldMaps.json b/pkg/siteshield/testdata/TestSiteShield/SiteShieldMaps.json new file mode 100644 index 00000000..7d61eed0 --- /dev/null +++ b/pkg/siteshield/testdata/TestSiteShield/SiteShieldMaps.json @@ -0,0 +1,67 @@ +{ + "siteShieldMaps": [ + { + "acknowledged": true, + "contacts": [ "kona@akamai.com" ], + "currentCidrs": [ + "165.254.26.0/24", "165.254.94.0/24", + "184.51.120.0/24", "63.162.234.0/24", + "63.233.110.0/24", "64.145.75.0/24", "69.31.102.0/24" + ], + "id": 1234, + "latestTicketId": 1, + "mapAlias": "SampleTest1", + "mcmMapRuleId": 123, + "proposedCidrs": [], + "ruleName": "a;s12.akamai.net", + "service": "W", + "shared": false, + "type": "Production" + }, + { + "acknowledged": true, + "contacts": [ + "luna@akamai.com", + "new@email.com" + ], + "currentCidrs": [ + "107.14.42.0/24", "117.103.188.0/24", + "195.59.54.0/24", "209.211.216.0/24", + "216.246.75.0/24", "63.130.161.0/24", + "63.235.29.0/24", "63.83.61.0/24", "69.22.163.0/24" + ], + "id": 7964, + "latestTicketId": 5884, + "mapAlias": "Sample Test-2", + "mcmMapRuleId": 957, + "proposedCidrs": [], + "ruleName": "e;s9.akamaiedge.net", + "service": "S", + "shared": false, + "type": "Production" + }, + { + "acknowledged": false, + "contacts": [ "pulse@akamai.com" ], + "currentCidrs": [], + "id": 65, + "latestTicketId": 883, + "mapAlias": "mapAlias", + "mcmMapRuleId": 4255, + "proposedCidrs": [ + "131.103.136.0/24", "131.103.137.0/24", "165.254.137.0/24", + "184.25.254.0/24", "184.51.199.0/24", "184.84.221.0/24", + "184.84.223.0/24", "195.10.11.0/24", "204.156.15.0/24", + "209.170.78.0/24", "216.246.75.0/24", "23.57.69.0/24", + "23.57.74.0/24", "23.62.238.0/24", "61.246.63.0/24", + "63.218.71.0/24", "66.171.227.0/24", "69.192.3.0/24", + "69.192.4.0/24", "72.246.184.0/24", "77.67.40.0/24", + "77.67.85.0/24", "80.157.149.0/24", "80.239.234.0/24" + ], + "ruleName": "e;s1.akamaiedge.net", + "service": "S", + "shared": false, + "type": "Production" + } + ] +} \ No newline at end of file