diff --git a/.gitattributes b/.gitattributes index e40dd0eae..e20a6ea4b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -29,3 +29,5 @@ go.sum text eol=lf *.jpg binary *.png *.svg + +pkg/cloudflare-go/.changelog text eol=crlf diff --git a/documentation/assets/providers/cloudflareapi/example-permissions-configuration.png b/documentation/assets/providers/cloudflareapi/example-permissions-configuration.png index cabb6076a..a145f6091 100644 Binary files a/documentation/assets/providers/cloudflareapi/example-permissions-configuration.png and b/documentation/assets/providers/cloudflareapi/example-permissions-configuration.png differ diff --git a/documentation/provider/cloudflareapi.md b/documentation/provider/cloudflareapi.md index b0da09c64..71a4fe46b 100644 --- a/documentation/provider/cloudflareapi.md +++ b/documentation/provider/cloudflareapi.md @@ -63,6 +63,8 @@ DNSControl requires the token to have the following permissions: * Add: Enable SSL controls (`Zone → SSL and Certificates → Edit`) * Editing Page Rules? * Add: Edit Page Rules (`Zone → Page Rules → Edit`) +* Creating Redirects? + * Add: Edit Dynamic Redirect (`Zone → Dynamic Redirect → Edit`) * Managing Cloudflare Workers? (if `manage_workers`: set to `true` or `CF_WORKER_ROUTE()` is in use.) * Add: Edit Worker Scripts (`Account → Workers Scripts → Edit`) * Add: Edit Worker Scripts (`Zone → Workers Routes → Edit`) @@ -200,6 +202,84 @@ If a domain does not exist in your Cloudflare account, DNSControl will automatically add it when `dnscontrol push` is executed. +## Old-style vs new-style redirects + +Old-style redirects uses the [Page Rules][https://developers.cloudflare.com/rules/page-rules/] product feature, which is [going away](https://developers.cloudflare.com/rules/reference/page-rules-migration/). In this mode, +`CF_REDIRECT` and `CF_TEMP_REDIRECT` functions generate Page Rules. + +Enable it using: + +```javascript +var DSP_CLOUDFLARE = NewDnsProvider("cloudflare", { + "manage_redirects": true +}); +``` + +New redirects uses the [Single Redirects][https://developers.cloudflare.com/rules/url-forwarding/] product feature. In this mode, +`CF_REDIRECT` and `CF_TEMP_REDIRECT` functions generates Single Redirects. + +Enable it using: + +```javascript +var DSP_CLOUDFLARE = NewDnsProvider("cloudflare", { + "manage_single_redirects": true +}); +``` + +{% hint style="warning" %} +New-style redirects ("Single Redirect Rules") are a new feature of DNSControl +as of v4.12.0 and may have bugs. Please test carefully. +{% endhint %} + + +Conversion mode: + +DNSControl can convert from old-style redirects (Page Rules) to new-style +redirect (Single Redirects). To enable this mode, set both `manage_redirects` +and `manage_single_redirects` to true. + +{% hint style="warning" %} +The conversion process only handles a few, very simple, patterns. +See `providers/cloudflare/singleredirect_test.go` for a list of patterns +supported. Please file bugs if you find problems. PRs welcome! +{% endhint %} + +In conversion mode, DNSControl takes `CF_REDIRECT`/`CF_TEMP_REDIRECT` +statements and turns each of them into two records: a Page Rules and an +equivalent Single Redirects rule. + +Cloudflare processes Single Redirects before Page Rules, thus it is safe to +have both at the same time, and provides an easy way to test the new-style +rules. If they do not work properly, use the Cloudflare web-based control +panel to manually delete the new-style rule to expose the old-style rule. (and +report the bug to DNSControl!) + +You'll find the new-style rule in the Cloudflare control panel. It will have +a very long name that includes the `CF_REDIRECT`/`CF_TEMP_REDIRECT` operands +plus matcher and replacement expressions. + +There is no mechanism to easily delete the old-style rules. Either delete them +manually using the Cloudflare control panel or wait for Cloudflare to remove +the old-style Page Rule feature. + +Once the conversion is complete, change +`manage_redirects` to `false` then either delete the old redirects +via the CloudFlare control panel or wait for Cloudflare to remove support for the old-style feature. + +{% hint style="warning" %} +Cloudflare's announcement says that they will convert old-style redirects (Page Rules) to new-style +redirect (Single Redirects) but they do not give a date for when this will happen. DNSControl +will probably see these new redirects as foreign and delete them. + +Therefore it is probably safer to do the conversion ahead of them. + +On the other hand, if you let them do the conversion, their conversion may be more correct +than DNSControl's. However there's no way for DNSControl to manage them since the automatically-generated name will be different. + +If you have suggestions on how to handle this better please file a bug. +{% endhint %} + + ## Redirects The Cloudflare provider can manage "Forwarding URL" Page Rules (redirects) for your domains. Simply use the `CF_REDIRECT` and `CF_TEMP_REDIRECT` functions to make redirects: diff --git a/go.mod b/go.mod index c42a4494f..05cf19d05 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.22.1 retract v4.8.0 +replace github.com/cloudflare/cloudflare-go => ./pkg/cloudflare-go + require google.golang.org/protobuf v1.34.1 // indirect require ( @@ -24,7 +26,7 @@ require ( github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.3 - github.com/cloudflare/cloudflare-go v0.96.0 + github.com/cloudflare/cloudflare-go v0.97.0 github.com/digitalocean/godo v1.116.0 github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c github.com/dnsimple/dnsimple-go v1.5.1 @@ -105,7 +107,7 @@ require ( github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-test/deep v1.0.3 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/gofrs/uuid v4.0.0+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -119,7 +121,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.6 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect diff --git a/go.sum b/go.sum index b63fde765..6bf3bae66 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,6 @@ github.com/centralnicgroup-opensource/rtldev-middleware-go-sdk/v4 v4.0.3/go.mod github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/cloudflare-go v0.96.0 h1:wd+qrnyw+C2eXUUujE6BzFEOREkEfoCvogpO5h33FxI= -github.com/cloudflare/cloudflare-go v0.96.0/go.mod h1:gLP9fJT8ROgRCjHNKxISNNKeU1JEg2yT5uPEEI8x9Ec= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -154,8 +152,8 @@ github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe h1:zn8tqiUbec4wR94o7Qj3LZCAT6uGobhEgnDRg6isG5U= github.com/gobwas/glob v0.2.4-0.20181002190808-e7a84e9525fe/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= @@ -227,8 +225,8 @@ github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVH github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM= -github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index f41066480..f0a9d4178 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -25,6 +25,7 @@ var endIdx = flag.Int("end", -1, "Test index to stop after") var verbose = flag.Bool("verbose", false, "Print corrections as you run them") var printElapsed = flag.Bool("elapsed", false, "Print elapsed time for each testgroup") var enableCFWorkers = flag.Bool("cfworkers", true, "Set false to disable CF worker tests") +var enableCFRedirectMode = flag.String("cfredirect", "", "cloudflare pagerule tests: default=page_rules, c=convert old to enw, n=new-style, o=none") func init() { testing.Init() @@ -65,11 +66,21 @@ func getProvider(t *testing.T) (providers.DNSServiceProvider, string, map[string // use this feature. Maybe because we didn't have the capabilities // feature at the time? if name == "CLOUDFLAREAPI" { + items := []string{} if *enableCFWorkers { - metadata = []byte(`{ "manage_redirects": true, "manage_workers": true }`) - } else { - metadata = []byte(`{ "manage_redirects": true }`) + items = append(items, `"manage_workers": true`) } + switch *enableCFRedirectMode { + case "": + items = append(items, `"manage_redirects": true`) + case "c": + items = append(items, `"manage_redirects": true`) + items = append(items, `"manage_single_redirects": true`) + case "n": + items = append(items, `"manage_single_redirects": true`) + case "o": + } + metadata = []byte(`{ ` + strings.Join(items, `, `) + ` }`) } provider, err := providers.CreateDNSProvider(name, cfg, metadata) @@ -1830,6 +1841,14 @@ func makeTests() []*TestGroup { // CLOUDFLAREAPI features + // CLOUDFLAREAPI: Redirects: + + // go test -v -verbose -provider CLOUDFLAREAPI // PAGE_RULEs + // go test -v -verbose -provider CLOUDFLAREAPI -cfredirect=c // Convert: Convert page rules to Single Redirect + // go test -v -verbose -provider CLOUDFLAREAPI -cfredirect=n // New: Convert old to new Single Redirect + // ProTip: Add this to just run this test: + // -start 59 -end 60 + testgroup("CF_REDIRECT", only("CLOUDFLAREAPI"), tc("redir", cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1")), @@ -1838,32 +1857,32 @@ func makeTests() []*TestGroup { // Removed these for speed. They tested if order matters, // which it doesn't seem to. Re-add if needed. - //clear(), - //tc("multipleA", - // cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), - // cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), - //), - //clear(), - //tc("multipleB", - // cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), - // cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), - //), - //tc("change1", - // cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), - // cfRedir("cnn.**current-domain-no-trailing**/*", "https://change.cnn.com/$1"), - //), - //tc("change1", - // cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), - // cfRedir("cablenews.**current-domain-no-trailing**/*", "https://change.cnn.com/$1"), - //), + clear(), + tc("multipleA", + cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), + cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), + ), + clear(), + tc("multipleB", + cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), + cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), + ), + tc("change1", + cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), + cfRedir("cnn.**current-domain-no-trailing**/*", "https://change.cnn.com/$1"), + ), + tc("change1", + cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), + cfRedir("cablenews.**current-domain-no-trailing**/*", "https://change.cnn.com/$1"), + ), - // TODO(tlim): Fix this test case. It is currently failing. - //clear(), - //tc("multiple3", - // cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), - // cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), - // cfRedir("nytimes.**current-domain-no-trailing**/*", "https://www.nytimes.com/$1"), - //), + // NB(tlim): This test case used to fail but mysteriously started working. + clear(), + tc("multiple3", + cfRedir("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), + cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), + cfRedir("nytimes.**current-domain-no-trailing**/*", "https://www.nytimes.com/$1"), + ), // Repeat the above tests using CF_TEMP_REDIR instead clear(), @@ -1888,14 +1907,23 @@ func makeTests() []*TestGroup { cfRedirTemp("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), cfRedirTemp("cablenews.**current-domain-no-trailing**/*", "https://change.cnn.com/$1"), ), - // TODO(tlim): Fix this test case: - //tc("tempmultiple3", - // cfRedirTemp("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), - // cfRedirTemp("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), - // cfRedirTemp("nytimes.**current-domain-no-trailing**/*", "https://www.nytimes.com/$1"), - //), + // NB(tlim): This test case used to fail but mysteriously started working. + tc("tempmultiple3", + cfRedirTemp("msnbc.**current-domain-no-trailing**/*", "https://msnbc.cnn.com/$1"), + cfRedirTemp("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1"), + cfRedirTemp("nytimes.**current-domain-no-trailing**/*", "https://www.nytimes.com/$1"), + ), ), + testgroup("CF_REDIRECT_CONVERT", + only("CLOUDFLAREAPI"), + tc("start301", cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1")), + tc("convert302", cfRedirTemp("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1")), + tc("convert301", cfRedir("cnn.**current-domain-no-trailing**/*", "https://www.cnn.com/$1")), + ), + + // CLOUDFLAREAPI: PROXY + testgroup("CF_PROXY A create", only("CLOUDFLAREAPI"), CfProxyOff(), clear(), diff --git a/models/record.go b/models/record.go index 7270207b4..b8c805233 100644 --- a/models/record.go +++ b/models/record.go @@ -39,6 +39,7 @@ import ( // CF_REDIRECT // CF_TEMP_REDIRECT // CF_WORKER_ROUTE +// CLOUDFLAREAPI_SINGLE_REDIRECT // CLOUDNS_WR // FRAME // IMPORT_TRANSFORM @@ -51,6 +52,8 @@ import ( // URL301 // WORKER_ROUTE // +// NOTE: All NEW record types should be prefixed with the provider name (Correct: CLOUDFLAREAPI_SINGLE_REDIRECT. Wrong: CF_REDIRECT) +// // Notes about the fields: // // Name: @@ -138,6 +141,30 @@ type RecordConfig struct { R53Alias map[string]string `json:"r53_alias,omitempty"` AzureAlias map[string]string `json:"azure_alias,omitempty"` UnknownTypeName string `json:"unknown_type_name,omitempty"` + + // Cloudflare-specific fields: + // When these are used, .target is set to a human-readable version (only to be used for display purposes). + CloudflareRedirect *CloudflareSingleRedirectConfig `json:"cloudflareapi_redirect,omitempty"` +} + +// CloudflareSingleRedirectConfig contains info about a Cloudflare Single Redirect. +// +// When these are used, .target is set to a human-readable version (only to be used for display purposes). +type CloudflareSingleRedirectConfig struct { + // + Code int `json:"code,omitempty"` // 301 or 302 + // PR == PageRule + PRDisplay string `json:"pr_display,omitempty"` // How is this displayed to the user + PRMatcher string `json:"pr_matcher,omitempty"` + PRReplacement string `json:"pr_replacement,omitempty"` + PRPriority int `json:"pr_priority,omitempty"` // Really an identifier for the rule. + // + // SR == SingleRedirect + SRDisplay string `json:"sr_display,omitempty"` // How is this displayed to the user + SRMatcher string `json:"sr_matcher,omitempty"` + SRReplacement string `json:"sr_replacement,omitempty"` + SRRRulesetID string `json:"sr_rulesetid,omitempty"` + SRRRulesetRuleID string `json:"sr_rulesetruleid,omitempty"` } // MarshalJSON marshals RecordConfig. diff --git a/pkg/cloudflare-go/.changelog/1001.txt b/pkg/cloudflare-go/.changelog/1001.txt new file mode 100644 index 000000000..8893559c6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1001.txt @@ -0,0 +1,3 @@ +```release-note:note +docs: add release notes +``` diff --git a/pkg/cloudflare-go/.changelog/1002.txt b/pkg/cloudflare-go/.changelog/1002.txt new file mode 100644 index 000000000..a601dd904 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1002.txt @@ -0,0 +1,3 @@ +```release-note:bug +rulesets: fix sni action parameter +``` diff --git a/pkg/cloudflare-go/.changelog/1003.txt b/pkg/cloudflare-go/.changelog/1003.txt new file mode 100644 index 000000000..2671840cc --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1003.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps github.com/urfave/cli/v2 from 2.11.0 to 2.11.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1004.txt b/pkg/cloudflare-go/.changelog/1004.txt new file mode 100644 index 000000000..3e5f12334 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1004.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +firewall_rule: automatically paginate `List` results unless `Page` and `PerPage` are provided +``` + +```release-note:enhancement +filter: automatically paginate `List` results unless `Page` and `PerPage` are provided +``` diff --git a/pkg/cloudflare-go/.changelog/1005.txt b/pkg/cloudflare-go/.changelog/1005.txt new file mode 100644 index 000000000..c6666ccca --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1005.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps github.com/golangci/golangci-lint from 1.47.1 to 1.47.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1006.txt b/pkg/cloudflare-go/.changelog/1006.txt new file mode 100644 index 000000000..334c03dba --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1006.txt @@ -0,0 +1,3 @@ +```release-note:bug +access_application: fix inability to set bool values to false +``` diff --git a/pkg/cloudflare-go/.changelog/1008.txt b/pkg/cloudflare-go/.changelog/1008.txt new file mode 100644 index 000000000..272bb6e8b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1008.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps github.com/golangci/golangci-lint from 1.47.2 to 1.47.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1010.txt b/pkg/cloudflare-go/.changelog/1010.txt new file mode 100644 index 000000000..df85b31f4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1010.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Add support to upload module workers +``` diff --git a/pkg/cloudflare-go/.changelog/1014.txt b/pkg/cloudflare-go/.changelog/1014.txt new file mode 100644 index 000000000..a9bbb904d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1014.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Add support for attaching a worker to a domain +``` diff --git a/pkg/cloudflare-go/.changelog/1016.txt b/pkg/cloudflare-go/.changelog/1016.txt new file mode 100644 index 000000000..753e4646b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1016.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +firewall_rule: fix double endpoint calls & moving towards common method signature +``` + +```release-note:enhancement +filter: fix double endpoint calls & moving towards common method signature +``` diff --git a/pkg/cloudflare-go/.changelog/1017.txt b/pkg/cloudflare-go/.changelog/1017.txt new file mode 100644 index 000000000..56bd30386 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1017.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +lockdown: automatically paginate `List` results unless `Page` and `PerPage` are provided +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1020.txt b/pkg/cloudflare-go/.changelog/1020.txt new file mode 100644 index 000000000..051b9e79d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1020.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps github.com/golangci/golangci-lint from 1.47.3 to 1.48.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1026.txt b/pkg/cloudflare-go/.changelog/1026.txt new file mode 100644 index 000000000..3a43ce2de --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1026.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers-tail: Add in support for Workers tail API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1027.txt b/pkg/cloudflare-go/.changelog/1027.txt new file mode 100644 index 000000000..4661228cd --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1027.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers-account-settings: Add in support for Workers account settings API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1028.txt b/pkg/cloudflare-go/.changelog/1028.txt new file mode 100644 index 000000000..f76d16ff3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1028.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +r2: Add in support for creating and deleting R2 buckets +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1030.txt b/pkg/cloudflare-go/.changelog/1030.txt new file mode 100644 index 000000000..7a5ff365a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1030.txt @@ -0,0 +1,3 @@ +```release-note:bug +tunnel_routes: Fix not removing route when it contains virtual network +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1031.txt b/pkg/cloudflare-go/.changelog/1031.txt new file mode 100644 index 000000000..3d039fe45 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1031.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers-subdomain: Add in support Workers Subdomain API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1032.txt b/pkg/cloudflare-go/.changelog/1032.txt new file mode 100644 index 000000000..f87cc851e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1032.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +certificate_packs: deprecate "custom" configuration for ACM everywhere +``` diff --git a/pkg/cloudflare-go/.changelog/1034.txt b/pkg/cloudflare-go/.changelog/1034.txt new file mode 100644 index 000000000..bd57aca22 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1034.txt @@ -0,0 +1,9 @@ +```release-note:enhancement +email_routing_destination: Adds support for the email routing destination API +``` +```release-note:enhancement +email_routing_rules: Adds support for the email routing rules API +``` +```release-note:enhancement +email_routing_settings: Adds support for the email routing settings API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1035.txt b/pkg/cloudflare-go/.changelog/1035.txt new file mode 100644 index 000000000..4de54bece --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1035.txt @@ -0,0 +1,3 @@ +```release-note:bug +r2: fix create bucket endpoint +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1036.txt b/pkg/cloudflare-go/.changelog/1036.txt new file mode 100644 index 000000000..a8a4d61cb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1036.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +rulesets: add support for `http_config_settings` phase and supporting actions +``` diff --git a/pkg/cloudflare-go/.changelog/1037.txt b/pkg/cloudflare-go/.changelog/1037.txt new file mode 100644 index 000000000..3159870ac --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1037.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps golang.org/x/tools/gopls from 0.9.1 to 0.9.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1038.txt b/pkg/cloudflare-go/.changelog/1038.txt new file mode 100644 index 000000000..23286c802 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1038.txt @@ -0,0 +1,9 @@ +```release-note:bug +email_routing_destination: Update API reference URLs +``` +```release-note:bug +email_routing_rules: Update API reference URLs +``` +```release-note:bug +email_routing_settings: Update API reference URLs +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1039.txt b/pkg/cloudflare-go/.changelog/1039.txt new file mode 100644 index 000000000..b98e8ddc9 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1039.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps golang.org/x/tools/gopls from 0.9.2 to 0.9.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1040.txt b/pkg/cloudflare-go/.changelog/1040.txt new file mode 100644 index 000000000..5f3402d35 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1040.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Support for multipart encoding for DownloadWorker on a module-format Worker script +``` diff --git a/pkg/cloudflare-go/.changelog/1042.txt b/pkg/cloudflare-go/.changelog/1042.txt new file mode 100644 index 000000000..ec8f7cac1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1042.txt @@ -0,0 +1,3 @@ +```release-note:dependency +provider: bumps github.com/urfave/cli/v2 from 2.11.1 to 2.11.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1043.txt b/pkg/cloudflare-go/.changelog/1043.txt new file mode 100644 index 000000000..68ab4727c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1043.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cloudflare: make it clear when the rate limit retries have been exhausted +``` diff --git a/pkg/cloudflare-go/.changelog/1044.txt b/pkg/cloudflare-go/.changelog/1044.txt new file mode 100644 index 000000000..f6a120705 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1044.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/tools/gopls from 0.9.3 to 0.9.4 +``` diff --git a/pkg/cloudflare-go/.changelog/1046.txt b/pkg/cloudflare-go/.changelog/1046.txt new file mode 100644 index 000000000..efe78ee9f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1046.txt @@ -0,0 +1,3 @@ +```release-note:bug +tunnel_configuration: Remove unnecessary double-unmarshalling due to changes in the API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1047.txt b/pkg/cloudflare-go/.changelog/1047.txt new file mode 100644 index 000000000..3b368ea25 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1047.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +errors: add some error type convenience functions for mocking and inspection +``` diff --git a/pkg/cloudflare-go/.changelog/1048.txt b/pkg/cloudflare-go/.changelog/1048.txt new file mode 100644 index 000000000..2e671e9a4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1048.txt @@ -0,0 +1,3 @@ +```release-note:bug +workers_test: Fix incorrect test from PR #1014 +``` diff --git a/pkg/cloudflare-go/.changelog/1049.txt b/pkg/cloudflare-go/.changelog/1049.txt new file mode 100644 index 000000000..30e387232 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1049.txt @@ -0,0 +1,3 @@ +```release-note:bug +workers_test: Use application/json mime-type in headers +``` diff --git a/pkg/cloudflare-go/.changelog/1051.txt b/pkg/cloudflare-go/.changelog/1051.txt new file mode 100644 index 000000000..817fe1bcd --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1051.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +pages_project: Add compatibility date and compatibility_flags to pages deployment configs +``` diff --git a/pkg/cloudflare-go/.changelog/1052.txt b/pkg/cloudflare-go/.changelog/1052.txt new file mode 100644 index 000000000..e974a16c3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1052.txt @@ -0,0 +1,3 @@ +```release-note:bug +zonelockdown: add `Priority` to `ZoneLockdownCreateParams` and `ZoneLockdownUpdateParams` +``` diff --git a/pkg/cloudflare-go/.changelog/1053.txt b/pkg/cloudflare-go/.changelog/1053.txt new file mode 100644 index 000000000..b57695a3e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1053.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_account: add support for `suppress_footer` +``` diff --git a/pkg/cloudflare-go/.changelog/1059.txt b/pkg/cloudflare-go/.changelog/1059.txt new file mode 100644 index 000000000..3b9017858 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1059.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +api_shield: add GET/PUT for API Shield Configuration +``` diff --git a/pkg/cloudflare-go/.changelog/1060.txt b/pkg/cloudflare-go/.changelog/1060.txt new file mode 100644 index 000000000..54ec6e02f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1060.txt @@ -0,0 +1,3 @@ +```release-note:bug +email_routing_settings: change enable endpoint from `enabled` to `enable` +``` diff --git a/pkg/cloudflare-go/.changelog/1063.txt b/pkg/cloudflare-go/.changelog/1063.txt new file mode 100644 index 000000000..e27d99dd1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1063.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +r2: Add support for listing R2 buckets +``` diff --git a/pkg/cloudflare-go/.changelog/1065.txt b/pkg/cloudflare-go/.changelog/1065.txt new file mode 100644 index 000000000..9dfbfcc4f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1065.txt @@ -0,0 +1,9 @@ +```release-note:enhancement +pages_project: Add `production_branch` field +``` +```release-note:enhancement +pages_project: Add `kv_namespaces`, `durable_object_namespaces`, `r2_buckets`, and `d1_databases` bindings to deployment config +``` +```release-note:enhancement +pages_project: Add `preview_deployment_setting`, `preview_branch_includes`, and `preview_branch_excludes` to source config +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1066.txt b/pkg/cloudflare-go/.changelog/1066.txt new file mode 100644 index 000000000..9e0282172 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1066.txt @@ -0,0 +1,3 @@ +```release-note:bug +stream: Update pctComplete to string from int +``` diff --git a/pkg/cloudflare-go/.changelog/1067.txt b/pkg/cloudflare-go/.changelog/1067.txt new file mode 100644 index 000000000..a560e4ad6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1067.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 3.0.0 to 3.1.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1068.txt b/pkg/cloudflare-go/.changelog/1068.txt new file mode 100644 index 000000000..fa7259c79 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1068.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +api: addded context and headers to Raw method +``` diff --git a/pkg/cloudflare-go/.changelog/1070.txt b/pkg/cloudflare-go/.changelog/1070.txt new file mode 100644 index 000000000..d3586c87e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1070.txt @@ -0,0 +1,3 @@ +```release-note:bug +email_routing_rules: Fix response for email routing catch all rule. +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1071.txt b/pkg/cloudflare-go/.changelog/1071.txt new file mode 100644 index 000000000..124a8d794 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1071.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +url_normalization_settings: Add APIs to get and update URL normalization settings +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1072.txt b/pkg/cloudflare-go/.changelog/1072.txt new file mode 100644 index 000000000..997e35070 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1072.txt @@ -0,0 +1,3 @@ +```release-note:bug +cloudflare: fix nil dereference error in makeRequestWithAuthTypeAndHeaders +``` diff --git a/pkg/cloudflare-go/.changelog/1073.txt b/pkg/cloudflare-go/.changelog/1073.txt new file mode 100644 index 000000000..54216744a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1073.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_account: add support for `os_distro_name` and `os_distro_revision` +``` diff --git a/pkg/cloudflare-go/.changelog/1074.txt b/pkg/cloudflare-go/.changelog/1074.txt new file mode 100644 index 000000000..1ecbf3228 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1074.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_service_token: add support for refreshing an existing token in place +``` diff --git a/pkg/cloudflare-go/.changelog/1075.txt b/pkg/cloudflare-go/.changelog/1075.txt new file mode 100644 index 000000000..28ef11693 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1075.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +auditlogs: add support for hide_user_logs filter parameter +``` diff --git a/pkg/cloudflare-go/.changelog/1077.txt b/pkg/cloudflare-go/.changelog/1077.txt new file mode 100644 index 000000000..1bd73e23c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1077.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.11.2 to 2.14.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1080.txt b/pkg/cloudflare-go/.changelog/1080.txt new file mode 100644 index 000000000..be2615869 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1080.txt @@ -0,0 +1,3 @@ +```release-note:bug +cloudflare: exiting closer to the source on context timeouts to improve error messaging and better defend from potential edge cases +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1081.txt b/pkg/cloudflare-go/.changelog/1081.txt new file mode 100644 index 000000000..f513a4d12 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1081.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.14.0 to 2.14.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1082.txt b/pkg/cloudflare-go/.changelog/1082.txt new file mode 100644 index 000000000..486297110 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1082.txt @@ -0,0 +1,3 @@ +```release-note:bug +origin certificate: Fix API auth type used +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1084.txt b/pkg/cloudflare-go/.changelog/1084.txt new file mode 100644 index 000000000..6599587b7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1084.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +load_balancing: update method signatures to match experimental conventions +``` diff --git a/pkg/cloudflare-go/.changelog/1085.txt b/pkg/cloudflare-go/.changelog/1085.txt new file mode 100644 index 000000000..bfc984fcb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1085.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.14.1 to 2.15.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1086.txt b/pkg/cloudflare-go/.changelog/1086.txt new file mode 100644 index 000000000..d62b6d6be --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1086.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.15.0 to 2.16.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1087.txt b/pkg/cloudflare-go/.changelog/1087.txt new file mode 100644 index 000000000..7a46321d6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1087.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rule: add input fields for linux OS +``` diff --git a/pkg/cloudflare-go/.changelog/1088.txt b/pkg/cloudflare-go/.changelog/1088.txt new file mode 100644 index 000000000..ac1759d7a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1088.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +stream: added metadata support +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1089.txt b/pkg/cloudflare-go/.changelog/1089.txt new file mode 100644 index 000000000..f0f86c220 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1089.txt @@ -0,0 +1,3 @@ +```release-note:bug +user-agent-blocking-rules: add missing managed_challenge validation and removed the deprecated whitelist one +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1090.txt b/pkg/cloudflare-go/.changelog/1090.txt new file mode 100644 index 000000000..10d1d303f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1090.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +devices_policy: Add support for additional device settings policies +``` diff --git a/pkg/cloudflare-go/.changelog/1091.txt b/pkg/cloudflare-go/.changelog/1091.txt new file mode 100644 index 000000000..3bef7aba3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1091.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +load_balancing: support adaptive_routing and location_strategy +``` diff --git a/pkg/cloudflare-go/.changelog/1093.txt b/pkg/cloudflare-go/.changelog/1093.txt new file mode 100644 index 000000000..b9ebd562f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1093.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +rulesets: add support for `sensitivity_level` to override all rule sensitivity +``` diff --git a/pkg/cloudflare-go/.changelog/1094.txt b/pkg/cloudflare-go/.changelog/1094.txt new file mode 100644 index 000000000..844eb36ff --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1094.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.16.3 to 2.17.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1095.txt b/pkg/cloudflare-go/.changelog/1095.txt new file mode 100644 index 000000000..aeab97d4a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1095.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +account_member: add support for domain scoped roles +``` + +```release-note:breaking-change +account_member: `CreateAccountMember` has been updated to accept a `CreateAccountMemberParams` struct instead of multiple parameters +``` diff --git a/pkg/cloudflare-go/.changelog/1097.txt b/pkg/cloudflare-go/.changelog/1097.txt new file mode 100644 index 000000000..b8d6ed8d0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1097.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.3.3 to 1.3.4 +``` diff --git a/pkg/cloudflare-go/.changelog/1102.txt b/pkg/cloudflare-go/.changelog/1102.txt new file mode 100644 index 000000000..4a7d41aae --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1102.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +waiting_room: add support for waiting room rules +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1103.txt b/pkg/cloudflare-go/.changelog/1103.txt new file mode 100644 index 000000000..887b42ade --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1103.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.17.1 to 2.19.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1104.txt b/pkg/cloudflare-go/.changelog/1104.txt new file mode 100644 index 000000000..f9b589d0f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1104.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access: add UI read-only field to organizations +``` diff --git a/pkg/cloudflare-go/.changelog/1105.txt b/pkg/cloudflare-go/.changelog/1105.txt new file mode 100644 index 000000000..a8631dec8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1105.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +total_tls: adds support for TotalTLS +``` diff --git a/pkg/cloudflare-go/.changelog/1106.txt b/pkg/cloudflare-go/.changelog/1106.txt new file mode 100644 index 000000000..da158169d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1106.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cloudflare: expose `Messages` from the `Response` object +``` diff --git a/pkg/cloudflare-go/.changelog/1108.txt b/pkg/cloudflare-go/.changelog/1108.txt new file mode 100644 index 000000000..76a58babe --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1108.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.19.2 to 2.20.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1111.txt b/pkg/cloudflare-go/.changelog/1111.txt new file mode 100644 index 000000000..330d7370e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1111.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp: Adds support for DLP resources +``` diff --git a/pkg/cloudflare-go/.changelog/1112.txt b/pkg/cloudflare-go/.changelog/1112.txt new file mode 100644 index 000000000..07aa7b3cc --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1112.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 3.1.0 to 3.2.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1114.txt b/pkg/cloudflare-go/.changelog/1114.txt new file mode 100644 index 000000000..afef66673 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1114.txt @@ -0,0 +1,7 @@ +```release-note:breaking-change +teams_list: updated methods to match the experimental client format +``` + +```release-note:enhancement +teams_list: `List` operations now automatically paginate +``` diff --git a/pkg/cloudflare-go/.changelog/1115.txt b/pkg/cloudflare-go/.changelog/1115.txt new file mode 100644 index 000000000..b77fa0e55 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1115.txt @@ -0,0 +1,47 @@ +```release-note:breaking-change +workers_kv: `CreateWorkersKVNamespace` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). +``` + +```release-note:breaking-change +workers_kv: `ListWorkersKVNamespaces` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). +``` + +```release-note:enhancement +workers_kv: `ListWorkersKVNamespaces` automatically paginates all results unless `PerPage` is defined. +``` + +```release-note:breaking-change +workers_kv: `DeleteWorkersKVNamespace` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). +``` + +```release-note:breaking-change +workers_kv: `UpdateWorkersKVNamespace` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). +``` + +```release-note:breaking-change +workers_kv: `WriteWorkersKV` has been renamed to `WriteWorkersKVEntry`. +``` + +```release-note:breaking-change +workers_kv: `WriteWorkersKVBulk` has been renamed to `WriteWorkersKVEntries`. +``` + +```release-note:breaking-change +workers_kv: `ReadWorkersKV` has been renamed to `GetWorkersKV`. +``` + +```release-note:breaking-change +workers_kv: `DeleteWorkersKV` has been renamed to `DeleteWorkersKVEntry`. +``` + +```release-note:breaking-change +workers_kv: `DeleteWorkersKVBulk` has been renamed to `DeleteWorkersKVEntries`. +``` + +```release-note:breaking-change +workers_kv: `ListWorkersKVs` has been renamed to `ListWorkersKVKeys`. +``` + +```release-note:breaking-change +workers_kv: `ListWorkersKVsWithOptions` has been removed. Use `ListWorkersKVKeys` instead and pass in the options. +``` diff --git a/pkg/cloudflare-go/.changelog/1116.txt b/pkg/cloudflare-go/.changelog/1116.txt new file mode 100644 index 000000000..9e77f4236 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1116.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: `ioutil` package is being deprecated in favor of `io` +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1117.txt b/pkg/cloudflare-go/.changelog/1117.txt new file mode 100644 index 000000000..d369999a2 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1117.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: remove `github.com/pkg/errors` in favor of `errors` +``` diff --git a/pkg/cloudflare-go/.changelog/1118.txt b/pkg/cloudflare-go/.changelog/1118.txt new file mode 100644 index 000000000..0dbd2bd0d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1118.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.20.2 to 2.20.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1119.txt b/pkg/cloudflare-go/.changelog/1119.txt new file mode 100644 index 000000000..ec5e9d33c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1119.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/stretchr/testify from 1.8.0 to 1.8.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1120.txt b/pkg/cloudflare-go/.changelog/1120.txt new file mode 100644 index 000000000..ca0c26758 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1120.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access: add support for service token rotation +``` diff --git a/pkg/cloudflare-go/.changelog/1121.txt b/pkg/cloudflare-go/.changelog/1121.txt new file mode 100644 index 000000000..75e087cdf --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1121.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +deps: fix import grouping, code formatting and enable goimports linter +``` diff --git a/pkg/cloudflare-go/.changelog/1122.txt b/pkg/cloudflare-go/.changelog/1122.txt new file mode 100644 index 000000000..c5b87f339 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1122.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.20.3 to 2.23.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1123.txt b/pkg/cloudflare-go/.changelog/1123.txt new file mode 100644 index 000000000..55ad335a0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1123.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.3.4 to 1.3.5 +``` diff --git a/pkg/cloudflare-go/.changelog/1124.txt b/pkg/cloudflare-go/.changelog/1124.txt new file mode 100644 index 000000000..a038d1ba0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1124.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.23.0 to 2.23.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1125.txt b/pkg/cloudflare-go/.changelog/1125.txt new file mode 100644 index 000000000..a4bf8cc9e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1125.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.23.2 to 2.23.4 +``` diff --git a/pkg/cloudflare-go/.changelog/1126.txt b/pkg/cloudflare-go/.changelog/1126.txt new file mode 100644 index 000000000..b6620125f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1126.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rule: add input fields crowdstrike +``` diff --git a/pkg/cloudflare-go/.changelog/1127.txt b/pkg/cloudflare-go/.changelog/1127.txt new file mode 100644 index 000000000..36d4124da --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1127.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.23.4 to 2.23.5 +``` diff --git a/pkg/cloudflare-go/.changelog/1130.txt b/pkg/cloudflare-go/.changelog/1130.txt new file mode 100644 index 000000000..229e12b78 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1130.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers_domain: add support for workers domain API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1131.txt b/pkg/cloudflare-go/.changelog/1131.txt new file mode 100644 index 000000000..1434c51d6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1131.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +queue: add support queue API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1133.txt b/pkg/cloudflare-go/.changelog/1133.txt new file mode 100644 index 000000000..9ebbd785e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1133.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Support for Workers Analytics Engine bindings +``` diff --git a/pkg/cloudflare-go/.changelog/1135.txt b/pkg/cloudflare-go/.changelog/1135.txt new file mode 100644 index 000000000..8b98f7cb6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1135.txt @@ -0,0 +1,3 @@ +```release-note:note +pages: removed the v1 logs endpoint for Pages deployments. Please switch to v2: https://developers.cloudflare.com/api/operations/pages-deployment-get-deployment-logs +``` diff --git a/pkg/cloudflare-go/.changelog/1136.txt b/pkg/cloudflare-go/.changelog/1136.txt new file mode 100644 index 000000000..976f76ea7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1136.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +pages: Updates bindings and other Functions related propreties. Service bindings, secrets, fail open/close and usage model are all now supported. +``` + +```release-note:breaking-change +pages: Changed the type of EnvVars in PagesProjectDeploymentConfigEnvironment & PagesProjectDeployment in order to properly support secrets. +``` diff --git a/pkg/cloudflare-go/.changelog/1137.txt b/pkg/cloudflare-go/.changelog/1137.txt new file mode 100644 index 000000000..b479d14d4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1137.txt @@ -0,0 +1,39 @@ +```release-note:note +workers: all worker methods have been split into product ownership(-ish) files +``` + +```release-note:note +workers: all worker methods now require an explicit `ResourceContainer` for endpoints instead of relying on the globally defined `api.AccountID` +``` + +```release-note:breaking-change +workers: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +workers: API operations now target account level resources instead of older zone level resources (these are a 1:1 now) +``` + +```release-note:breaking-change +workers_bindings: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +workers_kv: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +workers_tails: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +workers_secrets: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +workers_routes: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +workers_cron_triggers: method signatures have been updated to align with the upcoming client conventions +``` diff --git a/pkg/cloudflare-go/.changelog/1138.txt b/pkg/cloudflare-go/.changelog/1138.txt new file mode 100644 index 000000000..69b22055c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1138.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +adds OriginRequest field to UnvalidatedIngressRule struct. +``` diff --git a/pkg/cloudflare-go/.changelog/1139.txt b/pkg/cloudflare-go/.changelog/1139.txt new file mode 100644 index 000000000..71f26a2e8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1139.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.23.5 to 2.23.6 +``` diff --git a/pkg/cloudflare-go/.changelog/1140.txt b/pkg/cloudflare-go/.changelog/1140.txt new file mode 100644 index 000000000..f6b31ad5a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1140.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cache_rules: add ignore option to query string struct +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1142.txt b/pkg/cloudflare-go/.changelog/1142.txt new file mode 100644 index 000000000..48f9efbcd --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1142.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_rules: adds support for Egress Policies +``` diff --git a/pkg/cloudflare-go/.changelog/1146.txt b/pkg/cloudflare-go/.changelog/1146.txt new file mode 100644 index 000000000..76ce4035d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1146.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 3.2.0 to 4.1.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1148.txt b/pkg/cloudflare-go/.changelog/1148.txt new file mode 100644 index 000000000..55149599e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1148.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +managed_networks: add CRUD functionality for managednetworks +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1149.txt b/pkg/cloudflare-go/.changelog/1149.txt new file mode 100644 index 000000000..c6c40af07 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1149.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +tiered_cache: Add support for Tiered Caching interactions for setting Smart and Generic topologies +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1150.txt b/pkg/cloudflare-go/.changelog/1150.txt new file mode 100644 index 000000000..ef7d48cd3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1150.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +mtls_certificate: add support for managing mTLS certificates and assocations +``` diff --git a/pkg/cloudflare-go/.changelog/1151.txt b/pkg/cloudflare-go/.changelog/1151.txt new file mode 100644 index 000000000..5482d72f8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1151.txt @@ -0,0 +1,15 @@ +```release-note:enhancement +dns: add support for tags and comments +``` + +```release-note:breaking-change +dns: method signatures have been updated to align with the upcoming client conventions +``` + +```release-note:breaking-change +dns: `DNSRecords` has been renamed to `ListDNSRecords` +``` + +```release-note:breaking-change +dns: `DNSRecord` has been renamed to `GetDNSRecord` +``` diff --git a/pkg/cloudflare-go/.changelog/1155.txt b/pkg/cloudflare-go/.changelog/1155.txt new file mode 100644 index 000000000..d2572b3df --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1155.txt @@ -0,0 +1,3 @@ +```release-note:bug +workers: correctly set `body` value for non-ES module uploads +``` diff --git a/pkg/cloudflare-go/.changelog/1156.txt b/pkg/cloudflare-go/.changelog/1156.txt new file mode 100644 index 000000000..2a19167b7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1156.txt @@ -0,0 +1,31 @@ +```release-note:bug +firewall_rules: use empty reponse struct on each page call +``` + +```release-note:bug +filter: use empty reponse struct on each page call +``` + +```release-note:bug +email_routing_destination: use empty reponse struct on each page call +``` + +```release-note:bug +email_routing_rules: use empty reponse struct on each page call +``` + +```release-note:bug +lockdown: use empty reponse struct on each page call +``` + +```release-note:bug +queue: use empty reponse struct on each page call +``` + +```release-note:bug +teams_list: use empty reponse struct on each page call +``` + +```release-note:bug +workers_kv: use empty reponse struct on each page call +``` diff --git a/pkg/cloudflare-go/.changelog/1159.txt b/pkg/cloudflare-go/.changelog/1159.txt new file mode 100644 index 000000000..5aca5dc5b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1159.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_organization: add user_seat_expiration_inactive_time field +``` diff --git a/pkg/cloudflare-go/.changelog/1160.txt b/pkg/cloudflare-go/.changelog/1160.txt new file mode 100644 index 000000000..b4893dc46 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1160.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Add support for workers logpush enablement on script upload +``` diff --git a/pkg/cloudflare-go/.changelog/1161.txt b/pkg/cloudflare-go/.changelog/1161.txt new file mode 100644 index 000000000..5578937b3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1161.txt @@ -0,0 +1,23 @@ +```release-note:enhancement +origin_ca: add support for using API keys, API tokens or API User service keys for interacting with Origin CA endpoints +``` + +```release-note:breaking-change +origin_ca: renamed to `CreateOriginCertificate` to `CreateOriginCACertificate` +``` + +```release-note:breaking-change +origin_ca: renamed to `OriginCertificates` to `ListOriginCACertificates` +``` + +```release-note:breaking-change +origin_ca: renamed to `OriginCertificate` to `GetOriginCACertificate` +``` + +```release-note:breaking-change +origin_ca: renamed to `RevokeOriginCertificate` to `RevokeOriginCACertificate` +``` + +```release-note:breaking-change +origin_ca: renamed to `OriginCARootCertificate` to `GetOriginCARootCertificate` +``` diff --git a/pkg/cloudflare-go/.changelog/1162.txt b/pkg/cloudflare-go/.changelog/1162.txt new file mode 100644 index 000000000..871465c05 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1162.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.1 to 0.7.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1164.txt b/pkg/cloudflare-go/.changelog/1164.txt new file mode 100644 index 000000000..72272d5df --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1164.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cloudflare: automatically redact sensitive values from HTTP interactions +``` diff --git a/pkg/cloudflare-go/.changelog/1167.txt b/pkg/cloudflare-go/.changelog/1167.txt new file mode 100644 index 000000000..6dacacf5e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1167.txt @@ -0,0 +1,3 @@ +```release-note:bug +dns: don't send "priority" for list operations as it isn't supported and is only used for internal filtering +``` diff --git a/pkg/cloudflare-go/.changelog/1170.txt b/pkg/cloudflare-go/.changelog/1170.txt new file mode 100644 index 000000000..0a3b278ea --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1170.txt @@ -0,0 +1,7 @@ +```release-note:note +dns: remove additional lookup from `Update` operations when `Name` or `Type` was omitted +``` + +```release-note:breaking-change +dns: remove these read-only fields from `UpdateDNSRecordParams`: `CreatedOn`, `ModifiedOn`, `Meta`, `ZoneID`, `ZoneName`, `Proxiable`, and `Locked` +``` diff --git a/pkg/cloudflare-go/.changelog/1171.txt b/pkg/cloudflare-go/.changelog/1171.txt new file mode 100644 index 000000000..174071f00 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1171.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dns: update default `per_page` attribute to 100 records +``` diff --git a/pkg/cloudflare-go/.changelog/1172.txt b/pkg/cloudflare-go/.changelog/1172.txt new file mode 100644 index 000000000..9b4a5bff2 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1172.txt @@ -0,0 +1,3 @@ +```release-note:bug +managednetworks: Update should be PUT +``` diff --git a/pkg/cloudflare-go/.changelog/1173.txt b/pkg/cloudflare-go/.changelog/1173.txt new file mode 100644 index 000000000..403a2feff --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1173.txt @@ -0,0 +1,11 @@ +```release-note:bug +dns: the field `Tags` in `ListDNSRecordsParams` was not correctly serialized into URL queries +``` + +```release-note:enhancement +dns: the URL parameter `tag-match` for listing DNS records is now supported as the field `TagMatch` in `ListDNSRecordsParams` +``` + +```release-note:breaking-change +dns: the fields `CreatedOn` and `ModifiedOn` are removed from `ListDNSRecordsParams` +``` diff --git a/pkg/cloudflare-go/.changelog/1174.txt b/pkg/cloudflare-go/.changelog/1174.txt new file mode 100644 index 000000000..686be2ad1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1174.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dns: `GetDNSRecord`, `UpdateDNSRecord` and `DeleteDNSRecord` now return the new, dedicated error `ErrMissingDNSRecordID` when an empty DNS record ID is given. +``` diff --git a/pkg/cloudflare-go/.changelog/1176.txt b/pkg/cloudflare-go/.changelog/1176.txt new file mode 100644 index 000000000..4d3e6d894 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1176.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: script upload now supports Queues bindings +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1177.txt b/pkg/cloudflare-go/.changelog/1177.txt new file mode 100644 index 000000000..bb9f8d81e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1177.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Add support for compatibility_date and compatibility_flags when upoading a worker script +``` diff --git a/pkg/cloudflare-go/.changelog/1178.txt b/pkg/cloudflare-go/.changelog/1178.txt new file mode 100644 index 000000000..08101c917 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1178.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_identity_provider: add scim_config field +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1180.txt b/pkg/cloudflare-go/.changelog/1180.txt new file mode 100644 index 000000000..8d21a99d7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1180.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.23.7 to 2.24.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1181.txt b/pkg/cloudflare-go/.changelog/1181.txt new file mode 100644 index 000000000..355b67584 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1181.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_organization: add ui_read_only_toggle_reason field +``` diff --git a/pkg/cloudflare-go/.changelog/1183.txt b/pkg/cloudflare-go/.changelog/1183.txt new file mode 100644 index 000000000..01e394768 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1183.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +rulesets: add support for `score_per_period` and `score_response_header_name` +``` diff --git a/pkg/cloudflare-go/.changelog/1184.txt b/pkg/cloudflare-go/.changelog/1184.txt new file mode 100644 index 000000000..7e721c577 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1184.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.3.5 to 1.3.6 +``` diff --git a/pkg/cloudflare-go/.changelog/1185.txt b/pkg/cloudflare-go/.changelog/1185.txt new file mode 100644 index 000000000..5cf856200 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1185.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +magic_transit_ipsec_tunnel: makes customer endpoint an optional field for ipsec tunnel creation +``` diff --git a/pkg/cloudflare-go/.changelog/1188.txt b/pkg/cloudflare-go/.changelog/1188.txt new file mode 100644 index 000000000..b478f060d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1188.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +queues: UpdateQueue has been updated to match the API and now correctly updates a Queue's name +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1190.txt b/pkg/cloudflare-go/.changelog/1190.txt new file mode 100644 index 000000000..6e12a08b1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1190.txt @@ -0,0 +1,7 @@ +```release-note:bug +stream: Fix a bug that cannot unmarshal video duration number. +``` + +```release-note:breaking-change +stream: StreamVideo.Duration has changed from int to float64. +``` diff --git a/pkg/cloudflare-go/.changelog/1191.txt b/pkg/cloudflare-go/.changelog/1191.txt new file mode 100644 index 000000000..f42bc3663 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1191.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.24.1 to 2.24.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1192.txt b/pkg/cloudflare-go/.changelog/1192.txt new file mode 100644 index 000000000..23cd06d80 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1192.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 4.1.0 to 4.2.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1193.txt b/pkg/cloudflare-go/.changelog/1193.txt new file mode 100644 index 000000000..461d746f7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1193.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp_profile: Add new allowed_match_count field to profiles +``` diff --git a/pkg/cloudflare-go/.changelog/1195.txt b/pkg/cloudflare-go/.changelog/1195.txt new file mode 100644 index 000000000..4642d151e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1195.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dns: allow sending empty strings to remove comments +``` diff --git a/pkg/cloudflare-go/.changelog/1196.txt b/pkg/cloudflare-go/.changelog/1196.txt new file mode 100644 index 000000000..fba5681f2 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1196.txt @@ -0,0 +1,3 @@ +```release-note:bug +dns: always send `tags` to allow clearing +``` diff --git a/pkg/cloudflare-go/.changelog/1197.txt b/pkg/cloudflare-go/.changelog/1197.txt new file mode 100644 index 000000000..9535b0bae --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1197.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_account: add support for `check_disks` +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1199.txt b/pkg/cloudflare-go/.changelog/1199.txt new file mode 100644 index 000000000..f7cb743b3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1199.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.24.2 to 2.24.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1200.txt b/pkg/cloudflare-go/.changelog/1200.txt new file mode 100644 index 000000000..40d4ec4b7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1200.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp_profile: Use int rather than uint for allowed_match_count field +``` diff --git a/pkg/cloudflare-go/.changelog/1202.txt b/pkg/cloudflare-go/.changelog/1202.txt new file mode 100644 index 000000000..58c21d62c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1202.txt @@ -0,0 +1,3 @@ +```release-note:bug +stream: renamed `RequiredSignedURLs` to `RequireSignedURLs` +``` diff --git a/pkg/cloudflare-go/.changelog/1205.txt b/pkg/cloudflare-go/.changelog/1205.txt new file mode 100644 index 000000000..257352280 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1205.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +devices_policy: Add new exclude_office_ips field to policy +``` diff --git a/pkg/cloudflare-go/.changelog/1206.txt b/pkg/cloudflare-go/.changelog/1206.txt new file mode 100644 index 000000000..fb12a08eb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1206.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +tunnels: automatically paginate `ListTunnels` +``` diff --git a/pkg/cloudflare-go/.changelog/1207.txt b/pkg/cloudflare-go/.changelog/1207.txt new file mode 100644 index 000000000..7dd4bc704 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1207.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cloudflare: make it clearer when we hit a server error and to retry later +``` diff --git a/pkg/cloudflare-go/.changelog/1208.txt b/pkg/cloudflare-go/.changelog/1208.txt new file mode 100644 index 000000000..8075ecc79 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1208.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_accounts: Add new root_certificate_installation_enabled field +``` diff --git a/pkg/cloudflare-go/.changelog/1209.txt b/pkg/cloudflare-go/.changelog/1209.txt new file mode 100644 index 000000000..bc83d5537 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1209.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dex_test: add CRUD functionality for DEX test configurations +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1210.txt b/pkg/cloudflare-go/.changelog/1210.txt new file mode 100644 index 000000000..a05f5531d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1210.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.24.3 to 2.24.4 +``` diff --git a/pkg/cloudflare-go/.changelog/1212.txt b/pkg/cloudflare-go/.changelog/1212.txt new file mode 100644 index 000000000..8656a47b4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1212.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp: Adds support for partial payload logging +``` diff --git a/pkg/cloudflare-go/.changelog/1213.txt b/pkg/cloudflare-go/.changelog/1213.txt new file mode 100644 index 000000000..124db5e91 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1213.txt @@ -0,0 +1,3 @@ +```release-note:bug +dex_test: use dex test types and json struct mappings instead of managed networks +``` diff --git a/pkg/cloudflare-go/.changelog/1214.txt b/pkg/cloudflare-go/.changelog/1214.txt new file mode 100644 index 000000000..e41d1a146 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1214.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_rules: Add `untrusted_cert` rule setting +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1215.txt b/pkg/cloudflare-go/.changelog/1215.txt new file mode 100644 index 000000000..44307cf98 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1215.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/text from 0.3.7 to 0.3.8 +``` diff --git a/pkg/cloudflare-go/.changelog/1216.txt b/pkg/cloudflare-go/.changelog/1216.txt new file mode 100644 index 000000000..44307cf98 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1216.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/text from 0.3.7 to 0.3.8 +``` diff --git a/pkg/cloudflare-go/.changelog/1217.txt b/pkg/cloudflare-go/.changelog/1217.txt new file mode 100644 index 000000000..0fc959900 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1217.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/time from 0.0.0-20220224211638-0e9765cccd65 to 0.3.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1218.txt b/pkg/cloudflare-go/.changelog/1218.txt new file mode 100644 index 000000000..148e60cca --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1218.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.0.0-20220722155237-a158d28d115b to 0.7.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1219.txt b/pkg/cloudflare-go/.changelog/1219.txt new file mode 100644 index 000000000..148e60cca --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1219.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.0.0-20220722155237-a158d28d115b to 0.7.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1220.txt b/pkg/cloudflare-go/.changelog/1220.txt new file mode 100644 index 000000000..6f31f37ad --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1220.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/stretchr/testify from 1.8.1 to 1.8.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1222.txt b/pkg/cloudflare-go/.changelog/1222.txt new file mode 100644 index 000000000..5d9a668a0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1222.txt @@ -0,0 +1,3 @@ +```release-note:bug +dns: dont reuse DNSListResponse when using pagination to avoid Proxied pointer overwrite +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1223.txt b/pkg/cloudflare-go/.changelog/1223.txt new file mode 100644 index 000000000..e046c338b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1223.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add `path_cookie_attribute` app setting +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1226.txt b/pkg/cloudflare-go/.changelog/1226.txt new file mode 100644 index 000000000..167b1e6f7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1226.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +added audit_ssh to gateway actions, updated gateway rule settings +``` diff --git a/pkg/cloudflare-go/.changelog/1227.txt b/pkg/cloudflare-go/.changelog/1227.txt new file mode 100644 index 000000000..96c1ce052 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1227.txt @@ -0,0 +1,10 @@ +```release-note:breaking-change +tunnel: renamed `Tunnels` to `ListTunnels` +``` + +```release-note:breaking-change +tunnel: renamed `Tunnel` to `GetTunnel` +``` +```release-note:enhancement +tunnel: updated parameters to latest API docs +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1228.txt b/pkg/cloudflare-go/.changelog/1228.txt new file mode 100644 index 000000000..72816b5f3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1228.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.7.0 to 0.8.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1229.txt b/pkg/cloudflare-go/.changelog/1229.txt new file mode 100644 index 000000000..a4b655ccb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1229.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.24.4 to 2.25.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1232.txt b/pkg/cloudflare-go/.changelog/1232.txt new file mode 100644 index 000000000..c8e3ce2bd --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1232.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +addressing: Add `Address Map` support +``` diff --git a/pkg/cloudflare-go/.changelog/1236.txt b/pkg/cloudflare-go/.changelog/1236.txt new file mode 100644 index 000000000..bf5b7c4ac --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1236.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps actions/setup-go from 3 to 4 +``` diff --git a/pkg/cloudflare-go/.changelog/1237.txt b/pkg/cloudflare-go/.changelog/1237.txt new file mode 100644 index 000000000..1c87f2721 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1237.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_identity_provider: add `claims` and `scopes` fields +``` diff --git a/pkg/cloudflare-go/.changelog/1238.txt b/pkg/cloudflare-go/.changelog/1238.txt new file mode 100644 index 000000000..e01bf6453 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1238.txt @@ -0,0 +1,3 @@ +```release-note:bug +tunnel: Fix 'CreateTunnel' for tunnels using config_src +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1242.txt b/pkg/cloudflare-go/.changelog/1242.txt new file mode 100644 index 000000000..f847da86f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1242.txt @@ -0,0 +1,7 @@ +```release-note:bug +teams_rules: `AllowChildBypass` changes from a `bool` to `*bool` +``` + +```release-note:bug +teams_rules: `BypassParentRule` changes from a `bool` to `*bool` +``` diff --git a/pkg/cloudflare-go/.changelog/1243.txt b/pkg/cloudflare-go/.changelog/1243.txt new file mode 100644 index 000000000..f5c0728ec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1243.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +dns: Changed Create/UpdateDNSRecord method signatures to return (DNSRecord, error) +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1244.txt b/pkg/cloudflare-go/.changelog/1244.txt new file mode 100644 index 000000000..fa7858c06 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1244.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +ssl: make `GeoRestrictions` a pointer inside of ZoneCustomSSL +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1246.txt b/pkg/cloudflare-go/.changelog/1246.txt new file mode 100644 index 000000000..678b27fd5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1246.txt @@ -0,0 +1,3 @@ +```release-note:note +dns_firewall: The `OriginIPs` field has been renamed to `UpstreamIPs`. +``` diff --git a/pkg/cloudflare-go/.changelog/1249.txt b/pkg/cloudflare-go/.changelog/1249.txt new file mode 100644 index 000000000..0f2cbedf9 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1249.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +devices_policy: update `Mode` field to use new `ServiceMode` string type with explicit const service mode values +``` diff --git a/pkg/cloudflare-go/.changelog/1250.txt b/pkg/cloudflare-go/.changelog/1250.txt new file mode 100644 index 000000000..8f1a611d5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1250.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.25.0 to 2.25.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1251.txt b/pkg/cloudflare-go/.changelog/1251.txt new file mode 100644 index 000000000..b750d6ebf --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1251.txt @@ -0,0 +1,11 @@ +```release-note:breaking-change +zone: `ZoneSingleSetting` has been renamed to `GetZoneSetting` and updated method signature inline with our expected conventions +``` + +```release-note:breaking-change +zone: `UpdateZoneSingleSetting` has been renamed to `UpdateZoneSetting` and updated method signature inline with our expected conventions +``` + +```release-note:enhancement +zone: `GetZoneSetting` and `UpdateZoneSetting` now allow configuring the path for where a setting resides instead of assuming `settings` +``` diff --git a/pkg/cloudflare-go/.changelog/1253.txt b/pkg/cloudflare-go/.changelog/1253.txt new file mode 100644 index 000000000..a43f5d8cb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1253.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +rulesets: add support for add operation to HTTP header configuration +``` diff --git a/pkg/cloudflare-go/.changelog/1258.txt b/pkg/cloudflare-go/.changelog/1258.txt new file mode 100644 index 000000000..09748a6ec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1258.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access: Add `isolation_required` flag to Access policies +``` diff --git a/pkg/cloudflare-go/.changelog/1260.txt b/pkg/cloudflare-go/.changelog/1260.txt new file mode 100644 index 000000000..806345d46 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1260.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access: Add `auto_redirect_to_identity` flag to Access organizations +``` diff --git a/pkg/cloudflare-go/.changelog/1261.txt b/pkg/cloudflare-go/.changelog/1261.txt new file mode 100644 index 000000000..efa0a01ba --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1261.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +rulesets: add support for the `http_response_compression` phase +``` + +```release-note:enhancement +rulesets: add support for the `compress_response` action +``` diff --git a/pkg/cloudflare-go/.changelog/1263.txt b/pkg/cloudflare-go/.changelog/1263.txt new file mode 100644 index 000000000..785f7c0c1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1263.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.8.0 to 0.9.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1264.txt b/pkg/cloudflare-go/.changelog/1264.txt new file mode 100644 index 000000000..8c3fff357 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1264.txt @@ -0,0 +1,19 @@ +```release-note:breaking-change +pages_deployment: add support for auto pagination +``` + +```release-note:enchancement +pages_deployment: add Force to DeletePagesDeploymentParams +``` + +```release-note:breaking-change +pages_deployment: change DeletePagesDeploymentParams to contain all parameters +``` + +```release-note:breaking-change +pages_project: rename PagesProject to GetPagesProject +``` + +```release-note:breaking-change +pages_project: change to use ResourceContainer for account ID +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1265.txt b/pkg/cloudflare-go/.changelog/1265.txt new file mode 100644 index 000000000..a001f26c5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1265.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +r2_bucket: add support for getting a bucket +``` + +```release-note:breaking-change +r2_bucket: change creation time from string to *time.Time +``` diff --git a/pkg/cloudflare-go/.changelog/1266.txt b/pkg/cloudflare-go/.changelog/1266.txt new file mode 100644 index 000000000..749e3b8c8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1266.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dns: add support for importing and exporting DNS records using BIND file configurations +``` diff --git a/pkg/cloudflare-go/.changelog/1267.txt b/pkg/cloudflare-go/.changelog/1267.txt new file mode 100644 index 000000000..33e3526ec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1267.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +turnstile: add support for turnstile +``` diff --git a/pkg/cloudflare-go/.changelog/1268.txt b/pkg/cloudflare-go/.changelog/1268.txt new file mode 100644 index 000000000..586bac29d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1268.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rule: add input fields tanium, intune and kolide +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1269.txt b/pkg/cloudflare-go/.changelog/1269.txt new file mode 100644 index 000000000..686ac6a9a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1269.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.3.6 to 1.4.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1270.txt b/pkg/cloudflare-go/.changelog/1270.txt new file mode 100644 index 000000000..4c09f1f51 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1270.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +data localization: add support for regional hostnames API +``` diff --git a/pkg/cloudflare-go/.changelog/1271.txt b/pkg/cloudflare-go/.changelog/1271.txt new file mode 100644 index 000000000..664212a1e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1271.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +certificate_packs: add `Status` field to indicate the status of certificate pack +``` diff --git a/pkg/cloudflare-go/.changelog/1272.txt b/pkg/cloudflare-go/.changelog/1272.txt new file mode 100644 index 000000000..dfa388838 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1272.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +logpush: add support for max upload parameters +``` diff --git a/pkg/cloudflare-go/.changelog/1274.txt b/pkg/cloudflare-go/.changelog/1274.txt new file mode 100644 index 000000000..51deac303 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1274.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.25.1 to 2.25.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1275.txt b/pkg/cloudflare-go/.changelog/1275.txt new file mode 100644 index 000000000..718b80065 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1275.txt @@ -0,0 +1,3 @@ +```release-note:bug +rulesets: allow `PreserveQueryString` to be nullable +``` diff --git a/pkg/cloudflare-go/.changelog/1276.txt b/pkg/cloudflare-go/.changelog/1276.txt new file mode 100644 index 000000000..713898c8d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1276.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +waiting_room: add support for zone-level settings +``` diff --git a/pkg/cloudflare-go/.changelog/1278.txt b/pkg/cloudflare-go/.changelog/1278.txt new file mode 100644 index 000000000..64e605cf1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1278.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +zone: Added `GetCacheReserve` and `UpdateacheReserve` to allow setting Cache Reserve for a zone. +``` diff --git a/pkg/cloudflare-go/.changelog/1279.txt b/pkg/cloudflare-go/.changelog/1279.txt new file mode 100644 index 000000000..08cd4cab0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1279.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +pages: add support for Smart Placement. Added `Placement` in `PagesProjectDeploymentConfigEnvironment`. +``` + +```release-note:enhancement +workers: add support for Smart Placement. Added `Placement` in `CreateWorkerParams`. +``` diff --git a/pkg/cloudflare-go/.changelog/1280.txt b/pkg/cloudflare-go/.changelog/1280.txt new file mode 100644 index 000000000..7f690ce83 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1280.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.9.0 to 0.10.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1281.txt b/pkg/cloudflare-go/.changelog/1281.txt new file mode 100644 index 000000000..c11f54ae4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1281.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access: Added `self_hosted_domains` support to access applications +``` diff --git a/pkg/cloudflare-go/.changelog/1284.txt b/pkg/cloudflare-go/.changelog/1284.txt new file mode 100644 index 000000000..0da545211 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1284.txt @@ -0,0 +1,3 @@ +```release-note:bug +turnstile: remove `SiteKey`/`Secret` being sent in update request body +``` diff --git a/pkg/cloudflare-go/.changelog/1285.txt b/pkg/cloudflare-go/.changelog/1285.txt new file mode 100644 index 000000000..75140aed1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1285.txt @@ -0,0 +1,3 @@ +```release-note:bug +turnstile: remove `SiteKey` being sent in rotate secret's request body +``` diff --git a/pkg/cloudflare-go/.changelog/1286.txt b/pkg/cloudflare-go/.changelog/1286.txt new file mode 100644 index 000000000..380cc2973 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1286.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/stretchr/testify from 1.8.2 to 1.8.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1287.txt b/pkg/cloudflare-go/.changelog/1287.txt new file mode 100644 index 000000000..3e78c8210 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1287.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.4.0 to 1.5.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1288.txt b/pkg/cloudflare-go/.changelog/1288.txt new file mode 100644 index 000000000..740182344 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1288.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +lists: add support for hostname and ASN lists. +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1289.txt b/pkg/cloudflare-go/.changelog/1289.txt new file mode 100644 index 000000000..1c9e25e7a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1289.txt @@ -0,0 +1,3 @@ +```release-note:bug +flarectl/dns: ensure MX priority value is dereferenced +``` diff --git a/pkg/cloudflare-go/.changelog/1290.txt b/pkg/cloudflare-go/.changelog/1290.txt new file mode 100644 index 000000000..70224ff8f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1290.txt @@ -0,0 +1,3 @@ +```release-note:bug +dns: fix MX record priority not set by UpdateDNSRecord +``` diff --git a/pkg/cloudflare-go/.changelog/1291.txt b/pkg/cloudflare-go/.changelog/1291.txt new file mode 100644 index 000000000..60c5bf456 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1291.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +tunnels: add support for `access` and `http2Origin` keys +``` diff --git a/pkg/cloudflare-go/.changelog/1292.txt b/pkg/cloudflare-go/.changelog/1292.txt new file mode 100644 index 000000000..9bd62ebe8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1292.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.5.0 to 1.5.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1293.txt b/pkg/cloudflare-go/.changelog/1293.txt new file mode 100644 index 000000000..4c865f5bf --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1293.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +load_balancing: extend documentation for least_outstanding_requests steering policy +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1294.txt b/pkg/cloudflare-go/.changelog/1294.txt new file mode 100644 index 000000000..fa9244192 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1294.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +devices_policy: Add missing description field to policy +``` diff --git a/pkg/cloudflare-go/.changelog/1295.txt b/pkg/cloudflare-go/.changelog/1295.txt new file mode 100644 index 000000000..feff3ce19 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1295.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.25.3 to 2.25.5 +``` diff --git a/pkg/cloudflare-go/.changelog/1296.txt b/pkg/cloudflare-go/.changelog/1296.txt new file mode 100644 index 000000000..5eb7ddda1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1296.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/stretchr/testify from 1.8.3 to 1.8.4 +``` diff --git a/pkg/cloudflare-go/.changelog/1297.txt b/pkg/cloudflare-go/.changelog/1297.txt new file mode 100644 index 000000000..cee310173 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1297.txt @@ -0,0 +1,3 @@ +```release-note:bug +email_routing_destination: return encountered error, not `ErrMissingAccountID` all the time +``` diff --git a/pkg/cloudflare-go/.changelog/1298.txt b/pkg/cloudflare-go/.changelog/1298.txt new file mode 100644 index 000000000..fff1d2a2b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1298.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +custom_hostname: add support for `bundle_method` TLS configuration +``` diff --git a/pkg/cloudflare-go/.changelog/1300.txt b/pkg/cloudflare-go/.changelog/1300.txt new file mode 100644 index 000000000..b065b8252 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1300.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.2 to 0.7.3 +``` diff --git a/pkg/cloudflare-go/.changelog/1301.txt b/pkg/cloudflare-go/.changelog/1301.txt new file mode 100644 index 000000000..a0982af62 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1301.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.3 to 0.7.4 +``` diff --git a/pkg/cloudflare-go/.changelog/1302.txt b/pkg/cloudflare-go/.changelog/1302.txt new file mode 100644 index 000000000..9be4caa82 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1302.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +load_balancing: support header session affinity policy +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1303.txt b/pkg/cloudflare-go/.changelog/1303.txt new file mode 100644 index 000000000..50a9b97a3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1303.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +tunnel: swap `ConnectTimeout`, `TLSTimeout`, `TCPKeepAlive` and `KeepAliveTimeout` to `TunnelDuration` instead of `time.Duration` +``` diff --git a/pkg/cloudflare-go/.changelog/1304.txt b/pkg/cloudflare-go/.changelog/1304.txt new file mode 100644 index 000000000..f3e8693e2 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1304.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +custom_nameservers: add support for managing custom nameservers +``` diff --git a/pkg/cloudflare-go/.changelog/1305.txt b/pkg/cloudflare-go/.changelog/1305.txt new file mode 100644 index 000000000..1919a14e3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1305.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.25.5 to 2.25.6 +``` diff --git a/pkg/cloudflare-go/.changelog/1306.txt b/pkg/cloudflare-go/.changelog/1306.txt new file mode 100644 index 000000000..a655ab47b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1306.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 4.2.0 to 4.3.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1307.txt b/pkg/cloudflare-go/.changelog/1307.txt new file mode 100644 index 000000000..ce939ea0c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1307.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.10.0 to 0.11.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1311.txt b/pkg/cloudflare-go/.changelog/1311.txt new file mode 100644 index 000000000..46639f877 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1311.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +waiting_room: add support for `additional_routes` and `cookie_suffix` +``` diff --git a/pkg/cloudflare-go/.changelog/1312.txt b/pkg/cloudflare-go/.changelog/1312.txt new file mode 100644 index 000000000..9d3d216d1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1312.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +railgun: remove support for railgun +``` diff --git a/pkg/cloudflare-go/.changelog/1313.txt b/pkg/cloudflare-go/.changelog/1313.txt new file mode 100644 index 000000000..ace0376ab --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1313.txt @@ -0,0 +1,7 @@ +```release-note:breaking-change +virtualdns: remove support in favour of newer DNS firewall methods +``` + +```release-note:breaking-change +dns_firewall: modernise method signatures and conventions to align with the experimental client +``` diff --git a/pkg/cloudflare-go/.changelog/1314.txt b/pkg/cloudflare-go/.changelog/1314.txt new file mode 100644 index 000000000..4eb0d0acb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1314.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.25.6 to 2.25.7 +``` diff --git a/pkg/cloudflare-go/.changelog/1315.txt b/pkg/cloudflare-go/.changelog/1315.txt new file mode 100644 index 000000000..c5dedfd9a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1315.txt @@ -0,0 +1,7 @@ +```release-note:breaking-change +cloudflare: remove `api.AccountID` from client struct +``` + +```release-note:breaking-change +cloudflare: remove `UsingAccount` in favour of resource specific attributes +``` diff --git a/pkg/cloudflare-go/.changelog/1316.txt b/pkg/cloudflare-go/.changelog/1316.txt new file mode 100644 index 000000000..d2588ba25 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1316.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rule: support os_version_extra +``` diff --git a/pkg/cloudflare-go/.changelog/1317.txt b/pkg/cloudflare-go/.changelog/1317.txt new file mode 100644 index 000000000..722e04c20 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1317.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: Add ability to specify tail Workers in script metadata +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1319.txt b/pkg/cloudflare-go/.changelog/1319.txt new file mode 100644 index 000000000..4235c885c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1319.txt @@ -0,0 +1,59 @@ +```release-note:breaking-change +access_application: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:enhancement +access_application: add support for auto pagination +``` + +```release-note:breaking-change +access_ca_certificate: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:enhancement +access_ca_certificate: add support for auto pagination +``` + +```release-note:breaking-change +access_group: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:enhancement +access_group: add support for auto pagination +``` + +```release-note:breaking-change +access_identity_provider: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:enhancement +access_identity_provider: add support for auto pagination +``` + +```release-note:breaking-change +access_mutual_tls_certificates: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:enhancement +access_mutual_tls_certificates: add support for auto pagination +``` + +```release-note:breaking-change +access_organization: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:breaking-change +access_policy: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:enhancement +access_policy: add support for auto pagination +``` + +```release-note:breaking-change +access_service_tokens: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` + +```release-note:breaking-change +access_user_token: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods +``` diff --git a/pkg/cloudflare-go/.changelog/1320.txt b/pkg/cloudflare-go/.changelog/1320.txt new file mode 100644 index 000000000..6fe1268d1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1320.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.5.1 to 1.6.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1322.txt b/pkg/cloudflare-go/.changelog/1322.txt new file mode 100644 index 000000000..9f5c710fe --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1322.txt @@ -0,0 +1,31 @@ +```release-note:enhancement +images: adds support for v2 when uploading images directly +``` + +```release-note:breaking-change +images: updated method signatures of `UploadImage` to match newer conventions and standards +``` + +```release-note:breaking-change +images: updated method signatures of `UpdateImage` to match newer conventions and standards +``` + +```release-note:breaking-change +images: updated method signatures of `ListImages` to match newer conventions and standards +``` + +```release-note:breaking-change +images: updated method signatures of `DeleteImage` to match newer conventions and standards +``` + +```release-note:breaking-change +images: renamed `ImageDetails` to `GetImage` to match library conventions +``` + +```release-note:breaking-change +images: renamed `BaseImage` to `GetBaseImage` to match library conventions +``` + +```release-note:breaking-change +images: renamed `ImagesStats` to `GetImagesStats` to match library conventions +``` diff --git a/pkg/cloudflare-go/.changelog/1325.txt b/pkg/cloudflare-go/.changelog/1325.txt new file mode 100644 index 000000000..0742b1f7f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1325.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource_container: expose `Type` on `*ResourceContainer` to explicitly denote what type of resource it is instead of inferring from `Level`. +``` diff --git a/pkg/cloudflare-go/.changelog/1326.txt b/pkg/cloudflare-go/.changelog/1326.txt new file mode 100644 index 000000000..fd85096d0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1326.txt @@ -0,0 +1,83 @@ +```release-note:breaking-change +logpush: all methods are updated to use the newer client conventions for method signatures +``` + +```release-note:breaking-change +logpush: `CreateAccountLogpushJob` is removed in favour of `CreateLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `CreateZoneLogpushJob` is removed in favour of `CreateLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `ListAccountLogpushJobs` is removed in favour of `ListLogpushJobs` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `ListZoneLogpushJobs` is removed in favour of `ListLogpushJobs` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `ListAccountLogpushJobsForDataset` is removed in favour of `ListLogpushJobsForDataset` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `ListZoneLogpushJobsForDataset` is removed in favour of `ListLogpushJobsForDataset` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `GetAccountLogpushFields` is removed in favour of `GetLogpushFields` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `GetZoneLogpushFields` is removed in favour of `GetLogpushFields` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `GetAccountLogpushJob` is removed in favour of `GetLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `GetZoneLogpushJob` is removed in favour of `GetLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `UpdateAccountLogpushJob` is removed in favour of `UpdateLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `UpdateZoneLogpushJob` is removed in favour of `UpdateLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `DeleteAccountLogpushJob` is removed in favour of `DeleteLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `DeleteZoneLogpushJob` is removed in favour of `DeleteLogpushJob` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `GetAccountLogpushOwnershipChallenge` is removed in favour of `GetLogpushOwnershipChallenge` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `GetZoneLogpushOwnershipChallenge` is removed in favour of `GetLogpushOwnershipChallenge` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `ValidateAccountLogpushOwnershipChallenge` is removed in favour of `ValidateLogpushOwnershipChallenge` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `ValidateZoneLogpushOwnershipChallenge` is removed in favour of `ValidateLogpushOwnershipChallenge` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `CheckAccountLogpushDestinationExists` is removed in favour of `CheckLogpushDestinationExists` with `ResourceContainer` method parameter +``` + +```release-note:breaking-change +logpush: `CheckZoneLogpushDestinationExists` is removed in favour of `CheckLogpushDestinationExists` with `ResourceContainer` method parameter +``` diff --git a/pkg/cloudflare-go/.changelog/1328.txt b/pkg/cloudflare-go/.changelog/1328.txt new file mode 100644 index 000000000..51d73f830 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1328.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.11.0 to 0.12.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1330.txt b/pkg/cloudflare-go/.changelog/1330.txt new file mode 100644 index 000000000..630d7ee25 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1330.txt @@ -0,0 +1,15 @@ +```release-note:enhancement +workers: Add support for uploading scripts to a Workers for Platforms namespace. +``` + +```release-note:enhancement +workers: Add support for declaring arbitrary bindings with UnsafeBinding. +``` + +```release-note:enhancement +workers: Add support for uploading workers with Workers for Platforms namespace bindings. +``` + +```release-note:enhancement +workers: Add `pipeline_hash` field to Workers script response struct. +``` diff --git a/pkg/cloudflare-go/.changelog/1333.txt b/pkg/cloudflare-go/.changelog/1333.txt new file mode 100644 index 000000000..81026e815 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1333.txt @@ -0,0 +1,47 @@ +```release-note:breaking-change +rulesets: `GetZoneRuleset` is removed in favour of `GetRuleset` +``` + +```release-note:breaking-change +rulesets: `GetAccountRuleset` is removed in favour of `GetRuleset` +``` + +```release-note:breaking-change +rulesets: `CreateZoneRuleset` is removed in favour of `CreateRuleset` +``` + +```release-note:breaking-change +rulesets: `CreateAccountRuleset` is removed in favour of `CreateRuleset` +``` + +```release-note:breaking-change +rulesets: `DeleteZoneRuleset` is removed in favour of `DeleteRuleset` +``` + +```release-note:breaking-change +rulesets: `DeleteAccountRuleset` is removed in favour of `DeleteRuleset` +``` + +```release-note:breaking-change +rulesets: `UpdateZoneRuleset` is removed in favour of `UpdateRuleset` +``` + +```release-note:breaking-change +rulesets: `UpdateAccountRuleset` is removed in favour of `UpdateRuleset` +``` + +```release-note:breaking-change +rulesets: `GetZoneRulesetPhase` is removed in favour of `GetEntrypointRuleset` +``` + +```release-note:breaking-change +rulesets: `GetAccountRulesetPhase` is removed in favour of `GetEntrypointRuleset` +``` + +```release-note:breaking-change +rulesets: `UpdateZoneRulesetPhase` is removed in favour of `UpdateEntrypointRuleset` +``` + +```release-note:breaking-change +rulesets: `UpdateAccountRulesetPhase` is removed in favour of `UpdateEntrypointRuleset` +``` diff --git a/pkg/cloudflare-go/.changelog/1335.txt b/pkg/cloudflare-go/.changelog/1335.txt new file mode 100644 index 000000000..13b520854 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1335.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +images: adds ability to upload image by url +``` diff --git a/pkg/cloudflare-go/.changelog/1336.txt b/pkg/cloudflare-go/.changelog/1336.txt new file mode 100644 index 000000000..f949bdc25 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1336.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +zone: Added `GetRegionalTieredCache` and `UpdateRegionalTieredCache` to allow setting Regional Tiered Cache for a zone. +``` diff --git a/pkg/cloudflare-go/.changelog/1338.txt b/pkg/cloudflare-go/.changelog/1338.txt new file mode 100644 index 000000000..ce00f1f98 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1338.txt @@ -0,0 +1,3 @@ +```release-note:bug +load_balancing: Fix pool creation with MinimumOrigins set to 0 +``` diff --git a/pkg/cloudflare-go/.changelog/1339.txt b/pkg/cloudflare-go/.changelog/1339.txt new file mode 100644 index 000000000..7326b4ede --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1339.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +device_posture_rule: support certificate_id and cn for client_certificate posture rule +``` + +```release-note:enhancement +device_posture_rule: support active_threats, network_status, infected, and is_active for sentinelone_s2s posture rule +``` diff --git a/pkg/cloudflare-go/.changelog/1340.txt b/pkg/cloudflare-go/.changelog/1340.txt new file mode 100644 index 000000000..beb399780 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1340.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams-accounts: Adds support for protocol detection +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1341.txt b/pkg/cloudflare-go/.changelog/1341.txt new file mode 100644 index 000000000..8ba43549f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1341.txt @@ -0,0 +1,3 @@ +```release-note:bug +flarectl: allow for create or update to actually create the record +``` diff --git a/pkg/cloudflare-go/.changelog/1343.txt b/pkg/cloudflare-go/.changelog/1343.txt new file mode 100644 index 000000000..df6721b23 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1343.txt @@ -0,0 +1,11 @@ +```release-note:enhancement +access_application: Add support for custom pages +``` + +```release-note:enhancement +access_custom_page: Add support for custom pages +``` + +```release-note:enhancement +access_organization: add support for custom pages +``` diff --git a/pkg/cloudflare-go/.changelog/1344.txt b/pkg/cloudflare-go/.changelog/1344.txt new file mode 100644 index 000000000..f800df64e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1344.txt @@ -0,0 +1,11 @@ +```release-note:enhancement +access_identity_provider: add attr conditional_access_enabled +``` + +```release-note:enhancement +access_group: add auth_context group ruletype +``` + +```release-note:enhancement +access_identity_provider: add auth context list/put endpoint +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1345.txt b/pkg/cloudflare-go/.changelog/1345.txt new file mode 100644 index 000000000..4e748a75c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1345.txt @@ -0,0 +1,3 @@ +```release-note:bug +workers: Fix namespace dispatch upload API path +``` diff --git a/pkg/cloudflare-go/.changelog/1346.txt b/pkg/cloudflare-go/.changelog/1346.txt new file mode 100644 index 000000000..d9826d0f6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1346.txt @@ -0,0 +1,11 @@ +```release-note:enhancement +rulesets: Update API reference links +``` + +```release-note:enhancement +rulesets: Remove internal-only schema kind +``` + +```release-note:enhancement +rulesets: Remove some request parameters that are not allowed or have no effect +``` diff --git a/pkg/cloudflare-go/.changelog/1347.txt b/pkg/cloudflare-go/.changelog/1347.txt new file mode 100644 index 000000000..0271db293 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1347.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_service_token: add support for managing `Duration` +``` diff --git a/pkg/cloudflare-go/.changelog/1348.txt b/pkg/cloudflare-go/.changelog/1348.txt new file mode 100644 index 000000000..19eca269e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1348.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +web_analytics: add support for web_analytics API +``` diff --git a/pkg/cloudflare-go/.changelog/1353.txt b/pkg/cloudflare-go/.changelog/1353.txt new file mode 100644 index 000000000..e7a0f4024 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1353.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.12.0 to 0.13.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1355.txt b/pkg/cloudflare-go/.changelog/1355.txt new file mode 100644 index 000000000..d19977c08 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1355.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +cloudflare: `Raw` method now returns a RawResponse rather than the raw JSON `Result` message +``` diff --git a/pkg/cloudflare-go/.changelog/1356.txt b/pkg/cloudflare-go/.changelog/1356.txt new file mode 100644 index 000000000..dd94e72a3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1356.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +per_hostname_tls_settings: add support for managing hostname level TLS settings +``` diff --git a/pkg/cloudflare-go/.changelog/1357.txt b/pkg/cloudflare-go/.changelog/1357.txt new file mode 100644 index 000000000..e6f1f3b8a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1357.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +waiting_room: add support for `queueing_status_code` +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1359.txt b/pkg/cloudflare-go/.changelog/1359.txt new file mode 100644 index 000000000..982b4884c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1359.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +streams: adds support to initiate tus upload +``` diff --git a/pkg/cloudflare-go/.changelog/1360.txt b/pkg/cloudflare-go/.changelog/1360.txt new file mode 100644 index 000000000..6b2bbae58 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1360.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +cloudflare: swap `encoding/json` for `github.com/goccy/go-json` +``` + +```release-note:bug +cache_purge: don't escape HTML entity values in URLs for cache keys +``` diff --git a/pkg/cloudflare-go/.changelog/1361.txt b/pkg/cloudflare-go/.changelog/1361.txt new file mode 100644 index 000000000..0a8293a54 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1361.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +workers: Add support for retrieving and uploading only script content. +``` + +```release-note:enhancement +workers: Add support for retrieving and uploading only script metadata. +``` diff --git a/pkg/cloudflare-go/.changelog/1362.txt b/pkg/cloudflare-go/.changelog/1362.txt new file mode 100644 index 000000000..6268fe248 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1362.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.13.0 to 0.14.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1363.txt b/pkg/cloudflare-go/.changelog/1363.txt new file mode 100644 index 000000000..e538706f3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1363.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +bot_management: add support for bot_management API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1365.txt b/pkg/cloudflare-go/.changelog/1365.txt new file mode 100644 index 000000000..05cd13708 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1365.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +zone_hold: add support for zone hold API +``` diff --git a/pkg/cloudflare-go/.changelog/1366.txt b/pkg/cloudflare-go/.changelog/1366.txt new file mode 100644 index 000000000..0590b8e73 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1366.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rule: support eid_last_seen and risk_level and correct total_score for Tanium posture rule +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1367.txt b/pkg/cloudflare-go/.changelog/1367.txt new file mode 100644 index 000000000..7f8627357 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1367.txt @@ -0,0 +1,23 @@ +```release-note:note +rulesets: Remove non-existent `allow` action +``` + +```release-note:enhancement +rulesets: Add the `ddos_mitigation` action +``` + +```release-note:note +rulesets: Remove non-existent `http_request_main` phase +``` + +```release-note:note +rulesets: Remove non-public `http_response_headers_transform_managed` and `http_request_late_transform_managed` phases +``` + +```release-note:breaking-change +rulesets: Rename `RulesetPhaseRateLimit` to `RulesetPhaseHTTPRatelimit`, to match the phase name +``` + +```release-note:breaking-change +rulesets: Rename `RulesetPhaseSuperBotFightMode` to `RulesetPhaseHTTPRequestSBFM`, to match the phase name +``` diff --git a/pkg/cloudflare-go/.changelog/1368.txt b/pkg/cloudflare-go/.changelog/1368.txt new file mode 100644 index 000000000..c58d12c33 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1368.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: add support for tagging Worker scripts +``` diff --git a/pkg/cloudflare-go/.changelog/1369.txt b/pkg/cloudflare-go/.changelog/1369.txt new file mode 100644 index 000000000..a523c1c9f --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1369.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 4.3.0 to 4.4.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1372.txt b/pkg/cloudflare-go/.changelog/1372.txt new file mode 100644 index 000000000..ec3f51408 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1372.txt @@ -0,0 +1,23 @@ +```release-note:bug +pagination: Will look at `total_count` and `per_page` to calculate `total_pages` if `total_pages` is zero +``` + +```release-note:bug +access_application: Use autopaginate flag as expected +``` + +```release-note:bug +access_ca_certificate: Use autopaginate flag as expected +``` + +```release-note:bug +access_group: Use autopaginate flag as expected +``` + +```release-note:bug +access_mutual_tls_certifcate: Use autopaginate flag as expected +``` + +```release-note:bug +access_policy: Use autopaginate flag as expected +``` diff --git a/pkg/cloudflare-go/.changelog/1373.txt b/pkg/cloudflare-go/.changelog/1373.txt new file mode 100644 index 000000000..9276016d9 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1373.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: added custom_non_identity_deny_url +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1374.txt b/pkg/cloudflare-go/.changelog/1374.txt new file mode 100644 index 000000000..07bc895bb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1374.txt @@ -0,0 +1,3 @@ +```release_note:enhancement +rulesets: Add support for Proxy Read Timeout configuration via the Rulesets/Cache Rules API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1375.txt b/pkg/cloudflare-go/.changelog/1375.txt new file mode 100644 index 000000000..a7d2ae9c7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1375.txt @@ -0,0 +1,3 @@ +```release_note:enhancement +rulesets: Add support for Additional Cacheable Ports configuration via the Rulesets/Cache Rules API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1376.txt b/pkg/cloudflare-go/.changelog/1376.txt new file mode 100644 index 000000000..d2881fce3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1376.txt @@ -0,0 +1,3 @@ +```release_note:enhancement +rulesets: Add support for Origin Cache Control configuration via the Rulesets/Cache Rules API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1377.txt b/pkg/cloudflare-go/.changelog/1377.txt new file mode 100644 index 000000000..0a986b020 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1377.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: allow namespaced scripts to be used as Worker tail consumers +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1379.txt b/pkg/cloudflare-go/.changelog/1379.txt new file mode 100644 index 000000000..d3d637aa9 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1379.txt @@ -0,0 +1,7 @@ +```release-note:bug +images: Fix issue parsing Image Details from API due to incorrect struct json field +``` + +```release-note:breaking-change +images: Renamed Image struct "Metadata" field to "Meta" +``` diff --git a/pkg/cloudflare-go/.changelog/1380.txt b/pkg/cloudflare-go/.changelog/1380.txt new file mode 100644 index 000000000..9c00e73ad --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1380.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +load_balancer_monitor: add support for `consecutive_up`, `consecutive_down` +``` diff --git a/pkg/cloudflare-go/.changelog/1382.txt b/pkg/cloudflare-go/.changelog/1382.txt new file mode 100644 index 000000000..6074328c9 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1382.txt @@ -0,0 +1,3 @@ +```release-note:bug +semgrep: Improved IPv4 validation by implementing a new pattern to handle cases where non-IPv4 addresses were previously accepted. +``` diff --git a/pkg/cloudflare-go/.changelog/1384.txt b/pkg/cloudflare-go/.changelog/1384.txt new file mode 100644 index 000000000..83a5c51b8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1384.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dcv_delegation: add GET for DCV Delegation UUID +``` diff --git a/pkg/cloudflare-go/.changelog/1385.txt b/pkg/cloudflare-go/.changelog/1385.txt new file mode 100644 index 000000000..64d8b965e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1385.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +tunnel: add support for `include_prefix`, `exclude_prefix` in list operations +``` diff --git a/pkg/cloudflare-go/.changelog/1386.txt b/pkg/cloudflare-go/.changelog/1386.txt new file mode 100644 index 000000000..64d0db476 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1386.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +streams: adds support for stream create parameters for tus upload initiate +``` diff --git a/pkg/cloudflare-go/.changelog/1387.txt b/pkg/cloudflare-go/.changelog/1387.txt new file mode 100644 index 000000000..10f721589 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1387.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps actions/checkout from 3 to 4 +``` diff --git a/pkg/cloudflare-go/.changelog/1388.txt b/pkg/cloudflare-go/.changelog/1388.txt new file mode 100644 index 000000000..1969407f4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1388.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 4.4.0 to 4.6.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1389.txt b/pkg/cloudflare-go/.changelog/1389.txt new file mode 100644 index 000000000..0a89e8cd5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1389.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.14.0 to 0.15.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1390.txt b/pkg/cloudflare-go/.changelog/1390.txt new file mode 100644 index 000000000..0bfd9e053 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1390.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_identity_provider: add support for email_claim_name and authorization_server_id +``` diff --git a/pkg/cloudflare-go/.changelog/1391.txt b/pkg/cloudflare-go/.changelog/1391.txt new file mode 100644 index 000000000..5d14c004e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1391.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_identity_provider: add support for ping_env_id +``` diff --git a/pkg/cloudflare-go/.changelog/1393.txt b/pkg/cloudflare-go/.changelog/1393.txt new file mode 100644 index 000000000..a3ef6c8ae --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1393.txt @@ -0,0 +1,3 @@ +```release-note:bug +dns: keep comments when calling UpdateDNSRecord with zero values of UpdateDNSRecordParams +``` diff --git a/pkg/cloudflare-go/.changelog/1394.txt b/pkg/cloudflare-go/.changelog/1394.txt new file mode 100644 index 000000000..c2afb6cf7 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1394.txt @@ -0,0 +1,3 @@ +```release_note:enhancement +rulesets: Add support for Cache Reserve configuration via the Rulesets/Cache Rules API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1396.txt b/pkg/cloudflare-go/.changelog/1396.txt new file mode 100644 index 000000000..8511d4e9c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1396.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 4.6.0 to 5.0.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1397.txt b/pkg/cloudflare-go/.changelog/1397.txt new file mode 100644 index 000000000..a3954f447 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1397.txt @@ -0,0 +1,3 @@ +```release_note:enhancement +api_shield: Add support for Get/Post/Delete operations in API Shield Endpoint Management +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1401.txt b/pkg/cloudflare-go/.changelog/1401.txt new file mode 100644 index 000000000..b1ef19e84 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1401.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +observatory: add support for observatory API +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1402.txt b/pkg/cloudflare-go/.changelog/1402.txt new file mode 100644 index 000000000..476979377 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1402.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps codecov/codecov-action from 3 to 4 +``` diff --git a/pkg/cloudflare-go/.changelog/1403.txt b/pkg/cloudflare-go/.changelog/1403.txt new file mode 100644 index 000000000..92568c880 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1403.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +access_application: Add support for tags +``` + +```release-note:enhancement +access_tag: Add support for tags +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1405.txt b/pkg/cloudflare-go/.changelog/1405.txt new file mode 100644 index 000000000..353994093 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1405.txt @@ -0,0 +1,11 @@ +```release-note:bug +account_role: autopaginate all available results instead of a static number +``` + +```release-note:breaking-change +account_role: `AccountRoles` has been renamed to `ListAccountRoles` to align with the updated method conventions +``` + +```release-note:breaking-change +account_role: `AccountRole` has been renamed to `GetAccountRole` to align with the updated method conventions +``` diff --git a/pkg/cloudflare-go/.changelog/1406.txt b/pkg/cloudflare-go/.changelog/1406.txt new file mode 100644 index 000000000..248b681c5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1406.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +api_shield_schema: Add support for managing schemas for API Shield Schema Validation 2.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1407.txt b/pkg/cloudflare-go/.changelog/1407.txt new file mode 100644 index 000000000..783277081 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1407.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add support for app launcher customization fields +``` diff --git a/pkg/cloudflare-go/.changelog/1409.txt b/pkg/cloudflare-go/.changelog/1409.txt new file mode 100644 index 000000000..3132d3b0d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1409.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +list_item: allow filtering by search term, cursor and per page attributes +``` diff --git a/pkg/cloudflare-go/.changelog/1410.txt b/pkg/cloudflare-go/.changelog/1410.txt new file mode 100644 index 000000000..589d96d96 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1410.txt @@ -0,0 +1,3 @@ +```release-note:bug +custom_nameservers: change `NSSet` from string to int to match API response +``` diff --git a/pkg/cloudflare-go/.changelog/1412.txt b/pkg/cloudflare-go/.changelog/1412.txt new file mode 100644 index 000000000..fa44dd47e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1412.txt @@ -0,0 +1,3 @@ +```release-note:bug +observatory: fix double url encoding +``` diff --git a/pkg/cloudflare-go/.changelog/1413.txt b/pkg/cloudflare-go/.changelog/1413.txt new file mode 100644 index 000000000..9030b0c29 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1413.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +api_shield_discovery: Add support for Get/Patch API Shield API Discovery Operations +``` diff --git a/pkg/cloudflare-go/.changelog/1414.txt b/pkg/cloudflare-go/.changelog/1414.txt new file mode 100644 index 000000000..4af5cf907 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1414.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +load_balancing: extend documentation for least_connections steering policy +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1415.txt b/pkg/cloudflare-go/.changelog/1415.txt new file mode 100644 index 000000000..13ecd4428 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1415.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +access_organization: Add support for session_duration +``` + +```release-note:enhancement +access_policy: Add support for session_duration +``` diff --git a/pkg/cloudflare-go/.changelog/1416.txt b/pkg/cloudflare-go/.changelog/1416.txt new file mode 100644 index 000000000..63f14fe51 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1416.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.15.0 to 0.16.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1417.txt b/pkg/cloudflare-go/.changelog/1417.txt new file mode 100644 index 000000000..03e478798 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1417.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +d1: adds support for d1 +``` diff --git a/pkg/cloudflare-go/.changelog/1418.txt b/pkg/cloudflare-go/.changelog/1418.txt new file mode 100644 index 000000000..4cc578265 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1418.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +api_shield_schema: Add support for Get/Update API Shield Schema Validation Settings +``` diff --git a/pkg/cloudflare-go/.changelog/1419.txt b/pkg/cloudflare-go/.changelog/1419.txt new file mode 100644 index 000000000..a17415283 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1419.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams: Add `audit_ssh_settings` endpoints +``` diff --git a/pkg/cloudflare-go/.changelog/1420.txt b/pkg/cloudflare-go/.changelog/1420.txt new file mode 100644 index 000000000..109580539 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1420.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.16.0 to 0.17.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1421.txt b/pkg/cloudflare-go/.changelog/1421.txt new file mode 100644 index 000000000..860754977 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1421.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.7.0 to 0.17.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1422.txt b/pkg/cloudflare-go/.changelog/1422.txt new file mode 100644 index 000000000..20d26bcc1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1422.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +api_shield_schema: Add support for Get/Update API Shield Operation Schema Validation Settings +``` diff --git a/pkg/cloudflare-go/.changelog/1423.txt b/pkg/cloudflare-go/.changelog/1423.txt new file mode 100644 index 000000000..6a668eb11 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1423.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams: Add support for body_scanning (Enhanced File Detection) in teams account configuration +``` diff --git a/pkg/cloudflare-go/.changelog/1424.txt b/pkg/cloudflare-go/.changelog/1424.txt new file mode 100644 index 000000000..f4659f00b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1424.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +teams: Add `non_identity_enabled` boolean in browser isolation settings +``` + +```release-note:breaking-change +teams: `BrowserIsolation.UrlBrowserIsolationEnabled` has changed from `bool` to `*bool` to meet the library conventions +``` diff --git a/pkg/cloudflare-go/.changelog/1427.txt b/pkg/cloudflare-go/.changelog/1427.txt new file mode 100644 index 000000000..d948ad913 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1427.txt @@ -0,0 +1,23 @@ +```release-note:enhancement +access_seats: Add UpdateAccessUserSeat() to list IP Access Rules +``` + +```release-note:enhancement +access_user: Add GetAccessUserActiveSessions() to get all active sessions for a Access/Zero-Trust user. +``` + +```release-note:enhancement +access_user: Add GetAccessUserSingleActiveSession() to get an active session for a Access/Zero-Trust user. +``` + +```release-note:enhancement +access_user: Add GetAccessUserFailedLogins() to get all failed login attempts for a Access/Zero-Trust user. +``` + +```release-note:enhancement +access_user: Add GetAccessUserLastSeenIdentity() to get last seen identity for a Access/Zero-Trust user. +``` + +```release-note:enhancement +access_user: Add ListAccessUsers() to get a list of users for a Access/Zero-Trust account. +``` diff --git a/pkg/cloudflare-go/.changelog/1428.txt b/pkg/cloudflare-go/.changelog/1428.txt new file mode 100644 index 000000000..f0c3236ea --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1428.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +ip_access_rules: Add ListIPAccessRules() to list IP Access Rules +``` diff --git a/pkg/cloudflare-go/.changelog/1433.txt b/pkg/cloudflare-go/.changelog/1433.txt new file mode 100644 index 000000000..b8cbacb05 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1433.txt @@ -0,0 +1,43 @@ +```release-note:breaking-change +devices_policy: `UpdateDeviceClientCertificatesZone` is renamed to `UpdateDeviceClientCertificates` with updated method signatures +``` + +```release-note:breaking-change +devices_policy: `GetDeviceClientCertificatesZone` is renamed to `GetDeviceClientCertificates` with updated method signatures +``` + +```release-note:breaking-change +devices_policy: `DeviceClientCertificates` is renamed to `DeviceClientCertificates` +``` + +```release-note:breaking-change +devices_policy: `GetDeviceClientCertificates` is updated with method signatures matching the library conventions +``` + +```release-note:breaking-change +devices_policy: `CreateDeviceSettingsPolicy` is updated with method signatures matching the library conventions +``` + +```release-note:breaking-change +devices_policy: `UpdateDefaultDeviceSettingsPolicy` is updated with method signatures matching the library conventions +``` + +```release-note:breaking-change +devices_policy: `UpdateDeviceSettingsPolicy` is updated with method signatures matching the library conventions +``` + +```release-note:breaking-change +devices_policy: `DeleteDeviceSettingsPolicy` is updated with method signatures matching the library conventions +``` + +```release-note:breaking-change +devices_policy: `GetDefaultDeviceSettingsPolicy` is updated with method signatures matching the library conventions +``` + +```release-note:breaking-change +devices_policy: `GetDeviceSettingsPolicy` is updated with method signatures matching the library conventions +``` + +```release-note:enhancement +devices_policy: Add support for listing device settings policies +``` diff --git a/pkg/cloudflare-go/.changelog/1434.txt b/pkg/cloudflare-go/.changelog/1434.txt new file mode 100644 index 000000000..ef9d30dad --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1434.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/time from 0.3.0 to 0.4.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1436.txt b/pkg/cloudflare-go/.changelog/1436.txt new file mode 100644 index 000000000..a3b6264dc --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1436.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_rules: Add support for resolver policies +``` diff --git a/pkg/cloudflare-go/.changelog/1438.txt b/pkg/cloudflare-go/.changelog/1438.txt new file mode 100644 index 000000000..07af199d6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1438.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.4 to 0.7.5 +``` diff --git a/pkg/cloudflare-go/.changelog/1439.txt b/pkg/cloudflare-go/.changelog/1439.txt new file mode 100644 index 000000000..372b54172 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1439.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.17.0 to 0.18.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1440.txt b/pkg/cloudflare-go/.changelog/1440.txt new file mode 100644 index 000000000..f919f069a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1440.txt @@ -0,0 +1,3 @@ +```release-note:bug +per_hostname_tls_setting: use `buildURI` for defining the query parameters when sorting +``` diff --git a/pkg/cloudflare-go/.changelog/1441.txt b/pkg/cloudflare-go/.changelog/1441.txt new file mode 100644 index 000000000..0901f1c8c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1441.txt @@ -0,0 +1,3 @@ +```release-note:bug +load_balancing: Add support for virtual network id in origins +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1442.txt b/pkg/cloudflare-go/.changelog/1442.txt new file mode 100644 index 000000000..7cb0d2373 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1442.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +load_balancing: add healthy field to LoadBalancerPool +``` diff --git a/pkg/cloudflare-go/.changelog/1444.txt b/pkg/cloudflare-go/.changelog/1444.txt new file mode 100644 index 000000000..b6edff2ec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1444.txt @@ -0,0 +1,3 @@ +```release-note:enchancement +pages_project: Add standard as a usage model +``` diff --git a/pkg/cloudflare-go/.changelog/1445.txt b/pkg/cloudflare-go/.changelog/1445.txt new file mode 100644 index 000000000..664bb2d3e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1445.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_group: Add support for email lists +``` diff --git a/pkg/cloudflare-go/.changelog/1446.txt b/pkg/cloudflare-go/.changelog/1446.txt new file mode 100644 index 000000000..a324d9ebb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1446.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +worker_bindings: add support for `d1` bindings +``` diff --git a/pkg/cloudflare-go/.changelog/1449.txt b/pkg/cloudflare-go/.changelog/1449.txt new file mode 100644 index 000000000..855b2a5e3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1449.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/time from 0.4.0 to 0.5.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1450.txt b/pkg/cloudflare-go/.changelog/1450.txt new file mode 100644 index 000000000..660b97d1c --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1450.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +worker_bindings: Fixing form element name for d1 binding +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1452.txt b/pkg/cloudflare-go/.changelog/1452.txt new file mode 100644 index 000000000..944eea80a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1452.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.18.0 to 0.19.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1453.txt b/pkg/cloudflare-go/.changelog/1453.txt new file mode 100644 index 000000000..c50362f14 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1453.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +cloudflare: Add ResultInfo to RawResponse +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1454.txt b/pkg/cloudflare-go/.changelog/1454.txt new file mode 100644 index 000000000..b0d74dbef --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1454.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +devices_policy: add fields for Opt-In Split Tunnel Overlapping IPs feature. +``` diff --git a/pkg/cloudflare-go/.changelog/1456.txt b/pkg/cloudflare-go/.changelog/1456.txt new file mode 100644 index 000000000..d8198fe44 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1456.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.25.7 to 2.26.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1457.txt b/pkg/cloudflare-go/.changelog/1457.txt new file mode 100644 index 000000000..86f0914cb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1457.txt @@ -0,0 +1,15 @@ +```release-note:enhancement +stream: Add ScheduledDeletion to StreamVideo +``` + +```release-note:enhancement +stream: Add ScheduledDeletion to StreamUploadFromURLParameters +``` + +```release-note:enhancement +stream: Add ScheduledDeletion to StreamCreateVideoParameters +``` + +```release-note:enhancement +stream: Add ScheduledDeletion to StreamVideoCreate +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1459.txt b/pkg/cloudflare-go/.changelog/1459.txt new file mode 100644 index 000000000..b7d1a74e4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1459.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +page_shield: added support for page shield +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1460.txt b/pkg/cloudflare-go/.changelog/1460.txt new file mode 100644 index 000000000..0bf5ed5f5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1460.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps actions/setup-go from 4 to 5 +``` diff --git a/pkg/cloudflare-go/.changelog/1462.txt b/pkg/cloudflare-go/.changelog/1462.txt new file mode 100644 index 000000000..179a03ba6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1462.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github/codeql-action from 2 to 3 +``` diff --git a/pkg/cloudflare-go/.changelog/1463.txt b/pkg/cloudflare-go/.changelog/1463.txt new file mode 100644 index 000000000..a6b8e2e2d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1463.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_rules: Added support for notification settings in a gateway rule +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1464.txt b/pkg/cloudflare-go/.changelog/1464.txt new file mode 100644 index 000000000..d2bb00b15 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1464.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rules: add support for Access client fields in device posture integrations +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1466.txt b/pkg/cloudflare-go/.changelog/1466.txt new file mode 100644 index 000000000..c7e1d56aa --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1466.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/crypto from 0.14.0 to 0.17.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1468.txt b/pkg/cloudflare-go/.changelog/1468.txt new file mode 100644 index 000000000..3c91f6401 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1468.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +logpush: Add support for Output Options +``` diff --git a/pkg/cloudflare-go/.changelog/1470.txt b/pkg/cloudflare-go/.changelog/1470.txt new file mode 100644 index 000000000..b4ab0eaec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1470.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/go-git/go-git/v5 from 5.4.2 to 5.11.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1471.txt b/pkg/cloudflare-go/.changelog/1471.txt new file mode 100644 index 000000000..8fb73896e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1471.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.26.0 to 2.27.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1472.txt b/pkg/cloudflare-go/.changelog/1472.txt new file mode 100644 index 000000000..7a5fd1c69 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1472.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.27.0 to 2.27.1 +``` diff --git a/pkg/cloudflare-go/.changelog/1473.txt b/pkg/cloudflare-go/.changelog/1473.txt new file mode 100644 index 000000000..9070d9eeb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1473.txt @@ -0,0 +1,3 @@ +```release-note:internal +cloudflare: bump minimum Go version to 1.19 +``` diff --git a/pkg/cloudflare-go/.changelog/1474.txt b/pkg/cloudflare-go/.changelog/1474.txt new file mode 100644 index 000000000..6c275e3ec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1474.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +zaraz: Add support for CRUD APIs +``` diff --git a/pkg/cloudflare-go/.changelog/1475.txt b/pkg/cloudflare-go/.changelog/1475.txt new file mode 100644 index 000000000..80b3e1bb3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1475.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/cloudflare/circl from 1.3.3 to 1.3.7 +``` diff --git a/pkg/cloudflare-go/.changelog/1476.txt b/pkg/cloudflare-go/.changelog/1476.txt new file mode 100644 index 000000000..4320d392e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1476.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.19.0 to 0.20.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1477.txt b/pkg/cloudflare-go/.changelog/1477.txt new file mode 100644 index 000000000..fad08d0c6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1477.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add support for default_relay_state in saas apps +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1480.txt b/pkg/cloudflare-go/.changelog/1480.txt new file mode 100644 index 000000000..0fdf1bfec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1480.txt @@ -0,0 +1,7 @@ +```release-note:bug +access_seats: UpdateAccessUserSeat: fix parameters not being an array when sending to the api. This caused an error when updating a user's seat +``` + +```release-note:enhancement +access_seats: Add `UpdateAccessUsersSeats` with an array as input for multiple operations +``` diff --git a/pkg/cloudflare-go/.changelog/1482.txt b/pkg/cloudflare-go/.changelog/1482.txt new file mode 100644 index 000000000..54712fef4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1482.txt @@ -0,0 +1,3 @@ +```release-note:bug +access_users: ListAccessUsers was returning wrong values in pointer fields due to variable missused in loop +``` diff --git a/pkg/cloudflare-go/.changelog/1483.txt b/pkg/cloudflare-go/.changelog/1483.txt new file mode 100644 index 000000000..9b8b9cdfa --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1483.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps actions/cache from 3 to 4 +``` diff --git a/pkg/cloudflare-go/.changelog/1484.txt b/pkg/cloudflare-go/.changelog/1484.txt new file mode 100644 index 000000000..980618dc1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1484.txt @@ -0,0 +1,3 @@ +```release-note:bug +flarectl: alias zone certs to "ct" instead of duplicating the "c" alias +``` diff --git a/pkg/cloudflare-go/.changelog/1485.txt b/pkg/cloudflare-go/.changelog/1485.txt new file mode 100644 index 000000000..e8a177e2e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1485.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp: add support for EDM and CWL datasets +``` diff --git a/pkg/cloudflare-go/.changelog/1486.txt b/pkg/cloudflare-go/.changelog/1486.txt new file mode 100644 index 000000000..b1da4b5a8 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1486.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_accounts: add support for extended email matching +``` diff --git a/pkg/cloudflare-go/.changelog/1489.txt b/pkg/cloudflare-go/.changelog/1489.txt new file mode 100644 index 000000000..00fd540ec --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1489.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +pages_project: Add `build_caching` attribute +``` diff --git a/pkg/cloudflare-go/.changelog/1490.txt b/pkg/cloudflare-go/.changelog/1490.txt new file mode 100644 index 000000000..20f833089 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1490.txt @@ -0,0 +1,3 @@ +```release-note:note +zaraz: replace deprecated neoEvents with Actions on Zaraz Config tools schema +``` diff --git a/pkg/cloudflare-go/.changelog/1492.txt b/pkg/cloudflare-go/.changelog/1492.txt new file mode 100644 index 000000000..f46c87416 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1492.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +hyperdrive: Add support for hyperdrive CRUD operations +``` diff --git a/pkg/cloudflare-go/.changelog/1494.txt b/pkg/cloudflare-go/.changelog/1494.txt new file mode 100644 index 000000000..ad6abdd66 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1494.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +images_variants: Add support for Images Variants CRUD operations +``` diff --git a/pkg/cloudflare-go/.changelog/1496.txt b/pkg/cloudflare-go/.changelog/1496.txt new file mode 100644 index 000000000..5d3ea2857 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1496.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +access_application: Add support for `allow_authenticate_via_warp` +``` + +```release-note:enhancement +access_organization: Add support for `allow_authenticate_via_warp` and `warp_auth_session_duration` +``` diff --git a/pkg/cloudflare-go/.changelog/1497.txt b/pkg/cloudflare-go/.changelog/1497.txt new file mode 100644 index 000000000..7bcaa6101 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1497.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp: add support for Context Awareness in DLP profiles +``` diff --git a/pkg/cloudflare-go/.changelog/1499.txt b/pkg/cloudflare-go/.changelog/1499.txt new file mode 100644 index 000000000..8272eb845 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1499.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_rules: `AntiVirus` settings includes notification settings +``` diff --git a/pkg/cloudflare-go/.changelog/1500.txt b/pkg/cloudflare-go/.changelog/1500.txt new file mode 100644 index 000000000..af3eba15b --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1500.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add support for OIDC SaaS Applications +``` diff --git a/pkg/cloudflare-go/.changelog/1501.txt b/pkg/cloudflare-go/.changelog/1501.txt new file mode 100644 index 000000000..91b8d37a4 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1501.txt @@ -0,0 +1,3 @@ +```release-note:bug +hyperdrive: password should be nested in origin +``` diff --git a/pkg/cloudflare-go/.changelog/1502.txt b/pkg/cloudflare-go/.changelog/1502.txt new file mode 100644 index 000000000..09eb13b85 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1502.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.20.0 to 0.21.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1503.txt b/pkg/cloudflare-go/.changelog/1503.txt new file mode 100644 index 000000000..4798139e0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1503.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +magic-transit: Adds IPsec tunnel healthcheck direction & rate parameters +``` diff --git a/pkg/cloudflare-go/.changelog/1504.txt b/pkg/cloudflare-go/.changelog/1504.txt new file mode 100644 index 000000000..c7a53d1b2 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1504.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golangci/golangci-lint-action from 3 to 4 +``` diff --git a/pkg/cloudflare-go/.changelog/1505.txt b/pkg/cloudflare-go/.changelog/1505.txt new file mode 100644 index 000000000..4e307037a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1505.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: add support for `name_id_transform_jsonata` in saas apps +``` diff --git a/pkg/cloudflare-go/.changelog/1506.txt b/pkg/cloudflare-go/.changelog/1506.txt new file mode 100644 index 000000000..cafcb8de1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1506.txt @@ -0,0 +1,3 @@ +```release-note:bug +registrar: Fix request method to call domain list endpoint from POST to GET +``` diff --git a/pkg/cloudflare-go/.changelog/1508.txt b/pkg/cloudflare-go/.changelog/1508.txt new file mode 100644 index 000000000..c5be0a448 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1508.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +workers_for_platforms: Add ability to list Workers for Platforms namespaces, get a namespace, create a new namespace or delete a namespace. +``` + +```release-note:enhancement +workers: Add Workers for Platforms support for getting a Worker, content and bindings +``` diff --git a/pkg/cloudflare-go/.changelog/1509.txt b/pkg/cloudflare-go/.changelog/1509.txt new file mode 100644 index 000000000..ce5c97d10 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1509.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +device_posture_rule: support last_seen and state for crowdstrike_s2s posture rule +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1510.txt b/pkg/cloudflare-go/.changelog/1510.txt new file mode 100644 index 000000000..7f3d44bb1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1510.txt @@ -0,0 +1,3 @@ +```release-note:bug +dlp: added optional ContextAwareness support +``` diff --git a/pkg/cloudflare-go/.changelog/1511.txt b/pkg/cloudflare-go/.changelog/1511.txt new file mode 100644 index 000000000..dac7f2cd0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1511.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/stretchr/testify from 1.8.4 to 1.9.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1513.txt b/pkg/cloudflare-go/.changelog/1513.txt new file mode 100644 index 000000000..e08c53816 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1513.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.21.0 to 0.22.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1516.txt b/pkg/cloudflare-go/.changelog/1516.txt new file mode 100644 index 000000000..87346e8ca --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1516.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_mutual_tls_certificates: add support for mutual tls hostname settings +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1558.txt b/pkg/cloudflare-go/.changelog/1558.txt new file mode 100644 index 000000000..a297d72ea --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1558.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps google.golang.org/protobuf from 1.28.0 to 1.33.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1562.txt b/pkg/cloudflare-go/.changelog/1562.txt new file mode 100644 index 000000000..e135f681d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1562.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: add support for `saml_attribute_transform_jsonata` in saas apps +``` diff --git a/pkg/cloudflare-go/.changelog/1573.txt b/pkg/cloudflare-go/.changelog/1573.txt new file mode 100644 index 000000000..57d4e8c77 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1573.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps actions/checkout from 2 to 4 +``` diff --git a/pkg/cloudflare-go/.changelog/1593.txt b/pkg/cloudflare-go/.changelog/1593.txt new file mode 100644 index 000000000..893367291 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1593.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.6.0 to 1.7.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1600.txt b/pkg/cloudflare-go/.changelog/1600.txt new file mode 100644 index 000000000..eee34a863 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1600.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp: Adds support for ocr_enabled boolean flag +``` diff --git a/pkg/cloudflare-go/.changelog/1607.txt b/pkg/cloudflare-go/.changelog/1607.txt new file mode 100644 index 000000000..7fee8ceb1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1607.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 1.7.0 to 2.0.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1615.txt b/pkg/cloudflare-go/.changelog/1615.txt new file mode 100644 index 000000000..3025209f5 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1615.txt @@ -0,0 +1,3 @@ +```release-note:bug +teams_rules: add "resolve" to allowable actions +``` diff --git a/pkg/cloudflare-go/.changelog/1618.txt b/pkg/cloudflare-go/.changelog/1618.txt new file mode 100644 index 000000000..eae139f30 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1618.txt @@ -0,0 +1,3 @@ +```release-note:breaking-change +dns: Remove "locked" flag which is always false +``` diff --git a/pkg/cloudflare-go/.changelog/1688.txt b/pkg/cloudflare-go/.changelog/1688.txt new file mode 100644 index 000000000..c9a9d3615 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1688.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.22.0 to 0.24.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1710.txt b/pkg/cloudflare-go/.changelog/1710.txt new file mode 100644 index 000000000..86b05e351 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1710.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +magic_transit_ipsec_tunnel: Adds support for replay_protection boolean flag +``` diff --git a/pkg/cloudflare-go/.changelog/1737.txt b/pkg/cloudflare-go/.changelog/1737.txt new file mode 100644 index 000000000..e8538c834 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1737.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +workers: support deleting namespaced Workers +``` diff --git a/pkg/cloudflare-go/.changelog/1790.txt b/pkg/cloudflare-go/.changelog/1790.txt new file mode 100644 index 000000000..d5ac346dd --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1790.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: support options_preflight_bypass for access_application +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1811.txt b/pkg/cloudflare-go/.changelog/1811.txt new file mode 100644 index 000000000..86b6399a2 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1811.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_account: adds custom certificate setting to teams account configuration +``` diff --git a/pkg/cloudflare-go/.changelog/1825.txt b/pkg/cloudflare-go/.changelog/1825.txt new file mode 100644 index 000000000..c0bf7ae54 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1825.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.19.0 to 0.23.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1826.txt b/pkg/cloudflare-go/.changelog/1826.txt new file mode 100644 index 000000000..7538106f3 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1826.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +gateway: added ecs_support field to teams_location resource +``` diff --git a/pkg/cloudflare-go/.changelog/1832.txt b/pkg/cloudflare-go/.changelog/1832.txt new file mode 100644 index 000000000..4f09ea2c1 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1832.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +ruleset: add support for action parameters `fonts` and `disable_rum` +``` diff --git a/pkg/cloudflare-go/.changelog/1839.txt b/pkg/cloudflare-go/.changelog/1839.txt new file mode 100644 index 000000000..3cecfe483 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1839.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps dependabot/fetch-metadata from 2.0.0 to 2.1.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1845.txt b/pkg/cloudflare-go/.changelog/1845.txt new file mode 100644 index 000000000..ec9370149 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1845.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golangci/golangci-lint-action from 4 to 5 +``` diff --git a/pkg/cloudflare-go/.changelog/1861.txt b/pkg/cloudflare-go/.changelog/1861.txt new file mode 100644 index 000000000..a904c2c18 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1861.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/urfave/cli/v2 from 2.27.1 to 2.27.2 +``` diff --git a/pkg/cloudflare-go/.changelog/1887.txt b/pkg/cloudflare-go/.changelog/1887.txt new file mode 100644 index 000000000..cb86d3beb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1887.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +dlp: add support for zt risk behavior configuration +``` diff --git a/pkg/cloudflare-go/.changelog/1921.txt b/pkg/cloudflare-go/.changelog/1921.txt new file mode 100644 index 000000000..a36bc61f6 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1921.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: add support for `scim_config` +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1956.txt b/pkg/cloudflare-go/.changelog/1956.txt new file mode 100644 index 000000000..2f149db7d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1956.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +access_policy: add support for reusable policies +``` + +```release-note:enhancement +access_application: add support for `policies` array +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1959.txt b/pkg/cloudflare-go/.changelog/1959.txt new file mode 100644 index 000000000..a5ab17228 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1959.txt @@ -0,0 +1,3 @@ +```release-note:bug +access_application: fix scim configuration authentication json marshalling +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/1974.txt b/pkg/cloudflare-go/.changelog/1974.txt new file mode 100644 index 000000000..15e7a6af0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1974.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.24.0 to 0.25.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1975.txt b/pkg/cloudflare-go/.changelog/1975.txt new file mode 100644 index 000000000..a44e19152 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1975.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golangci/golangci-lint-action from 5 to 6 +``` diff --git a/pkg/cloudflare-go/.changelog/1981.txt b/pkg/cloudflare-go/.changelog/1981.txt new file mode 100644 index 000000000..71d4d074e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1981.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add Refresh Token, Custom Claims, and PKCE Without Client Secret support for OIDC SaaS configurations +``` diff --git a/pkg/cloudflare-go/.changelog/1991.txt b/pkg/cloudflare-go/.changelog/1991.txt new file mode 100644 index 000000000..d011b7590 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1991.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps bflad/action-milestone-comment from 1 to 2 +``` diff --git a/pkg/cloudflare-go/.changelog/1992.txt b/pkg/cloudflare-go/.changelog/1992.txt new file mode 100644 index 000000000..d72844282 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1992.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 5.0.0 to 5.1.0 +``` diff --git a/pkg/cloudflare-go/.changelog/1993.txt b/pkg/cloudflare-go/.changelog/1993.txt new file mode 100644 index 000000000..86356e942 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/1993.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.5 to 0.7.6 +``` diff --git a/pkg/cloudflare-go/.changelog/2107.txt b/pkg/cloudflare-go/.changelog/2107.txt new file mode 100644 index 000000000..f76e36eeb --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2107.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps `github.com/goccy/go-json` from 0.10.2 to 0.10.3 +``` diff --git a/pkg/cloudflare-go/.changelog/2126.txt b/pkg/cloudflare-go/.changelog/2126.txt new file mode 100644 index 000000000..47770bd2e --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2126.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_accounts: Add `use_zt_virtual_ip` attribute +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/2131.txt b/pkg/cloudflare-go/.changelog/2131.txt new file mode 100644 index 000000000..d32928a2d --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2131.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add support for Hybrid/Implicit flows and options +``` diff --git a/pkg/cloudflare-go/.changelog/2165.txt b/pkg/cloudflare-go/.changelog/2165.txt new file mode 100644 index 000000000..d053fdc62 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2165.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +teams_account: Add Zero Trust connectivity settings +``` \ No newline at end of file diff --git a/pkg/cloudflare-go/.changelog/2249.txt b/pkg/cloudflare-go/.changelog/2249.txt new file mode 100644 index 000000000..4130a0f66 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2249.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.6 to 0.7.7 +``` diff --git a/pkg/cloudflare-go/.changelog/2364.txt b/pkg/cloudflare-go/.changelog/2364.txt new file mode 100644 index 000000000..3e6fdd72a --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2364.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps golang.org/x/net from 0.25.0 to 0.26.0 +``` diff --git a/pkg/cloudflare-go/.changelog/2365.txt b/pkg/cloudflare-go/.changelog/2365.txt new file mode 100644 index 000000000..ddf0506c9 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2365.txt @@ -0,0 +1,3 @@ +```release-note:dependency +deps: bumps goreleaser/goreleaser-action from 5.1.0 to 6.0.0 +``` diff --git a/pkg/cloudflare-go/.changelog/2455.txt b/pkg/cloudflare-go/.changelog/2455.txt new file mode 100644 index 000000000..a3908aeb0 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/2455.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +access_application: Add support for SaaS OIDC Access Token Lifetime +``` diff --git a/pkg/cloudflare-go/.changelog/998.txt b/pkg/cloudflare-go/.changelog/998.txt new file mode 100644 index 000000000..0b3aa6633 --- /dev/null +++ b/pkg/cloudflare-go/.changelog/998.txt @@ -0,0 +1,7 @@ +```release-note:enhancement +rulesets: add support for `http_custom_errors` phase +``` + +```release-note:enhancement +rulesets: add support for `serve_error` action +``` diff --git a/pkg/cloudflare-go/.devcontainer/Dockerfile b/pkg/cloudflare-go/.devcontainer/Dockerfile new file mode 100644 index 000000000..28b19a666 --- /dev/null +++ b/pkg/cloudflare-go/.devcontainer/Dockerfile @@ -0,0 +1 @@ +FROM golang:1.19 diff --git a/pkg/cloudflare-go/.devcontainer/devcontainer.json b/pkg/cloudflare-go/.devcontainer/devcontainer.json new file mode 100644 index 000000000..691cbd1c5 --- /dev/null +++ b/pkg/cloudflare-go/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +{ + "name": "Go", + "build": { + "dockerfile": "Dockerfile" + }, + "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"], + "containerEnv": { + "GOPATH": "/go" + }, + "remoteEnv": { + "GOPATH": "${containerEnv:GOPATH}" + }, + "onCreateCommand": "go generate -tags tools internal/tools/tools.go", + "customizations": { + "vscode": { + "settings": { + "go.toolsManagement.checkForUpdates": "local", + "go.useLanguageServer": true, + "go.gopath": "/go", + "[go]": { + "editor.formatOnSave": true + }, + "go.formatTool": "gofmt", + "go.formatFlags": ["-w", "-s"], + "go.toolsManagement.autoUpdate": true, + "go.lintTool": "golangci-lint", + "go.lintFlags": ["--fast"], + "gopls": { + "ui.semanticTokens": true + }, + "go.survey.prompt": false + }, + "extensions": ["golang.Go"] + } + } +} diff --git a/pkg/cloudflare-go/.github/CODEOWNERS b/pkg/cloudflare-go/.github/CODEOWNERS new file mode 100644 index 000000000..a5d8a3a9b --- /dev/null +++ b/pkg/cloudflare-go/.github/CODEOWNERS @@ -0,0 +1,5 @@ + +# Own a service and want to be defined as an owner here? Open a PR or +# hit up @jacobbednarz to add your team. + +* @jacobbednarz diff --git a/pkg/cloudflare-go/.github/ISSUE_TEMPLATE/bug.yml b/pkg/cloudflare-go/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..0a836baac --- /dev/null +++ b/pkg/cloudflare-go/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: "\U0001F41B Bug report" +description: "When something isn't working as expected or documented" +labels: ["kind/bug", "needs-triage"] +body: +- type: checkboxes + attributes: + label: Confirmation + description: Please make sure to have followed the following checks. + options: + - label: My issue isn't already found on the issue tracker. + required: true + - label: I have replicated my issue using the latest version of the library and it is still present. + required: true +- type: input + attributes: + label: cloudflare-go version + validations: + required: true +- type: textarea + attributes: + label: Go environment + description: Output from `go env`. + validations: + required: true +- type: textarea + attributes: + label: Expected output + description: What did you expect to happen? + validations: + required: true +- type: textarea + attributes: + label: Actual output + description: What actually happened? + validations: + required: true +- type: textarea + attributes: + label: Code demonstrating the issue + description: | + No need to wrap the code in backticks, it will be automatically rendered + as Go in the final issue. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true +- type: textarea + attributes: + label: Steps to reproduce + description: How can your issue be replicated? + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true +- type: textarea + attributes: + label: References + description: | + Are there any other GitHub issues (open or closed) or Pull Requests that + should be linked here? + validations: + required: false diff --git a/pkg/cloudflare-go/.github/ISSUE_TEMPLATE/config.yml b/pkg/cloudflare-go/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..0002ca53c --- /dev/null +++ b/pkg/cloudflare-go/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: v2.0.0 Beta feedback + url: https://github.com/cloudflare/cloudflare-go/discussions/1538 + about: | + If you have a feature request or feedback on our new v2 library, please comment in our discussion page. + - name: Cloudflare support + url: https://developers.cloudflare.com/support/contacting-cloudflare-support + about: | + Please only file issues here that you believe represent actual bugs for the Cloudflare Go library. + If you're having general trouble with the Cloudflare API, please visit our help center to get support. diff --git a/pkg/cloudflare-go/.github/PULL_REQUEST_TEMPLATE.md b/pkg/cloudflare-go/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..5e7ad4252 --- /dev/null +++ b/pkg/cloudflare-go/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,45 @@ + + +## Description + + +## Has your change been tested? + + + +## Screenshots (if appropriate): + +## Types of changes + +What sort of change does your code introduce/modify? + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist: + +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. +- [ ] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) + and relies on stable APIs. + +[1]: https://help.github.com/articles/closing-issues-using-keywords/ diff --git a/pkg/cloudflare-go/.github/dependabot.yml b/pkg/cloudflare-go/.github/dependabot.yml new file mode 100644 index 000000000..6d349bc3c --- /dev/null +++ b/pkg/cloudflare-go/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: +- package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" +- package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" diff --git a/pkg/cloudflare-go/.github/labels.yml b/pkg/cloudflare-go/.github/labels.yml new file mode 100644 index 000000000..f31c97f0e --- /dev/null +++ b/pkg/cloudflare-go/.github/labels.yml @@ -0,0 +1,208 @@ +- name: "kind: breaking-change" + description: "" + color: e11d21 +- name: "kind: bug" + description: Categorizes issue or PR as related to a bug. + color: e11d21 +- name: 'kind: generation-issue' + description: Categorizes issue or PR as related to the generation pipeline. + color: e11d21 +- name: "kind: documentation" + description: Categorizes issue or PR as related to documentation. + color: d4c5f9 +- name: "kind: enhancement" + description: Categorizes issue or PR as related to improving an existing feature. + color: d4c5f9 +- name: "kind: failing-test" + description: | + Categorizes issue or PR as related to a consistently or frequently failing + test. + color: e11d21 +- name: "kind: flakey" + description: Categorizes issue or PR as related to a flaky test. + color: e11d21 +- name: "kind: regression" + description: Categorizes issue or PR as related to a regression from a prior release. + color: e11d21 +- name: "kind: support" + description: Categorizes issue or PR as related to user support. + color: e11d21 +- name: "lifecycle: stale" + description: "" + color: e11d21 +- name: "likelihood: all" + description: Categorizes issue or PR as impacting all users. + color: F7D2B6 +- name: "likelihood: few" + description: Categorizes issue or PR as impacting a small portion of users. + color: F7D2B6 +- name: "likelihood: low" + description: Categorizes issue or PR as impacting a low portion of users. + color: F7D2B6 +- name: "likelihood: many" + description: Categorizes issue or PR as impacting many users. + color: F7D2B6 +- name: "likelihood: most" + description: Categorizes issue or PR as impacting most users. + color: F7D2B6 +- name: needs-triage + description: "Indicates an issue or PR lacks a `triage: foo` label and requires one." + color: ef9ed3 +- name: "service: access" + description: Categorizes issue or PR as related to the Access service. + color: 98D063 +- name: "service: addressing" + description: Categorizes issue or PR as related to the Addressing service. + color: 98D063 +- name: "service: api-shield" + description: Categorizes issue or PR as related to the API shield service. + color: 98D063 +- name: "service: argo" + description: Categorizes issue or PR as related to the Argo service. + color: 98D063 +- name: "service: bot-management" + description: Categorizes issue or PR as related to the Bot Management service. + color: 98D063 +- name: "service: byoip" + description: Categorizes issue or PR as related to the BYOIP service. + color: 98D063 +- name: "service: cache" + description: Categorizes issue or PR as related to the Content Delivery service. + color: 98D063 +- name: "service: custom-pages" + description: Categorizes issue or PR as related to the custom pages service. + color: 98D063 +- name: "service: d1" + description: Categorizes issue or PR as related to the D1 service. + color: 98D063 +- name: "service: dns" + description: Categorizes issue or PR as related to the DNS service. + color: 98D063 +- name: "service: durable-objects" + description: Categorizes issue or PR as related to the Durable Objects service. + color: 98D063 +- name: "service: firewall" + description: Categorizes issue or PR as related to the Firewall service. + color: 98D063 +- name: "service: gateway" + description: Categorizes issue or PR as related to the Zero Trust Gateway service. + color: 98D063 +- name: "service: iam" + description: Categorizes issue or PR as related to the IAM service. + color: 98D063 +- name: "service: kv" + description: Categorizes issue or PR as related to the KV service. + color: 98D063 +- name: "service: list" + description: Categorizes issue or PR as related to the List service. + color: 98D063 +- name: "service: lists" + description: Categorizes issue or PR as related to the Lists service. + color: 98D063 +- name: "service: load-balancing" + description: Categorizes issue or PR as related to the Load Balancing service. + color: 98D063 +- name: "service: logs" + description: Categorizes issue or PR as related to the Logging services. + color: 98D063 +- name: "service: magic-transit" + description: Categorizes issue or PR as related to Magic Transit services. + color: 98D063 +- name: "service: magic-wan" + description: Categorizes issue or PR as related to the Magic WAN service. + color: 98D063 +- name: "service: notifications" + description: Categorizes issue or PR as related to the notification service. + color: 98D063 +- name: "service: page-rules" + description: Categorizes issue or PR as related to the Page Rules service. + color: 98D063 +- name: "service: pages" + description: Categorizes issue or PR as related to the Pages service. + color: 98D063 +- name: "service: r2" + description: Categorizes issue or PR as related to the R2 service. + color: 98D063 +- name: "service: rulesets" + description: Categorizes issue or PR as related to the Rulesets service. + color: 98D063 +- name: "service: spectrum" + description: Categorizes issue or PR as related to the Spectrum service. + color: 98D063 +- name: "service: tls" + description: Categorizes issue or PR as related to the TLS services. + color: 98D063 +- name: "service: tunnel" + description: Categorizes issue or PR as related to the Tunnel service. + color: 98D063 +- name: "service: turnstile" + description: | + Categorizes issue or PR as related to Turnstile and the Challenge Platform + service. + color: 98D063 +- name: "service: workers" + description: Categorizes issue or PR as related to the Workers service. + color: 98D063 +- name: "service: zero-trust-devices" + description: Categorizes issue or PR as related to the Zero Trust Devices service. + color: 98D063 +- name: "service: zones" + description: Categorizes issue or PR as related to the Zones service. + color: 98D063 +- name: spam + description: "" + color: e6f99f +- name: "triage: accepted" + description: Indicates an issue or PR is ready to be actively worked on. + color: fbca04 +- name: "triage: duplicate" + description: Indicates an issue is a duplicate of other open issue. + color: fbca04 +- name: "triage: needs-information" + description: Indicates an issue needs more information in order to work on it. + color: fbca04 +- name: "triage: not-reproducible" + description: Indicates an issue can not be reproduced as described. + color: fbca04 +- name: "triage: unresolved" + description: Indicates an issue that can not or will not be resolved. + color: fbca04 +- name: "workflow: needs-review" + description: Indicates an issue or PR needs review or feedback. + color: 006b75 +- name: "workflow: pending-cloudflare-response" + description: Indicates an issue or PR requires a response from the Cloudflare team. + color: 006b75 +- name: "workflow: pending-contributor-response" + description: Indicates an issue or PR requires a response from a contributor. + color: 006b75 +- name: "workflow: pending-maintainer-response" + description: Indicates an issue or PR requires a response from the maintainer team. + color: 006b75 +- name: "workflow: pending-op-response" + description: Indicates an issue or PR requires a response from the original poster. + color: 006b75 +- name: "workflow: pending-public-documentation" + description: | + Indicates an issue or PR requires changes to public documentation confirming + suitability for use. + color: 006b75 +- name: "workflow: pending-upstream-library" + description: Indicates an issue or PR requires changes from an upstream library. + color: 006b75 +- name: "workflow: pending-schemas" + description: Indicates an issue or PR requires changes from an upstream library. + color: 006b75 +- name: "workflow: synced" + description: "" + color: 006b75 + +# autorelease management +- name: "autorelease: custom version" + color: ededed +- name: "autorelease: pending" + color: ededed +- name: "autorelease: pre-release" + color: ededed +- name: "autorelease: tagged" + color: ededed diff --git a/pkg/cloudflare-go/.github/stale.yml b/pkg/cloudflare-go/.github/stale.yml new file mode 100644 index 000000000..3cb76e884 --- /dev/null +++ b/pkg/cloudflare-go/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 180 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 30 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/pkg/cloudflare-go/.github/workflows/changelog-check.yml b/pkg/cloudflare-go/.github/workflows/changelog-check.yml new file mode 100644 index 000000000..fe273f235 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/changelog-check.yml @@ -0,0 +1,19 @@ +name: Changelog check +on: [pull_request_target] + +jobs: + changelog-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'internal/tools/go.mod' + - run: go generate -tags tools internal/tools/tools.go + - run: go run cmd/changelog-check/main.go ${{ github.event.pull_request.number }} + working-directory: ./internal/tools + env: + GITHUB_OWNER: cloudflare + GITHUB_REPO: cloudflare-go + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/pkg/cloudflare-go/.github/workflows/codeql-analysis.yml b/pkg/cloudflare-go/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..8321177b3 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/codeql-analysis.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '41 2 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'go' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/pkg/cloudflare-go/.github/workflows/dependabot-changelog.yml b/pkg/cloudflare-go/.github/workflows/dependabot-changelog.yml new file mode 100644 index 000000000..8c8d83877 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/dependabot-changelog.yml @@ -0,0 +1,35 @@ +name: Add CHANGELOG for dependabot changes +on: pull_request_target +permissions: + pull-requests: write + issues: write + repository-projects: write + contents: write +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} + steps: + - name: Fetch dependabot metadata + id: dependabot-metadata + uses: dependabot/fetch-metadata@v2.1.0 + - uses: actions/checkout@v4 + - run: | + gh pr checkout $PR_URL + cat << EOF > .changelog/$PR_NUMBER.txt + \`\`\`release-note:dependency + deps: bumps $DEP_NAME from $DEP_PREV_VERSION to $DEP_NEXT_VERSION + \`\`\` + EOF + git config user.name github-actions[bot] + git config user.email github-actions[bot]@users.noreply.github.com + git add .changelog/$PR_NUMBER.txt + git commit -m "add CHANGELOG for #$PR_NUMBER" + git push + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DEP_NAME: ${{ steps.dependabot-metadata.outputs.dependency-names }} + DEP_PREV_VERSION: ${{ steps.dependabot-metadata.outputs.previous-version }} + DEP_NEXT_VERSION: ${{ steps.dependabot-metadata.outputs.new-version }} diff --git a/pkg/cloudflare-go/.github/workflows/generate-changelog.yml b/pkg/cloudflare-go/.github/workflows/generate-changelog.yml new file mode 100644 index 000000000..73de29182 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/generate-changelog.yml @@ -0,0 +1,31 @@ +name: Generate CHANGELOG +on: + pull_request_target: + types: [closed] + workflow_dispatch: +jobs: + GenerateChangelog: + if: github.event.pull_request.merged || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'internal/tools/go.mod' + - run: go generate -tags tools internal/tools/tools.go + - run: ./scripts/generate-changelog.sh + - run: | + if [[ `git status --porcelain` ]]; then + if ${{github.event_name == 'workflow_dispatch'}}; then + MSG="Update CHANGELOG.md (Manual Trigger)" + else + MSG="Update CHANGELOG.md for #${{ github.event.pull_request.number }}" + fi + git config --local user.email changelogbot@cloudflare.com + git config --local user.name changelogbot + git add CHANGELOG.md + git commit -m "$MSG" + git push + fi diff --git a/pkg/cloudflare-go/.github/workflows/lint.yml b/pkg/cloudflare-go/.github/workflows/lint.yml new file mode 100644 index 000000000..ea4426083 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +name: Lint +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review +jobs: + golangci-lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go${{ matrix.go-version }}-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: "--config .golintci.yaml" + only-new-issues: true # only show new issues in the PR, not all. + + semgrep: + name: semgrep + runs-on: ubuntu-latest + container: + image: returntocorp/semgrep + if: (github.actor != 'dependabot[bot]') + steps: + - uses: actions/checkout@v4 + - run: semgrep ci --config .semgrep.yml + env: + # only trigger for files in this change + SEMGREP_BASELINE_BRANCH: ${{ github.head_ref }} + + # structslop: + # name: structslop + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-go@v3 + # with: + # go-version: "1.18" + # - name: structslop + # run: | + # go generate -tags tools tools/tools.go + # $(go env GOPATH)/bin/structslop . diff --git a/pkg/cloudflare-go/.github/workflows/lock-released-issues.yml b/pkg/cloudflare-go/.github/workflows/lock-released-issues.yml new file mode 100644 index 000000000..f44571e65 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/lock-released-issues.yml @@ -0,0 +1,29 @@ +name: Lock released issues and PRs + +on: + workflow_dispatch: + inputs: + issue_list: + description: Comma seperated (no spaces) list of issues/PRs to lock + required: true + +permissions: + issues: write + pull-requests: write + +jobs: + lock-closed-issues: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: | + IFS=',' read -r -a issues <<< "$ISSUES" + for element in "${issues[@]}" + do + echo "Locking $element" + echo "no" | gh pr lock -r resolved $element || true + echo "no" | gh issue lock -r resolved $element || true + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUES: ${{ github.event.inputs.issue_list }} diff --git a/pkg/cloudflare-go/.github/workflows/milestone-closed.yml b/pkg/cloudflare-go/.github/workflows/milestone-closed.yml new file mode 100644 index 000000000..bea4e2f76 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/milestone-closed.yml @@ -0,0 +1,40 @@ +name: Closed Milestones + +on: + milestone: + types: [closed] + +permissions: + issues: write + pull-requests: write + +jobs: + comment-on-closed-milestone: + runs-on: ubuntu-latest + outputs: + ids: ${{ steps.milestone-comment.outputs.ids }} + steps: + - id: milestone-comment + uses: bflad/action-milestone-comment@v2 + with: + body: | + This functionality has been released in [${{ github.event.milestone.title }}](https://github.com/${{ github.repository }}/releases/tag/${{ github.event.milestone.title }}). + + For further feature requests or bug reports with this functionality, please create a [new GitHub issue](https://github.com/${{ github.repository }}/issues/new/choose) following the template. Thank you! + + lock-closed-issues: + runs-on: ubuntu-latest + needs: comment-on-closed-milestone + steps: + - uses: actions/checkout@v4 + - run: | + IFS=',' read -r -a issues <<< "$ISSUES" + for element in "${issues[@]}" + do + echo "Locking $element" + echo "no" | gh pr lock -r resolved $element || true + echo "no" | gh issue lock -r resolved $element || true + done + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUES: ${{ needs.comment-on-closed-milestone.outputs.ids }} diff --git a/pkg/cloudflare-go/.github/workflows/milestones.yml b/pkg/cloudflare-go/.github/workflows/milestones.yml new file mode 100644 index 000000000..d161b0955 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/milestones.yml @@ -0,0 +1,25 @@ +on: + pull_request_target: + types: [closed] +name: Add merged PR and linked issues to current milestone of target branch +jobs: + add-merged-to-current-milestone: + if: github.event.pull_request.merged + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + - id: get-current-milestone + run: | + echo ::set-output name=current_milestone::v$(head -1 CHANGELOG.md | cut -d " " -f 2) + - run: echo ${{ steps.get-current-milestone.outputs.current_milestone }} + - id: get-milestone-id + run: | + echo ::set-output name=milestone_id::$(curl -H "Authorization: Bearer ${{secrets.GITHUB_TOKEN}}" https://api.github.com/repos/${{ github.repository_owner }}/${{ github.event.repository.name }}/milestones | jq 'map(select(.title == "${{ steps.get-current-milestone.outputs.current_milestone }}"))[0].number') + - run: echo ${{ steps.get-milestone-id.outputs.milestone_id }} + - uses: breathingdust/current-milestone-action@v4 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + pull_number: ${{ github.event.pull_request.number }} + milestone_number: ${{ steps.get-milestone-id.outputs.milestone_id }} diff --git a/pkg/cloudflare-go/.github/workflows/release.yml b/pkg/cloudflare-go/.github/workflows/release.yml new file mode 100644 index 000000000..7abfac5e1 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6.0.0 + with: + version: latest + args: release --clean + workdir: cmd/flarectl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/pkg/cloudflare-go/.github/workflows/sync-labels.yml b/pkg/cloudflare-go/.github/workflows/sync-labels.yml new file mode 100644 index 000000000..fd69921cf --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/sync-labels.yml @@ -0,0 +1,18 @@ +name: Sync labels +on: + workflow_dispatch: + push: + branches: + - master + paths: + - .github/labels.yml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: micnncim/action-label-syncer@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + manifest: .github/labels.yml diff --git a/pkg/cloudflare-go/.github/workflows/test.yml b/pkg/cloudflare-go/.github/workflows/test.yml new file mode 100644 index 000000000..1405737f7 --- /dev/null +++ b/pkg/cloudflare-go/.github/workflows/test.yml @@ -0,0 +1,22 @@ +on: [pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: ["1.20", "1.21", "1.22"] + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go${{ matrix.go-version }}-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }} + - name: Vet + run: go vet ./... + - name: Test + run: go test -v -race ./... diff --git a/pkg/cloudflare-go/.gitignore b/pkg/cloudflare-go/.gitignore new file mode 100644 index 000000000..0953e13b2 --- /dev/null +++ b/pkg/cloudflare-go/.gitignore @@ -0,0 +1,6 @@ +.idea +.vscode/ +cmd/flarectl/dist/ +cmd/flarectl/flarectl +cmd/flarectl/flarectl.exe +.flox/ diff --git a/pkg/cloudflare-go/.golintci.yaml b/pkg/cloudflare-go/.golintci.yaml new file mode 100644 index 000000000..d5d1c9397 --- /dev/null +++ b/pkg/cloudflare-go/.golintci.yaml @@ -0,0 +1,33 @@ +run: + timeout: 5m + issues-exit-code: 1 + tests: true + skip-dirs-use-default: true + modules-download-mode: readonly + +linters: + enable: + - bodyclose # ensure HTTP response bodies are successfully closed. + - contextcheck # check we are passing context an inherited context. + - gofmt # checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification. + - errname # checks that sentinel errors are prefixed with the `Err`` and error types are suffixed with the `Error``. + - errorlint # used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. + - godot # check if comments end in a period. + - misspell # finds commonly misspelled English words in comments. + - nilerr # checks that there is no simultaneous return of nil error and an invalid value. + - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes. + - unparam # reports unused function parameters. + - whitespace # detection of leading and trailing whitespace. + - gosec # inspects source code for security problems. + - bidichk # checks for dangerous unicode character sequences. + - exportloopref # prevent scope issues with pointers in loops. + - goconst # use constants where values are repeated. + - reassign # checks that package variables are not reassigned. + - goimports # checks import grouping and code formatting. + +output: + format: colored-line-number + +linters-settings: + goconst: + ignore-tests: true diff --git a/pkg/cloudflare-go/.semgrep.yml b/pkg/cloudflare-go/.semgrep.yml new file mode 100644 index 000000000..e0a6dc4a5 --- /dev/null +++ b/pkg/cloudflare-go/.semgrep.yml @@ -0,0 +1,88 @@ +rules: + - id: rfc-5737-ip-address + languages: + - go + message: Where a real IPv4 address isn't needed, use IPv4 addresses from RFC5737. + paths: + include: + - '*.go' + patterns: + - pattern-regex: '(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' + - pattern-not-regex: '10\.\d+\.\d+.\d+' + - pattern-not-regex: '192\.168\.\d+.\d+' + - pattern-not-regex: '192\.0\.2\.\d+' # 192.0.2.0/24 (TEST-NET-1, rfc5737) + - pattern-not-regex: '198\.51\.100\.\d+' # 198.51.100.0/24 (TEST-NET-2, rfc5737) + - pattern-not-regex: '203\.0\.113\.\d+' # 203.0.113.0/24 (TEST-NET-3, rfc5737) + severity: WARNING + - id: rfc-3849-ip-address + languages: + - generic + message: Where a real IPv6 address isn't needed, use IPv6 addresses from RFC3849. + paths: + include: + - '*.go' + patterns: + - pattern-regex: '(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))' + severity: WARNING + + - id: calling-errors-wrap + languages: [go] + message: 'Do not call `errors.Wrap`. Use `fmt.Errorf(".. : %w", err)` instead.' + patterns: + - pattern-either: + - pattern: | + errors.Wrap(...) + severity: WARNING + - id: no-use-of-stdlib-json + languages: + - go + message: Favour "github.com/goccy/go-json" instead of "encoding/json" to allow better customisation of marshaling and unmarshaling JSON attributes. See https://github.com/cloudflare/cloudflare-go/pull/1360 for full details. + paths: + include: + - '*.go' + patterns: + - pattern-regex: '\"encoding\/json\"' + fix-regex: + regex: \".*\" + replacement: '"github.com/goccy/go-json"' + severity: WARNING + - id: use-pointers-for-booleans + languages: + - go + message: | + Booleans should always be represented as pointers in structs with an omitempty marshaling tag (most commonly as JSON). This ensures you can determine unset, false and truthy values. + paths: + include: + - '*.go' + patterns: + - pattern-regex: "bool" + - pattern-not-regex: "\\*bool" + - pattern-either: + - pattern-inside: | + type $STRUCTNAME struct { + ... + } + severity: WARNING + metadata: + references: + - https://github.com/cloudflare/cloudflare-go/blob/master/docs/conventions.md#booleans + - id: use-pointers-for-time + languages: + - go + message: | + time.Time should always be represented as pointers in structs. + paths: + include: + - '*.go' + patterns: + - pattern-regex: "time\\.Time" + - pattern-not-regex: "\\*time\\.Time" + - pattern-either: + - pattern-inside: | + type $STRUCTNAME struct { + ... + } + severity: WARNING + metadata: + references: + - https://github.com/cloudflare/cloudflare-go/blob/master/docs/conventions.md#timetime diff --git a/pkg/cloudflare-go/.semgrepignore b/pkg/cloudflare-go/.semgrepignore new file mode 100644 index 000000000..e12718f03 --- /dev/null +++ b/pkg/cloudflare-go/.semgrepignore @@ -0,0 +1,22 @@ +# Based on the default .semgrepignore documented here: + +# https://semgrep.dev/docs/ignoring-files-folders-code/#understanding-semgrep-defaults + +# but not ignoring '\*\_test.go'. + +# Ignore git items + +.gitignore +.git/ +:include .gitignore + +.semgrep +.semgrep_logs/ + +.github/ +.vscode/ +.changelog/ +CHANGELOG.md +go.mod +go.sum +README.md diff --git a/pkg/cloudflare-go/CHANGELOG.md b/pkg/cloudflare-go/CHANGELOG.md new file mode 100644 index 000000000..7c85aa340 --- /dev/null +++ b/pkg/cloudflare-go/CHANGELOG.md @@ -0,0 +1,1140 @@ +## 0.98.0 (Unreleased) + +## 0.97.0 (June 5th, 2024) + +ENHANCEMENTS: + +* access_application: Add support for Hybrid/Implicit flows and options ([#2131](https://github.com/cloudflare/cloudflare-go/issues/2131)) +* teams_account: Add Zero Trust connectivity settings ([#2165](https://github.com/cloudflare/cloudflare-go/issues/2165)) +* teams_accounts: Add `use_zt_virtual_ip` attribute ([#2126](https://github.com/cloudflare/cloudflare-go/issues/2126)) + +DEPENDENCIES: + +* deps: bumps `github.com/goccy/go-json` from 0.10.2 to 0.10.3 ([#2107](https://github.com/cloudflare/cloudflare-go/issues/2107)) +* deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.6 to 0.7.7 ([#2249](https://github.com/cloudflare/cloudflare-go/issues/2249)) + +## 0.96.0 (May 22nd, 2024) + +ENHANCEMENTS: + +* access_application: Add Refresh Token, Custom Claims, and PKCE Without Client Secret support for OIDC SaaS configurations ([#1981](https://github.com/cloudflare/cloudflare-go/issues/1981)) +* ruleset: add support for action parameters `fonts` and `disable_rum` ([#1832](https://github.com/cloudflare/cloudflare-go/issues/1832)) + +DEPENDENCIES: + +* deps: bumps bflad/action-milestone-comment from 1 to 2 ([#1991](https://github.com/cloudflare/cloudflare-go/issues/1991)) +* deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.5 to 0.7.6 ([#1993](https://github.com/cloudflare/cloudflare-go/issues/1993)) +* deps: bumps goreleaser/goreleaser-action from 5.0.0 to 5.1.0 ([#1992](https://github.com/cloudflare/cloudflare-go/issues/1992)) + +## 0.95.0 (May 8th, 2024) + +ENHANCEMENTS: + +* access_application: add support for `policies` array ([#1956](https://github.com/cloudflare/cloudflare-go/issues/1956)) +* access_application: add support for `scim_config` ([#1921](https://github.com/cloudflare/cloudflare-go/issues/1921)) +* access_policy: add support for reusable policies ([#1956](https://github.com/cloudflare/cloudflare-go/issues/1956)) +* dlp: add support for zt risk behavior configuration ([#1887](https://github.com/cloudflare/cloudflare-go/issues/1887)) + +BUG FIXES: + +* access_application: fix scim configuration authentication json marshalling ([#1959](https://github.com/cloudflare/cloudflare-go/issues/1959)) + +DEPENDENCIES: + +* deps: bumps dependabot/fetch-metadata from 2.0.0 to 2.1.0 ([#1839](https://github.com/cloudflare/cloudflare-go/issues/1839)) +* deps: bumps github.com/urfave/cli/v2 from 2.27.1 to 2.27.2 ([#1861](https://github.com/cloudflare/cloudflare-go/issues/1861)) +* deps: bumps golang.org/x/net from 0.24.0 to 0.25.0 ([#1974](https://github.com/cloudflare/cloudflare-go/issues/1974)) +* deps: bumps golangci/golangci-lint-action from 4 to 5 ([#1845](https://github.com/cloudflare/cloudflare-go/issues/1845)) +* deps: bumps golangci/golangci-lint-action from 5 to 6 ([#1975](https://github.com/cloudflare/cloudflare-go/issues/1975)) + +## 0.94.0 (April 24th, 2024) + +ENHANCEMENTS: + +* access_application: support options_preflight_bypass for access_application ([#1790](https://github.com/cloudflare/cloudflare-go/issues/1790)) +* gateway: added ecs_support field to teams_location resource ([#1826](https://github.com/cloudflare/cloudflare-go/issues/1826)) +* teams_account: adds custom certificate setting to teams account configuration ([#1811](https://github.com/cloudflare/cloudflare-go/issues/1811)) +* workers: support deleting namespaced Workers ([#1737](https://github.com/cloudflare/cloudflare-go/issues/1737)) + +DEPENDENCIES: + +* deps: bumps golang.org/x/net from 0.19.0 to 0.23.0 ([#1825](https://github.com/cloudflare/cloudflare-go/issues/1825)) + +## 0.93.0 (April 10th, 2024) + +BREAKING CHANGES: + +* dns: Remove "locked" flag which is always false ([#1618](https://github.com/cloudflare/cloudflare-go/issues/1618)) + +ENHANCEMENTS: + +* magic_transit_ipsec_tunnel: Adds support for replay_protection boolean flag ([#1710](https://github.com/cloudflare/cloudflare-go/issues/1710)) + +DEPENDENCIES: + +* deps: bumps golang.org/x/net from 0.22.0 to 0.24.0 ([#1688](https://github.com/cloudflare/cloudflare-go/issues/1688)) + +## 0.92.0 (March 27th, 2024) + +ENHANCEMENTS: + +- dlp: Adds support for ocr_enabled boolean flag ([#1600](https://github.com/cloudflare/cloudflare-go/issues/1600)) + +BUG FIXES: + +- teams_rules: add "resolve" to allowable actions ([#1615](https://github.com/cloudflare/cloudflare-go/issues/1615)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.6.0 to 1.7.0 ([#1593](https://github.com/cloudflare/cloudflare-go/issues/1593)) +- deps: bumps dependabot/fetch-metadata from 1.7.0 to 2.0.0 ([#1607](https://github.com/cloudflare/cloudflare-go/issues/1607)) + +## 0.91.0 (March 22nd, 2024) + +ENHANCEMENTS: + +- access_application: add support for `saml_attribute_transform_jsonata` in saas apps ([#1562](https://github.com/cloudflare/cloudflare-go/issues/1562)) +- dlp: Adds support for ocr_enabled boolean flag ([#1600](https://github.com/cloudflare/cloudflare-go/issues/1600)) + +BUG FIXES: + +- teams_rules: add "resolve" to allowable actions ([#1615](https://github.com/cloudflare/cloudflare-go/issues/1615)) + +DEPENDENCIES: + +- deps: bumps actions/checkout from 2 to 4 ([#1573](https://github.com/cloudflare/cloudflare-go/issues/1573)) +- deps: bumps dependabot/fetch-metadata from 1.6.0 to 1.7.0 ([#1593](https://github.com/cloudflare/cloudflare-go/issues/1593)) +- deps: bumps dependabot/fetch-metadata from 1.7.0 to 2.0.0 ([#1607](https://github.com/cloudflare/cloudflare-go/issues/1607)) +- deps: bumps google.golang.org/protobuf from 1.28.0 to 1.33.0 ([#1558](https://github.com/cloudflare/cloudflare-go/issues/1558)) + +## 0.90.0 (March 13th, 2024) + +ENHANCEMENTS: + +- access_mutual_tls_certificates: add support for mutual tls hostname settings ([#1516](https://github.com/cloudflare/cloudflare-go/issues/1516)) +- device_posture_rule: support last_seen and state for crowdstrike_s2s posture rule ([#1509](https://github.com/cloudflare/cloudflare-go/issues/1509)) +- dlp: add support for Context Awareness in DLP profiles ([#1497](https://github.com/cloudflare/cloudflare-go/issues/1497)) +- workers: Add Workers for Platforms support for getting a Worker, content and bindings ([#1508](https://github.com/cloudflare/cloudflare-go/issues/1508)) +- workers_for_platforms: Add ability to list Workers for Platforms namespaces, get a namespace, create a new namespace or delete a namespace. ([#1508](https://github.com/cloudflare/cloudflare-go/issues/1508)) + +BUG FIXES: + +- dlp: added optional ContextAwareness support ([#1510](https://github.com/cloudflare/cloudflare-go/issues/1510)) + +DEPENDENCIES: + +- deps: bumps github.com/stretchr/testify from 1.8.4 to 1.9.0 ([#1511](https://github.com/cloudflare/cloudflare-go/issues/1511)) +- deps: bumps golang.org/x/net from 0.21.0 to 0.22.0 ([#1513](https://github.com/cloudflare/cloudflare-go/issues/1513)) + +## 0.89.0 (February 28th, 2024) + +NOTES: + +- zaraz: replace deprecated neoEvents with Actions on Zaraz Config tools schema ([#1490](https://github.com/cloudflare/cloudflare-go/issues/1490)) + +ENHANCEMENTS: + +- magic-transit: Adds IPsec tunnel healthcheck direction & rate parameters ([#1503](https://github.com/cloudflare/cloudflare-go/issues/1503)) + +BUG FIXES: + +- registrar: Fix request method to call domain list endpoint from POST to GET ([#1506](https://github.com/cloudflare/cloudflare-go/issues/1506)) + +## 0.88.0 (February 14th, 2024) + +ENHANCEMENTS: + +- access_application: Add support for OIDC SaaS Applications ([#1500](https://github.com/cloudflare/cloudflare-go/issues/1500)) +- access_application: Add support for `allow_authenticate_via_warp` ([#1496](https://github.com/cloudflare/cloudflare-go/issues/1496)) +- access_application: add support for `name_id_transform_jsonata` in saas apps ([#1505](https://github.com/cloudflare/cloudflare-go/issues/1505)) +- access_organization: Add support for `allow_authenticate_via_warp` and `warp_auth_session_duration` ([#1496](https://github.com/cloudflare/cloudflare-go/issues/1496)) +- hyperdrive: Add support for hyperdrive CRUD operations ([#1492](https://github.com/cloudflare/cloudflare-go/issues/1492)) +- images_variants: Add support for Images Variants CRUD operations ([#1494](https://github.com/cloudflare/cloudflare-go/issues/1494)) +- teams_rules: `AntiVirus` settings includes notification settings ([#1499](https://github.com/cloudflare/cloudflare-go/issues/1499)) + +BUG FIXES: + +- hyperdrive: password should be nested in origin ([#1501](https://github.com/cloudflare/cloudflare-go/issues/1501)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/net from 0.20.0 to 0.21.0 ([#1502](https://github.com/cloudflare/cloudflare-go/issues/1502)) +- deps: bumps golangci/golangci-lint-action from 3 to 4 ([#1504](https://github.com/cloudflare/cloudflare-go/issues/1504)) + +## 0.87.0 (January 31st, 2024) + +ENHANCEMENTS: + +- access_seats: Add `UpdateAccessUsersSeats` with an array as input for multiple operations ([#1480](https://github.com/cloudflare/cloudflare-go/issues/1480)) +- dlp: add support for EDM and CWL datasets ([#1485](https://github.com/cloudflare/cloudflare-go/issues/1485)) +- logpush: Add support for Output Options ([#1468](https://github.com/cloudflare/cloudflare-go/issues/1468)) +- pages_project: Add `build_caching` attribute ([#1489](https://github.com/cloudflare/cloudflare-go/issues/1489)) +- streams: adds support for stream create parameters for tus upload initiate ([#1386](https://github.com/cloudflare/cloudflare-go/issues/1386)) +- teams_accounts: add support for extended email matching ([#1486](https://github.com/cloudflare/cloudflare-go/issues/1486)) + +BUG FIXES: + +- access_seats: UpdateAccessUserSeat: fix parameters not being an array when sending to the api. This caused an error when updating a user's seat ([#1480](https://github.com/cloudflare/cloudflare-go/issues/1480)) +- access_users: ListAccessUsers was returning wrong values in pointer fields due to variable missused in loop ([#1482](https://github.com/cloudflare/cloudflare-go/issues/1482)) +- flarectl: alias zone certs to "ct" instead of duplicating the "c" alias ([#1484](https://github.com/cloudflare/cloudflare-go/issues/1484)) + +DEPENDENCIES: + +- deps: bumps actions/cache from 3 to 4 ([#1483](https://github.com/cloudflare/cloudflare-go/issues/1483)) + +## 0.86.0 (January 17, 2024) + +ENHANCEMENTS: + +- access_application: Add support for default_relay_state in saas apps ([#1477](https://github.com/cloudflare/cloudflare-go/issues/1477)) +- zaraz: Add support for CRUD APIs ([#1474](https://github.com/cloudflare/cloudflare-go/issues/1474)) + +DEPENDENCIES: + +- deps: bumps github.com/cloudflare/circl from 1.3.3 to 1.3.7 ([#1475](https://github.com/cloudflare/cloudflare-go/issues/1475)) +- deps: bumps golang.org/x/net from 0.19.0 to 0.20.0 ([#1476](https://github.com/cloudflare/cloudflare-go/issues/1476)) + +## 0.85.0 (January 3rd, 2024) + +DEPENDENCIES: + +- deps: bumps github.com/go-git/go-git/v5 from 5.4.2 to 5.11.0 ([#1470](https://github.com/cloudflare/cloudflare-go/issues/1470)) +- deps: bumps github.com/urfave/cli/v2 from 2.26.0 to 2.27.0 ([#1471](https://github.com/cloudflare/cloudflare-go/issues/1471)) +- deps: bumps github.com/urfave/cli/v2 from 2.27.0 to 2.27.1 ([#1472](https://github.com/cloudflare/cloudflare-go/issues/1472)) + +## 0.84.0 (December 20th, 2023) + +ENHANCEMENTS: + +- access_group: Add support for email lists ([#1445](https://github.com/cloudflare/cloudflare-go/issues/1445)) +- device_posture_rules: add support for Access client fields in device posture integrations ([#1464](https://github.com/cloudflare/cloudflare-go/issues/1464)) +- page_shield: added support for page shield ([#1459](https://github.com/cloudflare/cloudflare-go/issues/1459)) + +DEPENDENCIES: + +- deps: bumps actions/setup-go from 4 to 5 ([#1460](https://github.com/cloudflare/cloudflare-go/issues/1460)) +- deps: bumps github/codeql-action from 2 to 3 ([#1462](https://github.com/cloudflare/cloudflare-go/issues/1462)) +- deps: bumps golang.org/x/crypto from 0.14.0 to 0.17.0 ([#1466](https://github.com/cloudflare/cloudflare-go/issues/1466)) + +## 0.83.0 (December 6th, 2023) + +ENHANCEMENTS: + +- cloudflare: Add ResultInfo to RawResponse ([#1453](https://github.com/cloudflare/cloudflare-go/issues/1453)) +- devices_policy: add fields for Opt-In Split Tunnel Overlapping IPs feature. ([#1454](https://github.com/cloudflare/cloudflare-go/issues/1454)) +- stream: Add ScheduledDeletion to StreamCreateVideoParameters ([#1457](https://github.com/cloudflare/cloudflare-go/issues/1457)) +- stream: Add ScheduledDeletion to StreamUploadFromURLParameters ([#1457](https://github.com/cloudflare/cloudflare-go/issues/1457)) +- stream: Add ScheduledDeletion to StreamVideo ([#1457](https://github.com/cloudflare/cloudflare-go/issues/1457)) +- stream: Add ScheduledDeletion to StreamVideoCreate ([#1457](https://github.com/cloudflare/cloudflare-go/issues/1457)) +- worker_bindings: Fixing form element name for d1 binding ([#1450](https://github.com/cloudflare/cloudflare-go/issues/1450)) +- worker_bindings: add support for `d1` bindings ([#1446](https://github.com/cloudflare/cloudflare-go/issues/1446)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.25.7 to 2.26.0 ([#1456](https://github.com/cloudflare/cloudflare-go/issues/1456)) +- deps: bumps golang.org/x/net from 0.18.0 to 0.19.0 ([#1452](https://github.com/cloudflare/cloudflare-go/issues/1452)) +- deps: bumps golang.org/x/time from 0.4.0 to 0.5.0 ([#1449](https://github.com/cloudflare/cloudflare-go/issues/1449)) + +## 0.82.0 (November 22nd, 2023) + +ENHANCEMENTS: + +- ip_access_rules: Add ListIPAccessRules() to list IP Access Rules ([#1428](https://github.com/cloudflare/cloudflare-go/issues/1428)) +- load_balancing: add healthy field to LoadBalancerPool ([#1442](https://github.com/cloudflare/cloudflare-go/issues/1442)) + +BUG FIXES: + +- load_balancing: Add support for virtual network id in origins ([#1441](https://github.com/cloudflare/cloudflare-go/issues/1441)) +- per_hostname_tls_setting: use `buildURI` for defining the query parameters when sorting ([#1440](https://github.com/cloudflare/cloudflare-go/issues/1440)) + +DEPENDENCIES: + +- deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.4 to 0.7.5 ([#1438](https://github.com/cloudflare/cloudflare-go/issues/1438)) +- deps: bumps golang.org/x/net from 0.17.0 to 0.18.0 ([#1439](https://github.com/cloudflare/cloudflare-go/issues/1439)) + +## 0.81.0 (November 8th, 2023) + +BREAKING CHANGES: + +- devices_policy: `CreateDeviceSettingsPolicy` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `DeleteDeviceSettingsPolicy` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `DeviceClientCertificates` is renamed to `DeviceClientCertificates` ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `GetDefaultDeviceSettingsPolicy` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `GetDeviceClientCertificatesZone` is renamed to `GetDeviceClientCertificates` with updated method signatures ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `GetDeviceClientCertificates` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `GetDeviceSettingsPolicy` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `UpdateDefaultDeviceSettingsPolicy` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `UpdateDeviceClientCertificatesZone` is renamed to `UpdateDeviceClientCertificates` with updated method signatures ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- devices_policy: `UpdateDeviceSettingsPolicy` is updated with method signatures matching the library conventions ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) + +ENHANCEMENTS: + +- access_seats: Add UpdateAccessUserSeat() to list IP Access Rules ([#1427](https://github.com/cloudflare/cloudflare-go/issues/1427)) +- access_user: Add GetAccessUserActiveSessions() to get all active sessions for a Access/Zero-Trust user. ([#1427](https://github.com/cloudflare/cloudflare-go/issues/1427)) +- access_user: Add GetAccessUserFailedLogins() to get all failed login attempts for a Access/Zero-Trust user. ([#1427](https://github.com/cloudflare/cloudflare-go/issues/1427)) +- access_user: Add GetAccessUserLastSeenIdentity() to get last seen identity for a Access/Zero-Trust user. ([#1427](https://github.com/cloudflare/cloudflare-go/issues/1427)) +- access_user: Add GetAccessUserSingleActiveSession() to get an active session for a Access/Zero-Trust user. ([#1427](https://github.com/cloudflare/cloudflare-go/issues/1427)) +- access_user: Add ListAccessUsers() to get a list of users for a Access/Zero-Trust account. ([#1427](https://github.com/cloudflare/cloudflare-go/issues/1427)) +- devices_policy: Add support for listing device settings policies ([#1433](https://github.com/cloudflare/cloudflare-go/issues/1433)) +- teams_rules: Add support for resolver policies ([#1436](https://github.com/cloudflare/cloudflare-go/issues/1436)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/time from 0.3.0 to 0.4.0 ([#1434](https://github.com/cloudflare/cloudflare-go/issues/1434)) + +## 0.80.0 (October 25th, 2023) + +BREAKING CHANGES: + +- teams: `BrowserIsolation.UrlBrowserIsolationEnabled` has changed from `bool` to `*bool` to meet the library conventions ([#1424](https://github.com/cloudflare/cloudflare-go/issues/1424)) + +ENHANCEMENTS: + +- access_application: Add support for app launcher customization fields ([#1407](https://github.com/cloudflare/cloudflare-go/issues/1407)) +- api_shield_schema: Add support for Get/Update API Shield Operation Schema Validation Settings ([#1422](https://github.com/cloudflare/cloudflare-go/issues/1422)) +- api_shield_schema: Add support for Get/Update API Shield Schema Validation Settings ([#1418](https://github.com/cloudflare/cloudflare-go/issues/1418)) +- teams: Add support for body_scanning (Enhanced File Detection) in teams account configuration ([#1423](https://github.com/cloudflare/cloudflare-go/issues/1423)) +- load_balancing: extend documentation for least_connections steering policy ([#1414](https://github.com/cloudflare/cloudflare-go/issues/1414)) +- teams: Add `non_identity_enabled` boolean in browser isolation settings ([#1424](https://github.com/cloudflare/cloudflare-go/issues/1424)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/net from 0.7.0 to 0.17.0 ([#1421](https://github.com/cloudflare/cloudflare-go/issues/1421)) + +## 0.79.0 (October 11th, 2023) + +ENHANCEMENTS: + +- access_organization: Add support for session_duration ([#1415](https://github.com/cloudflare/cloudflare-go/issues/1415)) +- access_policy: Add support for session_duration ([#1415](https://github.com/cloudflare/cloudflare-go/issues/1415)) + +ENHANCEMENTS: + +- api_shield_discovery: Add support for Get/Patch API Shield API Discovery Operations ([#1413](https://github.com/cloudflare/cloudflare-go/issues/1413)) +- api_shield_schema: Add support for managing schemas for API Shield Schema Validation 2.0 ([#1406](https://github.com/cloudflare/cloudflare-go/issues/1406)) +- d1: adds support for d1 ([#1417](https://github.com/cloudflare/cloudflare-go/issues/1417)) +- teams: Add `audit_ssh_settings` endpoints ([#1419](https://github.com/cloudflare/cloudflare-go/issues/1419)) + +BUG FIXES: + +- custom_nameservers: change `NSSet` from string to int to match API response ([#1410](https://github.com/cloudflare/cloudflare-go/issues/1410)) +- observatory: fix double url encoding ([#1412](https://github.com/cloudflare/cloudflare-go/issues/1412)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/net from 0.15.0 to 0.16.0 ([#1416](https://github.com/cloudflare/cloudflare-go/issues/1416)) +- deps: bumps golang.org/x/net from 0.16.0 to 0.17.0 ([#1420](https://github.com/cloudflare/cloudflare-go/issues/1420)) + +## 0.78.0 (September 27th, 2023) + +BREAKING CHANGES: + +- account_role: `AccountRole` has been renamed to `GetAccountRole` to align with the updated method conventions ([#1405](https://github.com/cloudflare/cloudflare-go/issues/1405)) +- account_role: `AccountRoles` has been renamed to `ListAccountRoles` to align with the updated method conventions ([#1405](https://github.com/cloudflare/cloudflare-go/issues/1405)) + +ENHANCEMENTS: + +- access_application: Add support for tags ([#1403](https://github.com/cloudflare/cloudflare-go/issues/1403)) +- access_tag: Add support for tags ([#1403](https://github.com/cloudflare/cloudflare-go/issues/1403)) +- list_item: allow filtering by search term, cursor and per page attributes ([#1409](https://github.com/cloudflare/cloudflare-go/issues/1409)) +- observatory: add support for observatory API ([#1401](https://github.com/cloudflare/cloudflare-go/issues/1401)) + +BUG FIXES: + +- account_role: autopaginate all available results instead of a static number ([#1405](https://github.com/cloudflare/cloudflare-go/issues/1405)) +- semgrep: Improved IPv4 validation by implementing a new pattern to handle cases where non-IPv4 addresses were previously accepted. ([#1382](https://github.com/cloudflare/cloudflare-go/issues/1382)) + +DEPENDENCIES: + +- deps: bumps codecov/codecov-action from 3 to 4 ([#1402](https://github.com/cloudflare/cloudflare-go/issues/1402)) + +## 0.77.0 (September 13th, 2023) + +ENHANCEMENTS: + +- access_identity_provider: add support for email_claim_name and authorization_server_id ([#1390](https://github.com/cloudflare/cloudflare-go/issues/1390)) +- access_identity_provider: add support for ping_env_id ([#1391](https://github.com/cloudflare/cloudflare-go/issues/1391)) +- dcv_delegation: add GET for DCV Delegation UUID ([#1384](https://github.com/cloudflare/cloudflare-go/issues/1384)) +- streams: adds support to initiate tus upload ([#1359](https://github.com/cloudflare/cloudflare-go/issues/1359)) +- tunnel: add support for `include_prefix`, `exclude_prefix` in list operations ([#1385](https://github.com/cloudflare/cloudflare-go/issues/1385)) + +BUG FIXES: + +- dns: keep comments when calling UpdateDNSRecord with zero values of UpdateDNSRecordParams ([#1393](https://github.com/cloudflare/cloudflare-go/issues/1393)) + +DEPENDENCIES: + +- deps: bumps actions/checkout from 3 to 4 ([#1387](https://github.com/cloudflare/cloudflare-go/issues/1387)) +- deps: bumps golang.org/x/net from 0.14.0 to 0.15.0 ([#1389](https://github.com/cloudflare/cloudflare-go/issues/1389)) +- deps: bumps goreleaser/goreleaser-action from 4.4.0 to 4.6.0 ([#1388](https://github.com/cloudflare/cloudflare-go/issues/1388)) +- deps: bumps goreleaser/goreleaser-action from 4.6.0 to 5.0.0 ([#1396](https://github.com/cloudflare/cloudflare-go/issues/1396)) + +## 0.76.0 (August 30th, 2023) + +BREAKING CHANGES: + +- images: Renamed Image struct "Metadata" field to "Meta" ([#1379](https://github.com/cloudflare/cloudflare-go/issues/1379)) + +ENHANCEMENTS: + +- access_application: added custom_non_identity_deny_url ([#1373](https://github.com/cloudflare/cloudflare-go/issues/1373)) +- load_balancer_monitor: add support for `consecutive_up`, `consecutive_down` ([#1380](https://github.com/cloudflare/cloudflare-go/issues/1380)) +- workers: Add support for retrieving and uploading only script content. ([#1361](https://github.com/cloudflare/cloudflare-go/issues/1361)) +- workers: Add support for retrieving and uploading only script metadata. ([#1361](https://github.com/cloudflare/cloudflare-go/issues/1361)) +- workers: allow namespaced scripts to be used as Worker tail consumers ([#1377](https://github.com/cloudflare/cloudflare-go/issues/1377)) + +BUG FIXES: + +- access_application: Use autopaginate flag as expected ([#1372](https://github.com/cloudflare/cloudflare-go/issues/1372)) +- access_ca_certificate: Use autopaginate flag as expected ([#1372](https://github.com/cloudflare/cloudflare-go/issues/1372)) +- access_group: Use autopaginate flag as expected ([#1372](https://github.com/cloudflare/cloudflare-go/issues/1372)) +- access_mutual_tls_certifcate: Use autopaginate flag as expected ([#1372](https://github.com/cloudflare/cloudflare-go/issues/1372)) +- access_policy: Use autopaginate flag as expected ([#1372](https://github.com/cloudflare/cloudflare-go/issues/1372)) +- images: Fix issue parsing Image Details from API due to incorrect struct json field ([#1379](https://github.com/cloudflare/cloudflare-go/issues/1379)) +- pagination: Will look at `total_count` and `per_page` to calculate `total_pages` if `total_pages` is zero ([#1372](https://github.com/cloudflare/cloudflare-go/issues/1372)) + +## 0.75.0 (August 16th, 2023) + +BREAKING CHANGES: + +- cloudflare: `Raw` method now returns a RawResponse rather than the raw JSON `Result` message ([#1355](https://github.com/cloudflare/cloudflare-go/issues/1355)) +- rulesets: Rename `RulesetPhaseRateLimit` to `RulesetPhaseHTTPRatelimit`, to match the phase name ([#1367](https://github.com/cloudflare/cloudflare-go/issues/1367)) +- rulesets: Rename `RulesetPhaseSuperBotFightMode` to `RulesetPhaseHTTPRequestSBFM`, to match the phase name ([#1367](https://github.com/cloudflare/cloudflare-go/issues/1367)) + +NOTES: + +- rulesets: Remove non-existent `allow` action ([#1367](https://github.com/cloudflare/cloudflare-go/issues/1367)) +- rulesets: Remove non-existent `http_request_main` phase ([#1367](https://github.com/cloudflare/cloudflare-go/issues/1367)) +- rulesets: Remove non-public `http_response_headers_transform_managed` and `http_request_late_transform_managed` phases ([#1367](https://github.com/cloudflare/cloudflare-go/issues/1367)) + +ENHANCEMENTS: + +- access_group: add auth_context group ruletype ([#1344](https://github.com/cloudflare/cloudflare-go/issues/1344)) +- access_identity_provider: add attr conditional_access_enabled ([#1344](https://github.com/cloudflare/cloudflare-go/issues/1344)) +- access_identity_provider: add auth context list/put endpoint ([#1344](https://github.com/cloudflare/cloudflare-go/issues/1344)) +- access_service_token: add support for managing `Duration` ([#1347](https://github.com/cloudflare/cloudflare-go/issues/1347)) +- bot_management: add support for bot_management API ([#1363](https://github.com/cloudflare/cloudflare-go/issues/1363)) +- cloudflare: swap `encoding/json` for `github.com/goccy/go-json` ([#1360](https://github.com/cloudflare/cloudflare-go/issues/1360)) +- device_posture_rule: support eid_last_seen and risk_level and correct total_score for Tanium posture rule ([#1366](https://github.com/cloudflare/cloudflare-go/issues/1366)) +- per_hostname_tls_settings: add support for managing hostname level TLS settings ([#1356](https://github.com/cloudflare/cloudflare-go/issues/1356)) +- rulesets: Add the `ddos_mitigation` action ([#1367](https://github.com/cloudflare/cloudflare-go/issues/1367)) +- waiting_room: add support for `queueing_status_code` ([#1357](https://github.com/cloudflare/cloudflare-go/issues/1357)) +- web_analytics: add support for web_analytics API ([#1348](https://github.com/cloudflare/cloudflare-go/issues/1348)) +- workers: add support for tagging Worker scripts ([#1368](https://github.com/cloudflare/cloudflare-go/issues/1368)) +- zone_hold: add support for zone hold API ([#1365](https://github.com/cloudflare/cloudflare-go/issues/1365)) + +BUG FIXES: + +- cache_purge: don't escape HTML entity values in URLs for cache keys ([#1360](https://github.com/cloudflare/cloudflare-go/issues/1360)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/net from 0.12.0 to 0.13.0 ([#1353](https://github.com/cloudflare/cloudflare-go/issues/1353)) +- deps: bumps golang.org/x/net from 0.13.0 to 0.14.0 ([#1362](https://github.com/cloudflare/cloudflare-go/issues/1362)) +- deps: bumps goreleaser/goreleaser-action from 4.3.0 to 4.4.0 ([#1369](https://github.com/cloudflare/cloudflare-go/issues/1369)) + +## 0.74.0 (August 2nd, 2023) + +ENHANCEMENTS: + +- access_application: Add support for custom pages ([#1343](https://github.com/cloudflare/cloudflare-go/issues/1343)) +- access_custom_page: Add support for custom pages ([#1343](https://github.com/cloudflare/cloudflare-go/issues/1343)) +- access_organization: add support for custom pages ([#1343](https://github.com/cloudflare/cloudflare-go/issues/1343)) +- rulesets: Remove internal-only schema kind ([#1346](https://github.com/cloudflare/cloudflare-go/issues/1346)) +- rulesets: Remove some request parameters that are not allowed or have no effect ([#1346](https://github.com/cloudflare/cloudflare-go/issues/1346)) +- rulesets: Update API reference links ([#1346](https://github.com/cloudflare/cloudflare-go/issues/1346)) +- teams-accounts: Adds support for protocol detection ([#1340](https://github.com/cloudflare/cloudflare-go/issues/1340)) +- workers: Add `pipeline_hash` field to Workers script response struct. ([#1330](https://github.com/cloudflare/cloudflare-go/issues/1330)) +- workers: Add support for declaring arbitrary bindings with UnsafeBinding. ([#1330](https://github.com/cloudflare/cloudflare-go/issues/1330)) +- workers: Add support for uploading scripts to a Workers for Platforms namespace. ([#1330](https://github.com/cloudflare/cloudflare-go/issues/1330)) +- workers: Add support for uploading workers with Workers for Platforms namespace bindings. ([#1330](https://github.com/cloudflare/cloudflare-go/issues/1330)) + +BUG FIXES: + +- flarectl: allow for create or update to actually create the record ([#1341](https://github.com/cloudflare/cloudflare-go/issues/1341)) +- load_balancing: Fix pool creation with MinimumOrigins set to 0 ([#1338](https://github.com/cloudflare/cloudflare-go/issues/1338)) +- workers: Fix namespace dispatch upload API path ([#1345](https://github.com/cloudflare/cloudflare-go/issues/1345)) + +## 0.73.0 (July 19th, 2023) + +BREAKING CHANGES: + +- pages_deployment: add support for auto pagination ([#1264](https://github.com/cloudflare/cloudflare-go/issues/1264)) +- pages_deployment: change DeletePagesDeploymentParams to contain all parameters ([#1264](https://github.com/cloudflare/cloudflare-go/issues/1264)) +- pages_project: change to use ResourceContainer for account ID ([#1264](https://github.com/cloudflare/cloudflare-go/issues/1264)) +- pages_project: rename PagesProject to GetPagesProject ([#1264](https://github.com/cloudflare/cloudflare-go/issues/1264)) +- rulesets: `CreateAccountRuleset` is removed in favour of `CreateRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `CreateZoneRuleset` is removed in favour of `CreateRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `DeleteAccountRuleset` is removed in favour of `DeleteRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `DeleteZoneRuleset` is removed in favour of `DeleteRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `GetAccountRulesetPhase` is removed in favour of `GetEntrypointRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `GetAccountRuleset` is removed in favour of `GetRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `GetZoneRulesetPhase` is removed in favour of `GetEntrypointRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `GetZoneRuleset` is removed in favour of `GetRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `UpdateAccountRulesetPhase` is removed in favour of `UpdateEntrypointRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `UpdateAccountRuleset` is removed in favour of `UpdateRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `UpdateZoneRulesetPhase` is removed in favour of `UpdateEntrypointRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) +- rulesets: `UpdateZoneRuleset` is removed in favour of `UpdateRuleset` ([#1333](https://github.com/cloudflare/cloudflare-go/issues/1333)) + +ENHANCEMENTS: + +- device_posture_rule: support active_threats, network_status, infected, and is_active for sentinelone_s2s posture rule ([#1339](https://github.com/cloudflare/cloudflare-go/issues/1339)) +- device_posture_rule: support certificate_id and cn for client_certificate posture rule ([#1339](https://github.com/cloudflare/cloudflare-go/issues/1339)) +- images: adds ability to upload image by url ([#1335](https://github.com/cloudflare/cloudflare-go/issues/1335)) +- load_balancing: support header session affinity policy ([#1302](https://github.com/cloudflare/cloudflare-go/issues/1302)) +- zone: Added `GetRegionalTieredCache` and `UpdateRegionalTieredCache` to allow setting Regional Tiered Cache for a zone. ([#1336](https://github.com/cloudflare/cloudflare-go/issues/1336)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/net from 0.11.0 to 0.12.0 ([#1328](https://github.com/cloudflare/cloudflare-go/issues/1328)) + +## 0.72.0 (July 5th, 2023) + +BREAKING CHANGES: + +- logpush: `CheckAccountLogpushDestinationExists` is removed in favour of `CheckLogpushDestinationExists` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `CheckZoneLogpushDestinationExists` is removed in favour of `CheckLogpushDestinationExists` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `CreateAccountLogpushJob` is removed in favour of `CreateLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `CreateZoneLogpushJob` is removed in favour of `CreateLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `DeleteAccountLogpushJob` is removed in favour of `DeleteLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `DeleteZoneLogpushJob` is removed in favour of `DeleteLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `GetAccountLogpushFields` is removed in favour of `GetLogpushFields` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `GetAccountLogpushJob` is removed in favour of `GetLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `GetAccountLogpushOwnershipChallenge` is removed in favour of `GetLogpushOwnershipChallenge` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `GetZoneLogpushFields` is removed in favour of `GetLogpushFields` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `GetZoneLogpushJob` is removed in favour of `GetLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `GetZoneLogpushOwnershipChallenge` is removed in favour of `GetLogpushOwnershipChallenge` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `ListAccountLogpushJobsForDataset` is removed in favour of `ListLogpushJobsForDataset` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `ListAccountLogpushJobs` is removed in favour of `ListLogpushJobs` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `ListZoneLogpushJobsForDataset` is removed in favour of `ListLogpushJobsForDataset` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `ListZoneLogpushJobs` is removed in favour of `ListLogpushJobs` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `UpdateAccountLogpushJob` is removed in favour of `UpdateLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `UpdateZoneLogpushJob` is removed in favour of `UpdateLogpushJob` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `ValidateAccountLogpushOwnershipChallenge` is removed in favour of `ValidateLogpushOwnershipChallenge` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: `ValidateZoneLogpushOwnershipChallenge` is removed in favour of `ValidateLogpushOwnershipChallenge` with `ResourceContainer` method parameter ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) +- logpush: all methods are updated to use the newer client conventions for method signatures ([#1326](https://github.com/cloudflare/cloudflare-go/issues/1326)) + +ENHANCEMENTS: + +- resource_container: expose `Type` on `*ResourceContainer` to explicitly denote what type of resource it is instead of inferring from `Level`. ([#1325](https://github.com/cloudflare/cloudflare-go/issues/1325)) + +## 0.71.0 (July 5th, 2023) + +BREAKING CHANGES: + +- access_application: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_ca_certificate: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_group: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_identity_provider: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_mutual_tls_certificates: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_organization: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_policy: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_service_tokens: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_user_token: refactor methods to use `ResourceContainer` instead of dedicated account/zone methods ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- images: renamed `BaseImage` to `GetBaseImage` to match library conventions ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- images: renamed `ImageDetails` to `GetImage` to match library conventions ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- images: renamed `ImagesStats` to `GetImagesStats` to match library conventions ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- images: updated method signatures of `DeleteImage` to match newer conventions and standards ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- images: updated method signatures of `ListImages` to match newer conventions and standards ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- images: updated method signatures of `UpdateImage` to match newer conventions and standards ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- images: updated method signatures of `UploadImage` to match newer conventions and standards ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) + +ENHANCEMENTS: + +- access_application: add support for auto pagination ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_ca_certificate: add support for auto pagination ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_group: add support for auto pagination ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_identity_provider: add support for auto pagination ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_mutual_tls_certificates: add support for auto pagination ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- access_policy: add support for auto pagination ([#1319](https://github.com/cloudflare/cloudflare-go/issues/1319)) +- device_posture_rule: support os_version_extra ([#1316](https://github.com/cloudflare/cloudflare-go/issues/1316)) +- images: adds support for v2 when uploading images directly ([#1322](https://github.com/cloudflare/cloudflare-go/issues/1322)) +- workers: Add ability to specify tail Workers in script metadata ([#1317](https://github.com/cloudflare/cloudflare-go/issues/1317)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.5.1 to 1.6.0 ([#1320](https://github.com/cloudflare/cloudflare-go/issues/1320)) + +## 0.70.0 (June 21st, 2023) + +BREAKING CHANGES: + +- cloudflare: remove `UsingAccount` in favour of resource specific attributes ([#1315](https://github.com/cloudflare/cloudflare-go/issues/1315)) +- cloudflare: remove `api.AccountID` from client struct ([#1315](https://github.com/cloudflare/cloudflare-go/issues/1315)) +- dns_firewall: modernise method signatures and conventions to align with the experimental client ([#1313](https://github.com/cloudflare/cloudflare-go/issues/1313)) +- railgun: remove support for railgun ([#1312](https://github.com/cloudflare/cloudflare-go/issues/1312)) +- tunnel: swap `ConnectTimeout`, `TLSTimeout`, `TCPKeepAlive` and `KeepAliveTimeout` to `TunnelDuration` instead of `time.Duration` ([#1303](https://github.com/cloudflare/cloudflare-go/issues/1303)) +- virtualdns: remove support in favour of newer DNS firewall methods ([#1313](https://github.com/cloudflare/cloudflare-go/issues/1313)) + +ENHANCEMENTS: + +- custom_nameservers: add support for managing custom nameservers ([#1304](https://github.com/cloudflare/cloudflare-go/issues/1304)) +- load_balancing: extend documentation for least_outstanding_requests steering policy ([#1293](https://github.com/cloudflare/cloudflare-go/issues/1293)) +- waiting_room: add support for `additional_routes` and `cookie_suffix` ([#1311](https://github.com/cloudflare/cloudflare-go/issues/1311)) + +DEPENDENCIES: + +- deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.3 to 0.7.4 ([#1301](https://github.com/cloudflare/cloudflare-go/issues/1301)) +- deps: bumps github.com/urfave/cli/v2 from 2.25.5 to 2.25.6 ([#1305](https://github.com/cloudflare/cloudflare-go/issues/1305)) +- deps: bumps github.com/urfave/cli/v2 from 2.25.6 to 2.25.7 ([#1314](https://github.com/cloudflare/cloudflare-go/issues/1314)) +- deps: bumps golang.org/x/net from 0.10.0 to 0.11.0 ([#1307](https://github.com/cloudflare/cloudflare-go/issues/1307)) +- deps: bumps goreleaser/goreleaser-action from 4.2.0 to 4.3.0 ([#1306](https://github.com/cloudflare/cloudflare-go/issues/1306)) + +## 0.69.0 (June 7th, 2023) + +BREAKING CHANGES: + +- stream: StreamVideo.Duration has changed from int to float64. ([#1190](https://github.com/cloudflare/cloudflare-go/issues/1190)) + +ENHANCEMENTS: + +- access: Added `self_hosted_domains` support to access applications ([#1281](https://github.com/cloudflare/cloudflare-go/issues/1281)) +- custom_hostname: add support for `bundle_method` TLS configuration ([#1298](https://github.com/cloudflare/cloudflare-go/issues/1298)) +- devices_policy: Add missing description field to policy ([#1294](https://github.com/cloudflare/cloudflare-go/issues/1294)) +- stream: added metadata support ([#1088](https://github.com/cloudflare/cloudflare-go/issues/1088)) + +BUG FIXES: + +- email_routing_destination: return encountered error, not `ErrMissingAccountID` all the time ([#1297](https://github.com/cloudflare/cloudflare-go/issues/1297)) +- stream: Fix a bug that cannot unmarshal video duration number. ([#1190](https://github.com/cloudflare/cloudflare-go/issues/1190)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.5.0 to 1.5.1 ([#1292](https://github.com/cloudflare/cloudflare-go/issues/1292)) +- deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.2 to 0.7.3 ([#1300](https://github.com/cloudflare/cloudflare-go/issues/1300)) +- deps: bumps github.com/stretchr/testify from 1.8.3 to 1.8.4 ([#1296](https://github.com/cloudflare/cloudflare-go/issues/1296)) +- deps: bumps github.com/urfave/cli/v2 from 2.25.3 to 2.25.5 ([#1295](https://github.com/cloudflare/cloudflare-go/issues/1295)) + +## 0.68.0 (May 24th, 2023) + +BREAKING CHANGES: + +- r2_bucket: change creation time from string to \*time.Time ([#1265](https://github.com/cloudflare/cloudflare-go/issues/1265)) + +ENHANCEMENTS: + +- adds OriginRequest field to UnvalidatedIngressRule struct. ([#1138](https://github.com/cloudflare/cloudflare-go/issues/1138)) +- lists: add support for hostname and ASN lists. ([#1288](https://github.com/cloudflare/cloudflare-go/issues/1288)) +- pages: add support for Smart Placement. Added `Placement` in `PagesProjectDeploymentConfigEnvironment`. ([#1279](https://github.com/cloudflare/cloudflare-go/issues/1279)) +- r2_bucket: add support for getting a bucket ([#1265](https://github.com/cloudflare/cloudflare-go/issues/1265)) +- tunnels: add support for `access` and `http2Origin` keys ([#1291](https://github.com/cloudflare/cloudflare-go/issues/1291)) +- workers: add support for Smart Placement. Added `Placement` in `CreateWorkerParams`. ([#1279](https://github.com/cloudflare/cloudflare-go/issues/1279)) +- zone: Added `GetCacheReserve` and `UpdateacheReserve` to allow setting Cache Reserve for a zone. ([#1278](https://github.com/cloudflare/cloudflare-go/issues/1278)) + +BUG FIXES: + +- dns: fix MX record priority not set by UpdateDNSRecord ([#1290](https://github.com/cloudflare/cloudflare-go/issues/1290)) +- flarectl/dns: ensure MX priority value is dereferenced ([#1289](https://github.com/cloudflare/cloudflare-go/issues/1289)) +- turnstile: remove `SiteKey` being sent in rotate secret's request body ([#1285](https://github.com/cloudflare/cloudflare-go/issues/1285)) +- turnstile: remove `SiteKey`/`Secret` being sent in update request body ([#1284](https://github.com/cloudflare/cloudflare-go/issues/1284)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.4.0 to 1.5.0 ([#1287](https://github.com/cloudflare/cloudflare-go/issues/1287)) +- deps: bumps github.com/stretchr/testify from 1.8.2 to 1.8.3 ([#1286](https://github.com/cloudflare/cloudflare-go/issues/1286)) + +## 0.67.0 (May 10th, 2023) + +NOTES: + +- dns_firewall: The `OriginIPs` field has been renamed to `UpstreamIPs`. ([#1246](https://github.com/cloudflare/cloudflare-go/issues/1246)) + +ENHANCEMENTS: + +- device_posture_rule: add input fields tanium, intune and kolide ([#1268](https://github.com/cloudflare/cloudflare-go/issues/1268)) +- waiting_room: add support for zone-level settings ([#1276](https://github.com/cloudflare/cloudflare-go/issues/1276)) + +BUG FIXES: + +- rulesets: allow `PreserveQueryString` to be nullable ([#1275](https://github.com/cloudflare/cloudflare-go/issues/1275)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.25.1 to 2.25.3 ([#1274](https://github.com/cloudflare/cloudflare-go/issues/1274)) +- deps: bumps golang.org/x/net from 0.9.0 to 0.10.0 ([#1280](https://github.com/cloudflare/cloudflare-go/issues/1280)) + +## 0.66.0 (26th April, 2023) + +ENHANCEMENTS: + +- access_application: Add `path_cookie_attribute` app setting ([#1223](https://github.com/cloudflare/cloudflare-go/issues/1223)) +- certificate_packs: add `Status` field to indicate the status of certificate pack ([#1271](https://github.com/cloudflare/cloudflare-go/issues/1271)) +- data localization: add support for regional hostnames API ([#1270](https://github.com/cloudflare/cloudflare-go/issues/1270)) +- dns: add support for importing and exporting DNS records using BIND file configurations ([#1266](https://github.com/cloudflare/cloudflare-go/issues/1266)) +- logpush: add support for max upload parameters ([#1272](https://github.com/cloudflare/cloudflare-go/issues/1272)) +- turnstile: add support for turnstile ([#1267](https://github.com/cloudflare/cloudflare-go/issues/1267)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.3.6 to 1.4.0 ([#1269](https://github.com/cloudflare/cloudflare-go/issues/1269)) + +## 0.65.0 (12th April, 2023) + +ENHANCEMENTS: + +- access: Add `auto_redirect_to_identity` flag to Access organizations ([#1260](https://github.com/cloudflare/cloudflare-go/issues/1260)) +- access: Add `isolation_required` flag to Access policies ([#1258](https://github.com/cloudflare/cloudflare-go/issues/1258)) +- rulesets: add support for add operation to HTTP header configuration ([#1253](https://github.com/cloudflare/cloudflare-go/issues/1253)) +- rulesets: add support for the `compress_response` action ([#1261](https://github.com/cloudflare/cloudflare-go/issues/1261)) +- rulesets: add support for the `http_response_compression` phase ([#1261](https://github.com/cloudflare/cloudflare-go/issues/1261)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/net from 0.8.0 to 0.9.0 ([#1263](https://github.com/cloudflare/cloudflare-go/issues/1263)) + +## 0.64.0 (29th March, 2023) + +BREAKING CHANGES: + +- dns: Changed Create/UpdateDNSRecord method signatures to return (DNSRecord, error) ([#1243](https://github.com/cloudflare/cloudflare-go/issues/1243)) +- zone: `UpdateZoneSingleSetting` has been renamed to `UpdateZoneSetting` and updated method signature inline with our expected conventions ([#1251](https://github.com/cloudflare/cloudflare-go/issues/1251)) +- zone: `ZoneSingleSetting` has been renamed to `GetZoneSetting` and updated method signature inline with our expected conventions ([#1251](https://github.com/cloudflare/cloudflare-go/issues/1251)) + +ENHANCEMENTS: + +- access_identity_provider: add `claims` and `scopes` fields ([#1237](https://github.com/cloudflare/cloudflare-go/issues/1237)) +- access_identity_provider: add scim_config field ([#1178](https://github.com/cloudflare/cloudflare-go/issues/1178)) +- devices_policy: update `Mode` field to use new `ServiceMode` string type with explicit const service mode values ([#1249](https://github.com/cloudflare/cloudflare-go/issues/1249)) +- ssl: make `GeoRestrictions` a pointer inside of ZoneCustomSSL ([#1244](https://github.com/cloudflare/cloudflare-go/issues/1244)) +- zone: `GetZoneSetting` and `UpdateZoneSetting` now allow configuring the path for where a setting resides instead of assuming `settings` ([#1251](https://github.com/cloudflare/cloudflare-go/issues/1251)) + +BUG FIXES: + +- teams_rules: `AllowChildBypass` changes from a `bool` to `*bool` ([#1242](https://github.com/cloudflare/cloudflare-go/issues/1242)) +- teams_rules: `BypassParentRule` changes from a `bool` to `*bool` ([#1242](https://github.com/cloudflare/cloudflare-go/issues/1242)) +- tunnel: Fix 'CreateTunnel' for tunnels using config_src ([#1238](https://github.com/cloudflare/cloudflare-go/issues/1238)) + +DEPENDENCIES: + +- deps: bumps actions/setup-go from 3 to 4 ([#1236](https://github.com/cloudflare/cloudflare-go/issues/1236)) +- deps: bumps github.com/urfave/cli/v2 from 2.25.0 to 2.25.1 ([#1250](https://github.com/cloudflare/cloudflare-go/issues/1250)) + +## 0.63.0 (15th March, 2023) + +BREAKING CHANGES: + +- tunnel: renamed `Tunnel` to `GetTunnel` ([#1227](https://github.com/cloudflare/cloudflare-go/issues/1227)) +- tunnel: renamed `Tunnels` to `ListTunnels` ([#1227](https://github.com/cloudflare/cloudflare-go/issues/1227)) + +ENHANCEMENTS: + +- access_organization: add ui_read_only_toggle_reason field ([#1181](https://github.com/cloudflare/cloudflare-go/issues/1181)) +- added audit_ssh to gateway actions, updated gateway rule settings ([#1226](https://github.com/cloudflare/cloudflare-go/issues/1226)) +- addressing: Add `Address Map` support ([#1232](https://github.com/cloudflare/cloudflare-go/issues/1232)) +- teams_account: add support for `check_disks` ([#1197](https://github.com/cloudflare/cloudflare-go/issues/1197)) +- tunnel: updated parameters to latest API docs ([#1227](https://github.com/cloudflare/cloudflare-go/issues/1227)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.24.4 to 2.25.0 ([#1229](https://github.com/cloudflare/cloudflare-go/issues/1229)) +- deps: bumps golang.org/x/net from 0.7.0 to 0.8.0 ([#1228](https://github.com/cloudflare/cloudflare-go/issues/1228)) + +## 0.62.0 (1st March, 2023) + +ENHANCEMENTS: + +- dex_test: add CRUD functionality for DEX test configurations ([#1209](https://github.com/cloudflare/cloudflare-go/issues/1209)) +- dlp: Adds support for partial payload logging ([#1212](https://github.com/cloudflare/cloudflare-go/issues/1212)) +- teams_accounts: Add new root_certificate_installation_enabled field ([#1208](https://github.com/cloudflare/cloudflare-go/issues/1208)) +- teams_rules: Add `untrusted_cert` rule setting ([#1214](https://github.com/cloudflare/cloudflare-go/issues/1214)) +- tunnels: automatically paginate `ListTunnels` ([#1206](https://github.com/cloudflare/cloudflare-go/issues/1206)) + +BUG FIXES: + +- dex_test: use dex test types and json struct mappings instead of managed networks ([#1213](https://github.com/cloudflare/cloudflare-go/issues/1213)) +- dns: dont reuse DNSListResponse when using pagination to avoid Proxied pointer overwrite ([#1222](https://github.com/cloudflare/cloudflare-go/issues/1222)) + +DEPENDENCIES: + +- deps: bumps github.com/stretchr/testify from 1.8.1 to 1.8.2 ([#1220](https://github.com/cloudflare/cloudflare-go/issues/1220)) +- deps: bumps github.com/urfave/cli/v2 from 2.24.3 to 2.24.4 ([#1210](https://github.com/cloudflare/cloudflare-go/issues/1210)) +- deps: bumps golang.org/x/net from 0.0.0-20220722155237-a158d28d115b to 0.7.0 ([#1218](https://github.com/cloudflare/cloudflare-go/issues/1218)) +- deps: bumps golang.org/x/net from 0.0.0-20220722155237-a158d28d115b to 0.7.0 ([#1219](https://github.com/cloudflare/cloudflare-go/issues/1219)) +- deps: bumps golang.org/x/text from 0.3.7 to 0.3.8 ([#1215](https://github.com/cloudflare/cloudflare-go/issues/1215)) +- deps: bumps golang.org/x/text from 0.3.7 to 0.3.8 ([#1216](https://github.com/cloudflare/cloudflare-go/issues/1216)) +- deps: bumps golang.org/x/time from 0.0.0-20220224211638-0e9765cccd65 to 0.3.0 ([#1217](https://github.com/cloudflare/cloudflare-go/issues/1217)) + +## 0.61.0 (15th February, 2023) + +ENHANCEMENTS: + +- cloudflare: make it clearer when we hit a server error and to retry later ([#1207](https://github.com/cloudflare/cloudflare-go/issues/1207)) +- devices_policy: Add new exclude_office_ips field to policy ([#1205](https://github.com/cloudflare/cloudflare-go/issues/1205)) +- dlp_profile: Use int rather than uint for allowed_match_count field ([#1200](https://github.com/cloudflare/cloudflare-go/issues/1200)) + +BUG FIXES: + +- dns: always send `tags` to allow clearing ([#1196](https://github.com/cloudflare/cloudflare-go/issues/1196)) +- stream: renamed `RequiredSignedURLs` to `RequireSignedURLs` ([#1202](https://github.com/cloudflare/cloudflare-go/issues/1202)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.24.2 to 2.24.3 ([#1199](https://github.com/cloudflare/cloudflare-go/issues/1199)) + +## 0.60.0 (1st February, 2023) + +BREAKING CHANGES: + +- queues: UpdateQueue has been updated to match the API and now correctly updates a Queue's name ([#1188](https://github.com/cloudflare/cloudflare-go/issues/1188)) + +ENHANCEMENTS: + +- dlp_profile: Add new allowed_match_count field to profiles ([#1193](https://github.com/cloudflare/cloudflare-go/issues/1193)) +- dns: allow sending empty strings to remove comments ([#1195](https://github.com/cloudflare/cloudflare-go/issues/1195)) +- magic_transit_ipsec_tunnel: makes customer endpoint an optional field for ipsec tunnel creation ([#1185](https://github.com/cloudflare/cloudflare-go/issues/1185)) +- rulesets: add support for `score_per_period` and `score_response_header_name` ([#1183](https://github.com/cloudflare/cloudflare-go/issues/1183)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.3.5 to 1.3.6 ([#1184](https://github.com/cloudflare/cloudflare-go/issues/1184)) +- deps: bumps github.com/urfave/cli/v2 from 2.23.7 to 2.24.1 ([#1180](https://github.com/cloudflare/cloudflare-go/issues/1180)) +- deps: bumps github.com/urfave/cli/v2 from 2.24.1 to 2.24.2 ([#1191](https://github.com/cloudflare/cloudflare-go/issues/1191)) +- deps: bumps goreleaser/goreleaser-action from 4.1.0 to 4.2.0 ([#1192](https://github.com/cloudflare/cloudflare-go/issues/1192)) + +## 0.59.0 (January 18th, 2023) + +BREAKING CHANGES: + +- dns: remove these read-only fields from `UpdateDNSRecordParams`: `CreatedOn`, `ModifiedOn`, `Meta`, `ZoneID`, `ZoneName`, `Proxiable`, and `Locked` ([#1170](https://github.com/cloudflare/cloudflare-go/issues/1170)) +- dns: the fields `CreatedOn` and `ModifiedOn` are removed from `ListDNSRecordsParams` ([#1173](https://github.com/cloudflare/cloudflare-go/issues/1173)) + +NOTES: + +- dns: remove additional lookup from `Update` operations when `Name` or `Type` was omitted ([#1170](https://github.com/cloudflare/cloudflare-go/issues/1170)) + +ENHANCEMENTS: + +- access_organization: add user_seat_expiration_inactive_time field ([#1159](https://github.com/cloudflare/cloudflare-go/issues/1159)) +- dns: `GetDNSRecord`, `UpdateDNSRecord` and `DeleteDNSRecord` now return the new, dedicated error `ErrMissingDNSRecordID` when an empty DNS record ID is given. ([#1174](https://github.com/cloudflare/cloudflare-go/issues/1174)) +- dns: the URL parameter `tag-match` for listing DNS records is now supported as the field `TagMatch` in `ListDNSRecordsParams` ([#1173](https://github.com/cloudflare/cloudflare-go/issues/1173)) +- dns: update default `per_page` attribute to 100 records ([#1171](https://github.com/cloudflare/cloudflare-go/issues/1171)) +- teams_rules: adds support for Egress Policies ([#1142](https://github.com/cloudflare/cloudflare-go/issues/1142)) +- workers: Add support for compatibility_date and compatibility_flags when upoading a worker script ([#1177](https://github.com/cloudflare/cloudflare-go/issues/1177)) +- workers: script upload now supports Queues bindings ([#1176](https://github.com/cloudflare/cloudflare-go/issues/1176)) + +BUG FIXES: + +- dns: don't send "priority" for list operations as it isn't supported and is only used for internal filtering ([#1167](https://github.com/cloudflare/cloudflare-go/issues/1167)) +- dns: the field `Tags` in `ListDNSRecordsParams` was not correctly serialized into URL queries ([#1173](https://github.com/cloudflare/cloudflare-go/issues/1173)) +- managednetworks: Update should be PUT ([#1172](https://github.com/cloudflare/cloudflare-go/issues/1172)) + +## 0.58.1 (January 5th, 2023) + +ENHANCEMENTS: + +- cloudflare: automatically redact sensitive values from HTTP interactions ([#1164](https://github.com/cloudflare/cloudflare-go/issues/1164)) + +## 0.58.0 (January 4th, 2023) + +BREAKING CHANGES: + +- dns: `DNSRecord` has been renamed to `GetDNSRecord` ([#1151](https://github.com/cloudflare/cloudflare-go/issues/1151)) +- dns: `DNSRecords` has been renamed to `ListDNSRecords` ([#1151](https://github.com/cloudflare/cloudflare-go/issues/1151)) +- dns: method signatures have been updated to align with the upcoming client conventions ([#1151](https://github.com/cloudflare/cloudflare-go/issues/1151)) +- origin_ca: renamed to `CreateOriginCertificate` to `CreateOriginCACertificate` ([#1161](https://github.com/cloudflare/cloudflare-go/issues/1161)) +- origin_ca: renamed to `OriginCARootCertificate` to `GetOriginCARootCertificate` ([#1161](https://github.com/cloudflare/cloudflare-go/issues/1161)) +- origin_ca: renamed to `OriginCertificate` to `GetOriginCACertificate` ([#1161](https://github.com/cloudflare/cloudflare-go/issues/1161)) +- origin_ca: renamed to `OriginCertificates` to `ListOriginCACertificates` ([#1161](https://github.com/cloudflare/cloudflare-go/issues/1161)) +- origin_ca: renamed to `RevokeOriginCertificate` to `RevokeOriginCACertificate` ([#1161](https://github.com/cloudflare/cloudflare-go/issues/1161)) + +ENHANCEMENTS: + +- dns: add support for tags and comments ([#1151](https://github.com/cloudflare/cloudflare-go/issues/1151)) +- mtls_certificate: add support for managing mTLS certificates and assocations ([#1150](https://github.com/cloudflare/cloudflare-go/issues/1150)) +- origin_ca: add support for using API keys, API tokens or API User service keys for interacting with Origin CA endpoints ([#1161](https://github.com/cloudflare/cloudflare-go/issues/1161)) +- workers: Add support for workers logpush enablement on script upload ([#1160](https://github.com/cloudflare/cloudflare-go/issues/1160)) + +BUG FIXES: + +- email_routing_destination: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- email_routing_rules: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- filter: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- firewall_rules: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- lockdown: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- queue: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- teams_list: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) +- workers_kv: use empty reponse struct on each page call ([#1156](https://github.com/cloudflare/cloudflare-go/issues/1156)) + +DEPENDENCIES: + +- deps: bumps github.com/hashicorp/go-retryablehttp from 0.7.1 to 0.7.2 ([#1162](https://github.com/cloudflare/cloudflare-go/issues/1162)) + +## 0.57.1 (December 23rd, 2022) + +ENHANCEMENTS: + +- tiered_cache: Add support for Tiered Caching interactions for setting Smart and Generic topologies ([#1149](https://github.com/cloudflare/cloudflare-go/issues/1149)) + +BUG FIXES: + +- workers: correctly set `body` value for non-ES module uploads ([#1155](https://github.com/cloudflare/cloudflare-go/issues/1155)) + +## 0.57.0 (December 22nd, 2022) + +BREAKING CHANGES: + +- workers: API operations now target account level resources instead of older zone level resources (these are a 1:1 now) ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers_bindings: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers_cron_triggers: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers_kv: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers_routes: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers_secrets: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers_tails: method signatures have been updated to align with the upcoming client conventions ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) + +NOTES: + +- workers: all worker methods have been split into product ownership(-ish) files ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) +- workers: all worker methods now require an explicit `ResourceContainer` for endpoints instead of relying on the globally defined `api.AccountID` ([#1137](https://github.com/cloudflare/cloudflare-go/issues/1137)) + +ENHANCEMENTS: + +- managed_networks: add CRUD functionality for managednetworks ([#1148](https://github.com/cloudflare/cloudflare-go/issues/1148)) + +DEPENDENCIES: + +- deps: bumps goreleaser/goreleaser-action from 3.2.0 to 4.1.0 ([#1146](https://github.com/cloudflare/cloudflare-go/issues/1146)) + +## 0.56.0 (December 5th, 2022) + +BREAKING CHANGES: + +- pages: Changed the type of EnvVars in PagesProjectDeploymentConfigEnvironment & PagesProjectDeployment in order to properly support secrets. ([#1136](https://github.com/cloudflare/cloudflare-go/issues/1136)) + +NOTES: + +- pages: removed the v1 logs endpoint for Pages deployments. Please switch to v2: https://developers.cloudflare.com/api/operations/pages-deployment-get-deployment-logs ([#1135](https://github.com/cloudflare/cloudflare-go/issues/1135)) + +ENHANCEMENTS: + +- cache_rules: add ignore option to query string struct ([#1140](https://github.com/cloudflare/cloudflare-go/issues/1140)) +- pages: Updates bindings and other Functions related propreties. Service bindings, secrets, fail open/close and usage model are all now supported. ([#1136](https://github.com/cloudflare/cloudflare-go/issues/1136)) +- workers: Support for Workers Analytics Engine bindings ([#1133](https://github.com/cloudflare/cloudflare-go/issues/1133)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.23.5 to 2.23.6 ([#1139](https://github.com/cloudflare/cloudflare-go/issues/1139)) + +## 0.55.0 (November 23th, 2022) + +BREAKING CHANGES: + +- workers_kv: `CreateWorkersKVNamespace` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `DeleteWorkersKVBulk` has been renamed to `DeleteWorkersKVEntries`. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `DeleteWorkersKVNamespace` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `DeleteWorkersKV` has been renamed to `DeleteWorkersKVEntry`. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `ListWorkersKVNamespaces` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `ListWorkersKVsWithOptions` has been removed. Use `ListWorkersKVKeys` instead and pass in the options. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `ListWorkersKVs` has been renamed to `ListWorkersKVKeys`. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `ReadWorkersKV` has been renamed to `GetWorkersKV`. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `UpdateWorkersKVNamespace` has been updated to match the experimental client method signatures (https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md). ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `WriteWorkersKVBulk` has been renamed to `WriteWorkersKVEntries`. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) +- workers_kv: `WriteWorkersKV` has been renamed to `WriteWorkersKVEntry`. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) + +ENHANCEMENTS: + +- device_posture_rule: add input fields crowdstrike ([#1126](https://github.com/cloudflare/cloudflare-go/issues/1126)) +- queue: add support queue API ([#1131](https://github.com/cloudflare/cloudflare-go/issues/1131)) +- r2: Add support for listing R2 buckets ([#1063](https://github.com/cloudflare/cloudflare-go/issues/1063)) +- workers_domain: add support for workers domain API ([#1130](https://github.com/cloudflare/cloudflare-go/issues/1130)) +- workers_kv: `ListWorkersKVNamespaces` automatically paginates all results unless `PerPage` is defined. ([#1115](https://github.com/cloudflare/cloudflare-go/issues/1115)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.23.4 to 2.23.5 ([#1127](https://github.com/cloudflare/cloudflare-go/issues/1127)) + +## 0.54.0 (November 9th, 2022) + +ENHANCEMENTS: + +- access: add support for service token rotation ([#1120](https://github.com/cloudflare/cloudflare-go/issues/1120)) +- deps: fix import grouping, code formatting and enable goimports linter ([#1121](https://github.com/cloudflare/cloudflare-go/issues/1121)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.3.4 to 1.3.5 ([#1123](https://github.com/cloudflare/cloudflare-go/issues/1123)) +- deps: bumps github.com/urfave/cli/v2 from 2.20.3 to 2.23.0 ([#1122](https://github.com/cloudflare/cloudflare-go/issues/1122)) +- deps: bumps github.com/urfave/cli/v2 from 2.23.0 to 2.23.2 ([#1124](https://github.com/cloudflare/cloudflare-go/issues/1124)) +- deps: bumps github.com/urfave/cli/v2 from 2.23.2 to 2.23.4 ([#1125](https://github.com/cloudflare/cloudflare-go/issues/1125)) + +## 0.53.0 (October 26th, 2022) + +BREAKING CHANGES: + +- account_member: `CreateAccountMember` has been updated to accept a `CreateAccountMemberParams` struct instead of multiple parameters ([#1095](https://github.com/cloudflare/cloudflare-go/issues/1095)) +- teams_list: updated methods to match the experimental client format ([#1114](https://github.com/cloudflare/cloudflare-go/issues/1114)) + +ENHANCEMENTS: + +- account_member: add support for domain scoped roles ([#1095](https://github.com/cloudflare/cloudflare-go/issues/1095)) +- cloudflare: expose `Messages` from the `Response` object ([#1106](https://github.com/cloudflare/cloudflare-go/issues/1106)) +- dlp: Adds support for DLP resources ([#1111](https://github.com/cloudflare/cloudflare-go/issues/1111)) +- teams_list: `List` operations now automatically paginate ([#1114](https://github.com/cloudflare/cloudflare-go/issues/1114)) +- total_tls: adds support for TotalTLS ([#1105](https://github.com/cloudflare/cloudflare-go/issues/1105)) +- waiting_room: add support for waiting room rules ([#1102](https://github.com/cloudflare/cloudflare-go/issues/1102)) + +DEPENDENCIES: + +- deps: `ioutil` package is being deprecated in favor of `io` ([#1116](https://github.com/cloudflare/cloudflare-go/issues/1116)) +- deps: bumps github.com/stretchr/testify from 1.8.0 to 1.8.1 ([#1119](https://github.com/cloudflare/cloudflare-go/issues/1119)) +- deps: bumps github.com/urfave/cli/v2 from 2.19.2 to 2.20.2 ([#1108](https://github.com/cloudflare/cloudflare-go/issues/1108)) +- deps: bumps github.com/urfave/cli/v2 from 2.20.2 to 2.20.3 ([#1118](https://github.com/cloudflare/cloudflare-go/issues/1118)) +- deps: bumps goreleaser/goreleaser-action from 3.1.0 to 3.2.0 ([#1112](https://github.com/cloudflare/cloudflare-go/issues/1112)) +- deps: remove `github.com/pkg/errors` in favor of `errors` ([#1117](https://github.com/cloudflare/cloudflare-go/issues/1117)) + +## 0.52.0 (October 12th, 2022) + +ENHANCEMENTS: + +- access: add UI read-only field to organizations ([#1104](https://github.com/cloudflare/cloudflare-go/issues/1104)) +- devices_policy: Add support for additional device settings policies ([#1090](https://github.com/cloudflare/cloudflare-go/issues/1090)) +- rulesets: add support for `sensitivity_level` to override all rule sensitivity ([#1093](https://github.com/cloudflare/cloudflare-go/issues/1093)) + +DEPENDENCIES: + +- deps: bumps dependabot/fetch-metadata from 1.3.3 to 1.3.4 ([#1097](https://github.com/cloudflare/cloudflare-go/issues/1097)) +- deps: bumps github.com/urfave/cli/v2 from 2.16.3 to 2.17.1 ([#1094](https://github.com/cloudflare/cloudflare-go/issues/1094)) +- deps: bumps github.com/urfave/cli/v2 from 2.17.1 to 2.19.2 ([#1103](https://github.com/cloudflare/cloudflare-go/issues/1103)) + +## 0.51.0 (September 28th, 2022) + +BREAKING CHANGES: + +- load_balancing: update method signatures to match experimental conventions ([#1084](https://github.com/cloudflare/cloudflare-go/issues/1084)) + +ENHANCEMENTS: + +- device_posture_rule: add input fields for linux OS ([#1087](https://github.com/cloudflare/cloudflare-go/issues/1087)) +- load_balancing: support adaptive_routing and location_strategy ([#1091](https://github.com/cloudflare/cloudflare-go/issues/1091)) + +BUG FIXES: + +- user-agent-blocking-rules: add missing managed_challenge validation and removed the deprecated whitelist one ([#1089](https://github.com/cloudflare/cloudflare-go/issues/1089)) + +## 0.50.0 (September 14, 2022) + +ENHANCEMENTS: + +- auditlogs: add support for hide_user_logs filter parameter ([#1075](https://github.com/cloudflare/cloudflare-go/issues/1075)) + +BUG FIXES: + +- cloudflare: exiting closer to the source on context timeouts to improve error messaging and better defend from potential edge cases ([#1080](https://github.com/cloudflare/cloudflare-go/issues/1080)) +- origin certificate: Fix API auth type used ([#1082](https://github.com/cloudflare/cloudflare-go/issues/1082)) + +DEPENDENCIES: + +- deps: bumps github.com/urfave/cli/v2 from 2.11.2 to 2.14.0 ([#1077](https://github.com/cloudflare/cloudflare-go/issues/1077)) +- deps: bumps github.com/urfave/cli/v2 from 2.14.0 to 2.14.1 ([#1081](https://github.com/cloudflare/cloudflare-go/issues/1081)) +- deps: bumps github.com/urfave/cli/v2 from 2.14.1 to 2.15.0 ([#1085](https://github.com/cloudflare/cloudflare-go/issues/1085)) +- deps: bumps github.com/urfave/cli/v2 from 2.15.0 to 2.16.3 ([#1086](https://github.com/cloudflare/cloudflare-go/issues/1086)) + +## 0.49.0 (August 31st, 2022) + +ENHANCEMENTS: + +- access_service_token: add support for refreshing an existing token in place ([#1074](https://github.com/cloudflare/cloudflare-go/issues/1074)) +- api: addded context and headers to Raw method ([#1068](https://github.com/cloudflare/cloudflare-go/issues/1068)) +- api_shield: add GET/PUT for API Shield Configuration ([#1059](https://github.com/cloudflare/cloudflare-go/issues/1059)) +- pages_project: Add `kv_namespaces`, `durable_object_namespaces`, `r2_buckets`, and `d1_databases` bindings to deployment config ([#1065](https://github.com/cloudflare/cloudflare-go/issues/1065)) +- pages_project: Add `preview_deployment_setting`, `preview_branch_includes`, and `preview_branch_excludes` to source config ([#1065](https://github.com/cloudflare/cloudflare-go/issues/1065)) +- pages_project: Add `production_branch` field ([#1065](https://github.com/cloudflare/cloudflare-go/issues/1065)) +- teams_account: add support for `os_distro_name` and `os_distro_revision` ([#1073](https://github.com/cloudflare/cloudflare-go/issues/1073)) +- url_normalization_settings: Add APIs to get and update URL normalization settings ([#1071](https://github.com/cloudflare/cloudflare-go/issues/1071)) +- workers: Support for multipart encoding for DownloadWorker on a module-format Worker script ([#1040](https://github.com/cloudflare/cloudflare-go/issues/1040)) + +BUG FIXES: + +- cloudflare: fix nil dereference error in makeRequestWithAuthTypeAndHeaders ([#1072](https://github.com/cloudflare/cloudflare-go/issues/1072)) +- email_routing_rules: Fix response for email routing catch all rule. ([#1070](https://github.com/cloudflare/cloudflare-go/issues/1070)) +- email_routing_settings: change enable endpoint from `enabled` to `enable` ([#1060](https://github.com/cloudflare/cloudflare-go/issues/1060)) +- stream: Update pctComplete to string from int ([#1066](https://github.com/cloudflare/cloudflare-go/issues/1066)) + +DEPENDENCIES: + +- deps: bumps goreleaser/goreleaser-action from 3.0.0 to 3.1.0 ([#1067](https://github.com/cloudflare/cloudflare-go/issues/1067)) + +## 0.48.0 (August 22nd, 2022) + +ENHANCEMENTS: + +- errors: add some error type convenience functions for mocking and inspection ([#1047](https://github.com/cloudflare/cloudflare-go/issues/1047)) +- pages_project: Add compatibility date and compatibility_flags to pages deployment configs ([#1051](https://github.com/cloudflare/cloudflare-go/issues/1051)) +- teams_account: add support for `suppress_footer` ([#1053](https://github.com/cloudflare/cloudflare-go/issues/1053)) + +BUG FIXES: + +- r2: fix create bucket endpoint ([#1035](https://github.com/cloudflare/cloudflare-go/issues/1035)) +- tunnel_configuration: Remove unnecessary double-unmarshalling due to changes in the API ([#1046](https://github.com/cloudflare/cloudflare-go/issues/1046)) + +## 0.47.1 (August 18th, 2022) + +BUG FIXES: + +- zonelockdown: add `Priority` to `ZoneLockdownCreateParams` and `ZoneLockdownUpdateParams` ([#1052](https://github.com/cloudflare/cloudflare-go/issues/1052)) + +## 0.47.0 (August 17th, 2022) + +BREAKING CHANGES: + +- certificate_packs: deprecate "custom" configuration for ACM everywhere ([#1032](https://github.com/cloudflare/cloudflare-go/issues/1032)) + +ENHANCEMENTS: + +- cloudflare: make it clear when the rate limit retries have been exhausted ([#1043](https://github.com/cloudflare/cloudflare-go/issues/1043)) +- email_routing_destination: Adds support for the email routing destination API ([#1034](https://github.com/cloudflare/cloudflare-go/issues/1034)) +- email_routing_rules: Adds support for the email routing rules API ([#1034](https://github.com/cloudflare/cloudflare-go/issues/1034)) +- email_routing_settings: Adds support for the email routing settings API ([#1034](https://github.com/cloudflare/cloudflare-go/issues/1034)) +- filter: fix double endpoint calls & moving towards common method signature ([#1016](https://github.com/cloudflare/cloudflare-go/issues/1016)) +- firewall_rule: fix double endpoint calls & moving towards common method signature ([#1016](https://github.com/cloudflare/cloudflare-go/issues/1016)) +- lockdown: automatically paginate `List` results unless `Page` and `PerPage` are provided ([#1017](https://github.com/cloudflare/cloudflare-go/issues/1017)) +- r2: Add in support for creating and deleting R2 buckets ([#1028](https://github.com/cloudflare/cloudflare-go/issues/1028)) +- rulesets: add support for `http_config_settings` phase and supporting actions ([#1036](https://github.com/cloudflare/cloudflare-go/issues/1036)) +- workers-account-settings: Add in support for Workers account settings API ([#1027](https://github.com/cloudflare/cloudflare-go/issues/1027)) +- workers-subdomain: Add in support Workers Subdomain API ([#1031](https://github.com/cloudflare/cloudflare-go/issues/1031)) +- workers-tail: Add in support for Workers tail API ([#1026](https://github.com/cloudflare/cloudflare-go/issues/1026)) +- workers: Add support for attaching a worker to a domain ([#1014](https://github.com/cloudflare/cloudflare-go/issues/1014)) +- workers: Add support to upload module workers ([#1010](https://github.com/cloudflare/cloudflare-go/issues/1010)) + +BUG FIXES: + +- email_routing_destination: Update API reference URLs ([#1038](https://github.com/cloudflare/cloudflare-go/issues/1038)) +- email_routing_rules: Update API reference URLs ([#1038](https://github.com/cloudflare/cloudflare-go/issues/1038)) +- email_routing_settings: Update API reference URLs ([#1038](https://github.com/cloudflare/cloudflare-go/issues/1038)) +- tunnel_routes: Fix not removing route when it contains virtual network ([#1030](https://github.com/cloudflare/cloudflare-go/issues/1030)) +- workers_test: Fix incorrect test from PR #1014 ([#1048](https://github.com/cloudflare/cloudflare-go/issues/1048)) +- workers_test: Use application/json mime-type in headers ([#1049](https://github.com/cloudflare/cloudflare-go/issues/1049)) + +DEPENDENCIES: + +- deps: bumps golang.org/x/tools/gopls from 0.9.3 to 0.9.4 ([#1044](https://github.com/cloudflare/cloudflare-go/issues/1044)) +- deps: bumps github.com/golangci/golangci-lint from 1.47.3 to 1.48.0 ([#1020](https://github.com/cloudflare/cloudflare-go/issues/1020)) +- deps: bumps github.com/urfave/cli/v2 from 2.11.1 to 2.11.2 ([#1042](https://github.com/cloudflare/cloudflare-go/issues/1042)) +- deps: bumps golang.org/x/tools/gopls from 0.9.1 to 0.9.2 ([#1037](https://github.com/cloudflare/cloudflare-go/issues/1037)) +- deps: bumps golang.org/x/tools/gopls from 0.9.2 to 0.9.3 ([#1039](https://github.com/cloudflare/cloudflare-go/issues/1039)) + +## 0.46.0 (3rd August, 2022) + +NOTES: + +- docs: add release notes ([#1001](https://github.com/cloudflare/cloudflare-go/issues/1001)) + +ENHANCEMENTS: + +- filter: automatically paginate `List` results unless `Page` and `PerPage` are provided ([#1004](https://github.com/cloudflare/cloudflare-go/issues/1004)) +- firewall_rule: automatically paginate `List` results unless `Page` and `PerPage` are provided ([#1004](https://github.com/cloudflare/cloudflare-go/issues/1004)) +- rulesets: add support for `http_custom_errors` phase ([#998](https://github.com/cloudflare/cloudflare-go/issues/998)) +- rulesets: add support for `serve_error` action ([#998](https://github.com/cloudflare/cloudflare-go/issues/998)) + +BUG FIXES: + +- access_application: fix inability to set bool values to false ([#1006](https://github.com/cloudflare/cloudflare-go/issues/1006)) +- rulesets: fix sni action parameter ([#1002](https://github.com/cloudflare/cloudflare-go/issues/1002)) + +DEPENDENCIES: + +- provider: bumps github.com/golangci/golangci-lint from 1.47.1 to 1.47.2 ([#1005](https://github.com/cloudflare/cloudflare-go/issues/1005)) +- provider: bumps github.com/golangci/golangci-lint from 1.47.2 to 1.47.3 ([#1008](https://github.com/cloudflare/cloudflare-go/issues/1008)) +- provider: bumps github.com/urfave/cli/v2 from 2.11.0 to 2.11.1 ([#1003](https://github.com/cloudflare/cloudflare-go/issues/1003)) + +## 0.45.0 (July 20th, 2022) diff --git a/pkg/cloudflare-go/CODE_OF_CONDUCT.md b/pkg/cloudflare-go/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..bfbc69d22 --- /dev/null +++ b/pkg/cloudflare-go/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at ggalow@cloudflare.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq + diff --git a/pkg/cloudflare-go/LICENSE b/pkg/cloudflare-go/LICENSE new file mode 100644 index 000000000..39e4ddc27 --- /dev/null +++ b/pkg/cloudflare-go/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2015-2022, Cloudflare. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors +may be used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkg/cloudflare-go/README.md b/pkg/cloudflare-go/README.md new file mode 100644 index 000000000..dc9355846 --- /dev/null +++ b/pkg/cloudflare-go/README.md @@ -0,0 +1,85 @@ +# cloudflare-go + +[![Go Reference](https://pkg.go.dev/badge/github.com/cloudflare/cloudflare-go.svg)](https://pkg.go.dev/github.com/cloudflare/cloudflare-go) +![Test](https://github.com/cloudflare/cloudflare-go/workflows/Test/badge.svg) +[![Go Report Card](https://goreportcard.com/badge/github.com/cloudflare/cloudflare-go?style=flat-square)](https://goreportcard.com/report/github.com/cloudflare/cloudflare-go) + +> **Note**: This library is under active development as we expand it to cover +> our (expanding!) API. Consider the public API of this package a little +> unstable as we work towards a v1.0. + +A Go library for interacting with +[Cloudflare's API v4](https://api.cloudflare.com/). This library allows you to: + +- Manage and automate changes to your DNS records within Cloudflare +- Manage and automate changes to your zones (domains) on Cloudflare, including + adding new zones to your account +- List and modify the status of WAF (Web Application Firewall) rules for your + zones +- Fetch Cloudflare's IP ranges for automating your firewall whitelisting + +A command-line client, [flarectl](cmd/flarectl), is also available as part of +this project. + +## Installation + +You need a working Go environment. We officially support only currently supported Go versions according to [Go project's release policy](https://go.dev/doc/devel/release#policy). + +``` +go get github.com/cloudflare/cloudflare-go +``` + +## Getting Started + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/cloudflare/cloudflare-go" +) + +func main() { + // Construct a new API object using a global API key + api, err := cloudflare.New(os.Getenv("CLOUDFLARE_API_KEY"), os.Getenv("CLOUDFLARE_API_EMAIL")) + // alternatively, you can use a scoped API token + // api, err := cloudflare.NewWithAPIToken(os.Getenv("CLOUDFLARE_API_TOKEN")) + if err != nil { + log.Fatal(err) + } + + // Most API calls require a Context + ctx := context.Background() + + // Fetch user details on the account + u, err := api.UserDetails(ctx) + if err != nil { + log.Fatal(err) + } + // Print user details + fmt.Println(u) +} +``` + +Also refer to the +[API documentation](https://pkg.go.dev/github.com/cloudflare/cloudflare-go) for +how to use this package in-depth. + +## Experimental improvements + +This library is starting to ship with experimental improvements that are not yet +ready for production but will be introduced before the next major version. See +[experimental README](/docs/experimental.md) for full details. + +## Contributing + +Pull Requests are welcome, but please open an issue (or comment in an existing +issue) to discuss any non-trivial changes before submitting code. + +## License + +BSD licensed. See the [LICENSE](LICENSE) file for details. diff --git a/pkg/cloudflare-go/access_application.go b/pkg/cloudflare-go/access_application.go new file mode 100644 index 000000000..bdeaf79cd --- /dev/null +++ b/pkg/cloudflare-go/access_application.go @@ -0,0 +1,510 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AccessApplicationType represents the application type. +type AccessApplicationType string + +// These constants represent all valid application types. +const ( + SelfHosted AccessApplicationType = "self_hosted" + SSH AccessApplicationType = "ssh" + VNC AccessApplicationType = "vnc" + Biso AccessApplicationType = "biso" + AppLauncher AccessApplicationType = "app_launcher" + Warp AccessApplicationType = "warp" + Bookmark AccessApplicationType = "bookmark" + Saas AccessApplicationType = "saas" +) + +// AccessApplication represents an Access application. +type AccessApplication struct { + GatewayRules []AccessApplicationGatewayRule `json:"gateway_rules,omitempty"` + AllowedIdps []string `json:"allowed_idps,omitempty"` + CustomDenyMessage string `json:"custom_deny_message,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + AUD string `json:"aud,omitempty"` + Domain string `json:"domain"` + SelfHostedDomains []string `json:"self_hosted_domains"` + Type AccessApplicationType `json:"type,omitempty"` + SessionDuration string `json:"session_duration,omitempty"` + SameSiteCookieAttribute string `json:"same_site_cookie_attribute,omitempty"` + CustomDenyURL string `json:"custom_deny_url,omitempty"` + CustomNonIdentityDenyURL string `json:"custom_non_identity_deny_url,omitempty"` + Name string `json:"name"` + ID string `json:"id,omitempty"` + PrivateAddress string `json:"private_address"` + CorsHeaders *AccessApplicationCorsHeaders `json:"cors_headers,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + SaasApplication *SaasApplication `json:"saas_app,omitempty"` + AutoRedirectToIdentity *bool `json:"auto_redirect_to_identity,omitempty"` + SkipInterstitial *bool `json:"skip_interstitial,omitempty"` + AppLauncherVisible *bool `json:"app_launcher_visible,omitempty"` + EnableBindingCookie *bool `json:"enable_binding_cookie,omitempty"` + HttpOnlyCookieAttribute *bool `json:"http_only_cookie_attribute,omitempty"` + ServiceAuth401Redirect *bool `json:"service_auth_401_redirect,omitempty"` + PathCookieAttribute *bool `json:"path_cookie_attribute,omitempty"` + AllowAuthenticateViaWarp *bool `json:"allow_authenticate_via_warp,omitempty"` + OptionsPreflightBypass *bool `json:"options_preflight_bypass,omitempty"` + CustomPages []string `json:"custom_pages,omitempty"` + Tags []string `json:"tags,omitempty"` + SCIMConfig *AccessApplicationSCIMConfig `json:"scim_config,omitempty"` + Policies []AccessPolicy `json:"policies,omitempty"` + AccessAppLauncherCustomization +} + +type AccessApplicationGatewayRule struct { + ID string `json:"id,omitempty"` +} + +// AccessApplicationCorsHeaders represents the CORS HTTP headers for an Access +// Application. +type AccessApplicationCorsHeaders struct { + AllowedMethods []string `json:"allowed_methods,omitempty"` + AllowedOrigins []string `json:"allowed_origins,omitempty"` + AllowedHeaders []string `json:"allowed_headers,omitempty"` + AllowAllMethods bool `json:"allow_all_methods,omitempty"` + AllowAllHeaders bool `json:"allow_all_headers,omitempty"` + AllowAllOrigins bool `json:"allow_all_origins,omitempty"` + AllowCredentials bool `json:"allow_credentials,omitempty"` + MaxAge int `json:"max_age,omitempty"` +} + +// AccessApplicationSCIMConfig represents the configuration for provisioning to an Access Application via SCIM. +type AccessApplicationSCIMConfig struct { + Enabled *bool `json:"enabled,omitempty"` + RemoteURI string `json:"remote_uri,omitempty"` + Authentication *AccessApplicationScimAuthenticationJson `json:"authentication,omitempty"` + IdPUID string `json:"idp_uid,omitempty"` + DeactivateOnDelete *bool `json:"deactivate_on_delete,omitempty"` + Mappings []*AccessApplicationScimMapping `json:"mappings,omitempty"` +} + +type AccessApplicationScimAuthenticationScheme string + +const ( + AccessApplicationScimAuthenticationSchemeHttpBasic AccessApplicationScimAuthenticationScheme = "httpbasic" + AccessApplicationScimAuthenticationSchemeOauthBearerToken AccessApplicationScimAuthenticationScheme = "oauthbearertoken" + AccessApplicationScimAuthenticationSchemeOauth2 AccessApplicationScimAuthenticationScheme = "oauth2" +) + +type AccessApplicationScimAuthenticationJson struct { + Value AccessApplicationScimAuthentication +} + +func (a *AccessApplicationScimAuthenticationJson) UnmarshalJSON(buf []byte) error { + var scheme baseScimAuthentication + if err := json.Unmarshal(buf, &scheme); err != nil { + return err + } + + switch scheme.Scheme { + case AccessApplicationScimAuthenticationSchemeHttpBasic: + a.Value = new(AccessApplicationScimAuthenticationHttpBasic) + case AccessApplicationScimAuthenticationSchemeOauthBearerToken: + a.Value = new(AccessApplicationScimAuthenticationOauthBearerToken) + case AccessApplicationScimAuthenticationSchemeOauth2: + a.Value = new(AccessApplicationScimAuthenticationOauth2) + default: + return errors.New("invalid authentication scheme") + } + + return json.Unmarshal(buf, a.Value) +} + +func (a *AccessApplicationScimAuthenticationJson) MarshalJSON() ([]byte, error) { + return json.Marshal(a.Value) +} + +type AccessApplicationScimAuthentication interface { + isScimAuthentication() +} + +type baseScimAuthentication struct { + Scheme AccessApplicationScimAuthenticationScheme `json:"scheme"` +} + +func (baseScimAuthentication) isScimAuthentication() {} + +type AccessApplicationScimAuthenticationHttpBasic struct { + baseScimAuthentication + User string `json:"user"` + Password string `json:"password"` +} + +type AccessApplicationScimAuthenticationOauthBearerToken struct { + baseScimAuthentication + Token string `json:"token"` +} + +type AccessApplicationScimAuthenticationOauth2 struct { + baseScimAuthentication + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthorizationURL string `json:"authorization_url"` + TokenURL string `json:"token_url"` + Scopes []string `json:"scopes,omitempty"` +} + +type AccessApplicationScimMapping struct { + Schema string `json:"schema"` + Enabled *bool `json:"enabled,omitempty"` + Filter string `json:"filter,omitempty"` + TransformJsonata string `json:"transform_jsonata,omitempty"` + Operations *AccessApplicationScimMappingOperations `json:"operations,omitempty"` +} + +type AccessApplicationScimMappingOperations struct { + Create *bool `json:"create,omitempty"` + Update *bool `json:"update,omitempty"` + Delete *bool `json:"delete,omitempty"` +} + +// AccessApplicationListResponse represents the response from the list +// access applications endpoint. +type AccessApplicationListResponse struct { + Result []AccessApplication `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessApplicationDetailResponse is the API response, containing a single +// access application. +type AccessApplicationDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessApplication `json:"result"` +} + +type SourceConfig struct { + Name string `json:"name,omitempty"` + NameByIDP map[string]string `json:"name_by_idp,omitempty"` +} + +type SAMLAttributeConfig struct { + Name string `json:"name,omitempty"` + NameFormat string `json:"name_format,omitempty"` + FriendlyName string `json:"friendly_name,omitempty"` + Required bool `json:"required,omitempty"` + Source SourceConfig `json:"source"` +} + +type OIDCClaimConfig struct { + Name string `json:"name,omitempty"` + Source SourceConfig `json:"source"` + Required *bool `json:"required,omitempty"` + Scope string `json:"scope,omitempty"` +} + +type RefreshTokenOptions struct { + Lifetime string `json:"lifetime,omitempty"` +} + +type AccessApplicationHybridAndImplicitOptions struct { + ReturnIDTokenFromAuthorizationEndpoint *bool `json:"return_id_token_from_authorization_endpoint,omitempty"` + ReturnAccessTokenFromAuthorizationEndpoint *bool `json:"return_access_token_from_authorization_endpoint,omitempty"` +} + +type SaasApplication struct { + // Items common to both SAML and OIDC + AppID string `json:"app_id,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + PublicKey string `json:"public_key,omitempty"` + AuthType string `json:"auth_type,omitempty"` + + // SAML saas app + ConsumerServiceUrl string `json:"consumer_service_url,omitempty"` + SPEntityID string `json:"sp_entity_id,omitempty"` + IDPEntityID string `json:"idp_entity_id,omitempty"` + NameIDFormat string `json:"name_id_format,omitempty"` + SSOEndpoint string `json:"sso_endpoint,omitempty"` + DefaultRelayState string `json:"default_relay_state,omitempty"` + CustomAttributes []SAMLAttributeConfig `json:"custom_attributes,omitempty"` + NameIDTransformJsonata string `json:"name_id_transform_jsonata,omitempty"` + SamlAttributeTransformJsonata string `json:"saml_attribute_transform_jsonata"` + + // OIDC saas app + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AppLauncherURL string `json:"app_launcher_url,omitempty"` + GroupFilterRegex string `json:"group_filter_regex,omitempty"` + CustomClaims []OIDCClaimConfig `json:"custom_claims,omitempty"` + AllowPKCEWithoutClientSecret *bool `json:"allow_pkce_without_client_secret,omitempty"` + RefreshTokenOptions *RefreshTokenOptions `json:"refresh_token_options,omitempty"` + HybridAndImplicitOptions *AccessApplicationHybridAndImplicitOptions `json:"hybrid_and_implicit_options,omitempty"` + AccessTokenLifetime string `json:"access_token_lifetime,omitempty"` +} + +type AccessAppLauncherCustomization struct { + LandingPageDesign AccessLandingPageDesign `json:"landing_page_design"` + LogoURL string `json:"app_launcher_logo_url"` + HeaderBackgroundColor string `json:"header_bg_color"` + BackgroundColor string `json:"bg_color"` + FooterLinks []AccessFooterLink `json:"footer_links"` +} + +type AccessFooterLink struct { + Name string `json:"name"` + URL string `json:"url"` +} + +type AccessLandingPageDesign struct { + Title string `json:"title"` + Message string `json:"message"` + ImageURL string `json:"image_url"` + ButtonColor string `json:"button_color"` + ButtonTextColor string `json:"button_text_color"` +} + +type ListAccessApplicationsParams struct { + ResultInfo +} + +type CreateAccessApplicationParams struct { + AllowedIdps []string `json:"allowed_idps,omitempty"` + AppLauncherVisible *bool `json:"app_launcher_visible,omitempty"` + AUD string `json:"aud,omitempty"` + AutoRedirectToIdentity *bool `json:"auto_redirect_to_identity,omitempty"` + CorsHeaders *AccessApplicationCorsHeaders `json:"cors_headers,omitempty"` + CustomDenyMessage string `json:"custom_deny_message,omitempty"` + CustomDenyURL string `json:"custom_deny_url,omitempty"` + CustomNonIdentityDenyURL string `json:"custom_non_identity_deny_url,omitempty"` + Domain string `json:"domain"` + EnableBindingCookie *bool `json:"enable_binding_cookie,omitempty"` + GatewayRules []AccessApplicationGatewayRule `json:"gateway_rules,omitempty"` + HttpOnlyCookieAttribute *bool `json:"http_only_cookie_attribute,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + Name string `json:"name"` + PathCookieAttribute *bool `json:"path_cookie_attribute,omitempty"` + PrivateAddress string `json:"private_address"` + SaasApplication *SaasApplication `json:"saas_app,omitempty"` + SameSiteCookieAttribute string `json:"same_site_cookie_attribute,omitempty"` + SelfHostedDomains []string `json:"self_hosted_domains"` + ServiceAuth401Redirect *bool `json:"service_auth_401_redirect,omitempty"` + SessionDuration string `json:"session_duration,omitempty"` + SkipInterstitial *bool `json:"skip_interstitial,omitempty"` + OptionsPreflightBypass *bool `json:"options_preflight_bypass,omitempty"` + Type AccessApplicationType `json:"type,omitempty"` + AllowAuthenticateViaWarp *bool `json:"allow_authenticate_via_warp,omitempty"` + CustomPages []string `json:"custom_pages,omitempty"` + Tags []string `json:"tags,omitempty"` + SCIMConfig *AccessApplicationSCIMConfig `json:"scim_config,omitempty"` + // List of policy ids to link to this application in ascending order of precedence. + Policies []string `json:"policies,omitempty"` + AccessAppLauncherCustomization +} + +type UpdateAccessApplicationParams struct { + ID string `json:"id,omitempty"` + AllowedIdps []string `json:"allowed_idps,omitempty"` + AppLauncherVisible *bool `json:"app_launcher_visible,omitempty"` + AUD string `json:"aud,omitempty"` + AutoRedirectToIdentity *bool `json:"auto_redirect_to_identity,omitempty"` + CorsHeaders *AccessApplicationCorsHeaders `json:"cors_headers,omitempty"` + CustomDenyMessage string `json:"custom_deny_message,omitempty"` + CustomDenyURL string `json:"custom_deny_url,omitempty"` + CustomNonIdentityDenyURL string `json:"custom_non_identity_deny_url,omitempty"` + Domain string `json:"domain"` + EnableBindingCookie *bool `json:"enable_binding_cookie,omitempty"` + GatewayRules []AccessApplicationGatewayRule `json:"gateway_rules,omitempty"` + HttpOnlyCookieAttribute *bool `json:"http_only_cookie_attribute,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + Name string `json:"name"` + PathCookieAttribute *bool `json:"path_cookie_attribute,omitempty"` + PrivateAddress string `json:"private_address"` + SaasApplication *SaasApplication `json:"saas_app,omitempty"` + SameSiteCookieAttribute string `json:"same_site_cookie_attribute,omitempty"` + SelfHostedDomains []string `json:"self_hosted_domains"` + ServiceAuth401Redirect *bool `json:"service_auth_401_redirect,omitempty"` + SessionDuration string `json:"session_duration,omitempty"` + SkipInterstitial *bool `json:"skip_interstitial,omitempty"` + Type AccessApplicationType `json:"type,omitempty"` + AllowAuthenticateViaWarp *bool `json:"allow_authenticate_via_warp,omitempty"` + OptionsPreflightBypass *bool `json:"options_preflight_bypass,omitempty"` + CustomPages []string `json:"custom_pages,omitempty"` + Tags []string `json:"tags,omitempty"` + SCIMConfig *AccessApplicationSCIMConfig `json:"scim_config,omitempty"` + // List of policy ids to link to this application in ascending order of precedence. + // Can reference reusable policies and policies specific to this application. + // If this field is not provided, the existing policies will not be modified. + Policies *[]string `json:"policies,omitempty"` + AccessAppLauncherCustomization +} + +// ListAccessApplications returns all applications within an account or zone. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-applications-list-access-applications +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-applications-list-access-applications +func (api *API) ListAccessApplications(ctx context.Context, rc *ResourceContainer, params ListAccessApplicationsParams) ([]AccessApplication, *ResultInfo, error) { + baseURL := fmt.Sprintf("/%s/%s/access/apps", rc.Level, rc.Identifier) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var applications []AccessApplication + var r AccessApplicationListResponse + + for { + uri := buildURI(baseURL, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessApplication{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessApplication{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + applications = append(applications, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return applications, &r.ResultInfo, nil +} + +// GetAccessApplication returns a single application based on the application +// ID for either account or zone. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-applications-get-an-access-application +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-applications-get-an-access-application +func (api *API) GetAccessApplication(ctx context.Context, rc *ResourceContainer, applicationID string) (AccessApplication, error) { + uri := fmt.Sprintf( + "/%s/%s/access/apps/%s", + rc.Level, + rc.Identifier, + applicationID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessApplication{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessApplicationDetailResponse AccessApplicationDetailResponse + err = json.Unmarshal(res, &accessApplicationDetailResponse) + if err != nil { + return AccessApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessApplicationDetailResponse.Result, nil +} + +// CreateAccessApplication creates a new access application. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-applications-add-an-application +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-applications-add-a-bookmark-application +func (api *API) CreateAccessApplication(ctx context.Context, rc *ResourceContainer, params CreateAccessApplicationParams) (AccessApplication, error) { + uri := fmt.Sprintf("/%s/%s/access/apps", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessApplication{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessApplicationDetailResponse AccessApplicationDetailResponse + err = json.Unmarshal(res, &accessApplicationDetailResponse) + if err != nil { + return AccessApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessApplicationDetailResponse.Result, nil +} + +// UpdateAccessApplication updates an existing access application. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-applications-update-a-bookmark-application +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-applications-update-a-bookmark-application +func (api *API) UpdateAccessApplication(ctx context.Context, rc *ResourceContainer, params UpdateAccessApplicationParams) (AccessApplication, error) { + if params.ID == "" { + return AccessApplication{}, fmt.Errorf("access application ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/access/apps/%s", + rc.Level, + rc.Identifier, + params.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessApplication{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessApplicationDetailResponse AccessApplicationDetailResponse + err = json.Unmarshal(res, &accessApplicationDetailResponse) + if err != nil { + return AccessApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessApplicationDetailResponse.Result, nil +} + +// DeleteAccessApplication deletes an access application. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-applications-delete-an-access-application +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-applications-delete-an-access-application +func (api *API) DeleteAccessApplication(ctx context.Context, rc *ResourceContainer, applicationID string) error { + uri := fmt.Sprintf( + "/%s/%s/access/apps/%s", + rc.Level, + rc.Identifier, + applicationID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} + +// RevokeAccessApplicationTokens revokes tokens associated with an +// access application. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-applications-revoke-service-tokens +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-applications-revoke-service-tokens +func (api *API) RevokeAccessApplicationTokens(ctx context.Context, rc *ResourceContainer, applicationID string) error { + uri := fmt.Sprintf( + "/%s/%s/access/apps/%s/revoke-tokens", + rc.Level, + rc.Identifier, + applicationID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/access_application_test.go b/pkg/cloudflare-go/access_application_test.go new file mode 100644 index 000000000..178386395 --- /dev/null +++ b/pkg/cloudflare-go/access_application_test.go @@ -0,0 +1,1545 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testAccessApplicationID = "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" +) + +func TestAccessApplications(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Site", + "domain": "test.example.com/admin", + "type": "self_hosted", + "session_duration": "24h", + "allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_non_identity_deny_url": "https://blocked.com", + "custom_deny_message": "denied!", + "http_only_cookie_attribute": true, + "same_site_cookie_attribute": "strict", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "path_cookie_attribute": true, + "custom_pages": ["480f4f69-1a28-4fdd-9240-1ed29f0ac1dc"], + "tags": ["engineers"], + "allow_authenticate_via_warp": true, + "options_preflight_bypass": false, + "policies": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "reusable": true, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []AccessApplication{{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + Name: "Admin Site", + Domain: "test.example.com/admin", + Type: "self_hosted", + SessionDuration: "24h", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + SameSiteCookieAttribute: "strict", + HttpOnlyCookieAttribute: BoolPtr(true), + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + PathCookieAttribute: BoolPtr(true), + CustomPages: []string{"480f4f69-1a28-4fdd-9240-1ed29f0ac1dc"}, + Tags: []string{"engineers"}, + CustomNonIdentityDenyURL: "https://blocked.com", + AllowAuthenticateViaWarp: BoolPtr(true), + OptionsPreflightBypass: BoolPtr(false), + Policies: []AccessPolicy{ + { + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Reusable: BoolPtr(true), + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []any{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + }, + }, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, _, err := client.ListAccessApplications(context.Background(), AccountIdentifier(testAccountID), ListAccessApplicationsParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps", handler) + + actual, _, err = client.ListAccessApplications(context.Background(), ZoneIdentifier(testZoneID), ListAccessApplicationsParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessApplication(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Site", + "domain": "test.example.com/admin", + "self_hosted_domains": ["test.example.com/admin", "test.example.com/admin2"], + "type": "self_hosted", + "session_duration": "24h", + "allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_non_identity_deny_url": "https://blocked.com", + "custom_deny_message": "denied!", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "http_only_cookie_attribute": false, + "path_cookie_attribute": false, + "allow_authenticate_via_warp": false, + "options_preflight_bypass": false, + "policies": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "reusable": true, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + ] + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + HttpOnlyCookieAttribute: BoolPtr(false), + PathCookieAttribute: BoolPtr(false), + CustomNonIdentityDenyURL: "https://blocked.com", + AllowAuthenticateViaWarp: BoolPtr(false), + OptionsPreflightBypass: BoolPtr(false), + Policies: []AccessPolicy{ + { + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Reusable: BoolPtr(true), + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []any{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.GetAccessApplication(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err = client.GetAccessApplication(context.Background(), ZoneIdentifier(testZoneID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessApplications(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Site", + "domain": "test.example.com/admin", + "self_hosted_domains": ["test.example.com/admin", "test.example.com/admin2"], + "type": "self_hosted", + "session_duration": "24h", + "allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "custom_non_identity_deny_url": "https://blocked.com", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "tags": ["engineers"], + "allow_authenticate_via_warp": false, + "options_preflight_bypass": true, + "policies": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "reusable": true, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + ] + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + AllowAuthenticateViaWarp: BoolPtr(false), + OptionsPreflightBypass: BoolPtr(true), + Policies: []AccessPolicy{ + { + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Reusable: BoolPtr(true), + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []any{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{ + Name: "Admin Site", + Domain: "test.example.com/admin", + SessionDuration: "24h", + Policies: []string{"699d98642c564d2e855e9661899b7252"}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps", handler) + + actual, err = client.CreateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), CreateAccessApplicationParams{ + Name: "Admin Site", + Domain: "test.example.com/admin", + SessionDuration: "24h", + Policies: []string{"699d98642c564d2e855e9661899b7252"}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestUpdateAccessApplication(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Site", + "domain": "test.example.com/admin", + "self_hosted_domains": ["test.example.com/admin", "test.example.com/admin2"], + "type": "self_hosted", + "session_duration": "24h", + "allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "custom_non_identity_deny_url": "https://blocked.com", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "tags": ["engineers"], + "allow_authenticate_via_warp": true, + "options_preflight_bypass": true, + "policies": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "reusable": true, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + ] + } + } + `) + } + + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + SkipInterstitial: BoolPtr(true), + AllowAuthenticateViaWarp: BoolPtr(true), + OptionsPreflightBypass: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Policies: []AccessPolicy{ + { + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Reusable: BoolPtr(true), + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []any{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + }, + }, + } + + params := UpdateAccessApplicationParams{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + AllowAuthenticateViaWarp: BoolPtr(true), + OptionsPreflightBypass: BoolPtr(true), + Policies: &[]string{"699d98642c564d2e855e9661899b7252"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.UpdateAccessApplication(context.Background(), AccountIdentifier(testAccountID), params) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err = client.UpdateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), UpdateAccessApplicationParams{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CustomNonIdentityDenyURL: "https://blocked.com", + OptionsPreflightBypass: BoolPtr(true), + Policies: &[]string{"699d98642c564d2e855e9661899b7252"}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestUpdateAccessApplicationOmitPolicies(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + reqBody, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.NotContains(t, string(reqBody), "policies") + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Site", + "domain": "test.example.com/admin", + "self_hosted_domains": ["test.example.com/admin", "test.example.com/admin2"], + "type": "self_hosted", + "session_duration": "24h", + "allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "custom_non_identity_deny_url": "https://blocked.com", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "tags": ["engineers"], + "allow_authenticate_via_warp": true, + "options_preflight_bypass": true, + "policies": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "reusable": true, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + ] + } + } + `) + } + + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + SkipInterstitial: BoolPtr(true), + AllowAuthenticateViaWarp: BoolPtr(true), + OptionsPreflightBypass: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Policies: []AccessPolicy{ + { + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Reusable: BoolPtr(true), + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []any{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + }, + }, + } + + params := UpdateAccessApplicationParams{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + AllowAuthenticateViaWarp: BoolPtr(true), + OptionsPreflightBypass: BoolPtr(true), + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.UpdateAccessApplication(context.Background(), AccountIdentifier(testAccountID), params) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err = client.UpdateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), UpdateAccessApplicationParams{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Site", + Domain: "test.example.com/admin", + SelfHostedDomains: []string{"test.example.com/admin", "test.example.com/admin2"}, + Type: "self_hosted", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CustomNonIdentityDenyURL: "https://blocked.com", + OptionsPreflightBypass: BoolPtr(true), + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestUpdateAccessApplicationWithMissingID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessApplication(context.Background(), AccountIdentifier(testAccountID), UpdateAccessApplicationParams{}) + assert.EqualError(t, err, "access application ID cannot be empty") + + _, err = client.UpdateAccessApplication(context.Background(), AccountIdentifier(testAccountID), UpdateAccessApplicationParams{}) + assert.EqualError(t, err, "access application ID cannot be empty") +} + +func TestDeleteAccessApplication(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + err := client.DeleteAccessApplication(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + err = client.DeleteAccessApplication(context.Background(), ZoneIdentifier(testZoneID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) +} + +func TestRevokeAccessApplicationTokens(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [] + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db/revoke-tokens", handler) + err := client.RevokeAccessApplicationTokens(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db/revoke-tokens", handler) + err = client.RevokeAccessApplicationTokens(context.Background(), ZoneIdentifier(testZoneID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) +} + +func TestAccessApplicationWithCORS(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [ + + ], + "result":{ + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Site", + "domain": "test.example.com/admin", + "type": "self_hosted", + "session_duration": "24h", + "cors_headers": { + "allowed_methods": [ + "GET" + ], + "allowed_origins": [ + "https://example.com" + ], + "allow_all_headers": true, + "max_age": -1 + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + Name: "Admin Site", + Domain: "test.example.com/admin", + Type: "self_hosted", + SessionDuration: "24h", + CorsHeaders: &AccessApplicationCorsHeaders{ + AllowedMethods: []string{http.MethodGet}, + AllowedOrigins: []string{"https://example.com"}, + AllowAllHeaders: true, + MaxAge: -1, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.GetAccessApplication(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err = client.GetAccessApplication(context.Background(), ZoneIdentifier(testZoneID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreatePrivateAccessApplication(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Private Admin Site", + "private_address": "198.51.100.0", + "type": "private_ip", + "gateway_rules": [ + {"id": "d9c61460-6f4d-4c40-89ea-2f552c9a8466"}, + {"id": "bc5ee7e7-9773-47a3-835e-b9b9799ebb92"} + ], + "session_duration": "24h", + "allowed_idps": ["f174e90a-fafe-4643-bbbc-4a0ed4fc8415"], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": false, + "service_auth_401_redirect": false + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Private Admin Site", + PrivateAddress: "198.51.100.0", + Type: "private_ip", + GatewayRules: []AccessApplicationGatewayRule{ + {ID: "d9c61460-6f4d-4c40-89ea-2f552c9a8466"}, + {ID: "bc5ee7e7-9773-47a3-835e-b9b9799ebb92"}, + }, + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(false), + ServiceAuth401Redirect: BoolPtr(false), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{ + Name: "Admin Site", + PrivateAddress: "198.51.100.0", + SessionDuration: "24h", + Type: "private_ip", + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestCreateSAMLSaasAccessApplications(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin Saas App", + "domain": "example.cloudflareaccess.com/cdn-cgi/access/sso/saml/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "type": "saas", + "session_duration": "24h", + "allowed_idps": [], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "custom_non_identity_deny_url": "https://blocked.com", + "tags": ["engineers"], + "saas_app": { + "consumer_service_url": "https://saas.example.com", + "sp_entity_id": "dash.example.com", + "name_id_format": "id", + "default_relay_state": "https://saas.example.com", + "custom_attributes": [ + { + "name": "test1", + "name_format": "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", + "source": { + "name": "test1" + } + }, + { + "name": "test2", + "name_format": "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + "source": { + "name": "test2" + } + }, + { + "name": "test3", + "name_format": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "source": { + "name": "test3" + } + } + ], + "name_id_transform_jsonata": "$substringBefore(email, '@') & '+sandbox@' & $substringAfter(email, '@')", + "saml_attribute_transform_jsonata": "$ ~>| groups | {'group_name': name} |" + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin Saas App", + Domain: "example.cloudflareaccess.com/cdn-cgi/access/sso/saml/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + Type: "saas", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + SaasApplication: &SaasApplication{ + ConsumerServiceUrl: "https://saas.example.com", + SPEntityID: "dash.example.com", + NameIDFormat: "id", + DefaultRelayState: "https://saas.example.com", + CustomAttributes: []SAMLAttributeConfig{ + { + Name: "test1", + NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", + Source: SourceConfig{ + Name: "test1", + }, + }, + { + Name: "test2", + NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", + Source: SourceConfig{ + Name: "test2", + }, + }, + { + Name: "test3", + NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + Source: SourceConfig{ + Name: "test3", + }, + }, + }, + NameIDTransformJsonata: "$substringBefore(email, '@') & '+sandbox@' & $substringAfter(email, '@')", + SamlAttributeTransformJsonata: "$ ~>| groups | {'group_name': name} |", + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{ + Name: "Admin Saas Site", + SaasApplication: &SaasApplication{ + ConsumerServiceUrl: "https://examplesaas.com", + SPEntityID: "TEST12345678", + NameIDFormat: "id", + }, + SessionDuration: "24h", + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps", handler) + + actual, err = client.CreateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), CreateAccessApplicationParams{ + Name: "Admin Saas Site", + SaasApplication: &SaasApplication{ + ConsumerServiceUrl: "https://saas.example.com", + SPEntityID: "TEST12345678", + NameIDFormat: "id", + }, + SessionDuration: "24h", + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestCreateOIDCSaasAccessApplications(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin OIDC Saas App", + "domain": "example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "type": "saas", + "session_duration": "24h", + "allowed_idps": [], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "custom_non_identity_deny_url": "https://blocked.com", + "tags": ["engineers"], + "saas_app": { + "auth_type": "oidc", + "client_id": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "client_secret": "secret", + "redirect_uris": ["https://saas.example.com"], + "grant_types": ["authorization_code", "hybrid", "implicit"], + "scopes": ["openid", "email", "profile", "groups"], + "app_launcher_url": "https://saas.example.com", + "group_filter_regex": ".*", + "allow_pkce_without_client_secret": false, + "custom_claims": [ + { + "name": "test1", + "source": { + "name": "test1" + }, + "required": true, + "scope": "profile" + } + ], + "hybrid_and_implicit_options": { + "return_id_token_from_authorization_endpoint": true, + "return_access_token_from_authorization_endpoint": true + }, + "access_token_lifetime": "1m" + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin OIDC Saas App", + Domain: "example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + Type: "saas", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + SaasApplication: &SaasApplication{ + AuthType: "oidc", + ClientID: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + ClientSecret: "secret", + RedirectURIs: []string{"https://saas.example.com"}, + GrantTypes: []string{"authorization_code", "hybrid", "implicit"}, + Scopes: []string{"openid", "email", "profile", "groups"}, + AppLauncherURL: "https://saas.example.com", + GroupFilterRegex: ".*", + AllowPKCEWithoutClientSecret: BoolPtr(false), + CustomClaims: []OIDCClaimConfig{ + { + Name: "test1", + Source: SourceConfig{Name: "test1"}, + Required: BoolPtr(true), + Scope: "profile", + }, + }, + HybridAndImplicitOptions: &AccessApplicationHybridAndImplicitOptions{ + ReturnIDTokenFromAuthorizationEndpoint: BoolPtr(true), + ReturnAccessTokenFromAuthorizationEndpoint: BoolPtr(true), + }, + AccessTokenLifetime: "1m", + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{ + Name: "Admin Saas Site", + SaasApplication: &SaasApplication{ + AuthType: "oidc", + GrantTypes: []string{"authorization_code", "hybrid", "implicit"}, + RedirectURIs: []string{"https://saas.example.com"}, + AppLauncherURL: "https://saas.example.com", + GroupFilterRegex: ".*", + AllowPKCEWithoutClientSecret: BoolPtr(false), + CustomClaims: []OIDCClaimConfig{ + { + Name: "test1", + Source: SourceConfig{Name: "test1"}, + Required: BoolPtr(true), + Scope: "profile", + }, + }, + HybridAndImplicitOptions: &AccessApplicationHybridAndImplicitOptions{ + ReturnIDTokenFromAuthorizationEndpoint: BoolPtr(true), + ReturnAccessTokenFromAuthorizationEndpoint: BoolPtr(true), + }, + AccessTokenLifetime: "1m", + }, + SessionDuration: "24h", + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps", handler) + + actual, err = client.CreateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), CreateAccessApplicationParams{ + Name: "Admin Saas Site", + SaasApplication: &SaasApplication{ + AuthType: "oidc", + GrantTypes: []string{"authorization_code", "hybrid", "implicit"}, + RedirectURIs: []string{"https://saas.example.com"}, + AppLauncherURL: "https://saas.example.com", + GroupFilterRegex: ".*", + AllowPKCEWithoutClientSecret: BoolPtr(false), + CustomClaims: []OIDCClaimConfig{ + { + Name: "test1", + Source: SourceConfig{Name: "test1"}, + Required: BoolPtr(true), + Scope: "profile", + }, + }, + HybridAndImplicitOptions: &AccessApplicationHybridAndImplicitOptions{ + ReturnIDTokenFromAuthorizationEndpoint: BoolPtr(true), + ReturnAccessTokenFromAuthorizationEndpoint: BoolPtr(true), + }, + AccessTokenLifetime: "1m", + }, + SessionDuration: "24h", + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestCreateApplicationWithAccessAppLauncherCustomization(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "App Launcher", + "type": "app_launcher", + "session_duration": "24h", + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": false, + "service_auth_401_redirect": false, + "landing_page_design": { + "title": "A title", + "message": "a message", + "image_url": "https://www.example.com/example.png", + "button_color": "green", + "button_text_color": "red" + }, + "header_bg_color": "red", + "bg_color": "blue", + "footer_links": [ + { + "url": "https://somesite.com", + "name": "bug" + } + ] + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "App Launcher", + Type: "app_launcher", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(false), + ServiceAuth401Redirect: BoolPtr(false), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AccessAppLauncherCustomization: AccessAppLauncherCustomization{ + LandingPageDesign: AccessLandingPageDesign{ + Title: "A title", + Message: "a message", + ImageURL: "https://www.example.com/example.png", + ButtonColor: "green", + ButtonTextColor: "red", + }, + HeaderBackgroundColor: "red", + BackgroundColor: "blue", + FooterLinks: []AccessFooterLink{ + { + URL: "https://somesite.com", + Name: "bug", + }, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{ + Name: "Admin Site", + SessionDuration: "24h", + Type: "app_launcher", + AccessAppLauncherCustomization: AccessAppLauncherCustomization{ + LandingPageDesign: AccessLandingPageDesign{ + Title: "A title", + Message: "a message", + ImageURL: "https://www.example.com/example.png", + ButtonColor: "green", + ButtonTextColor: "red", + }, + HeaderBackgroundColor: "red", + BackgroundColor: "blue", + FooterLinks: []AccessFooterLink{ + { + URL: "https://somesite.com", + Name: "bug", + }, + }, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} + +func TestCreateAccessApplicationWithSCIMProvisioning(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "name": "Admin SCIM App", + "domain": "example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + "type": "saas", + "session_duration": "24h", + "allowed_idps": [], + "auto_redirect_to_identity": false, + "enable_binding_cookie": false, + "custom_deny_url": "https://www.example.com", + "custom_deny_message": "denied!", + "logo_url": "https://www.example.com/example.png", + "skip_interstitial": true, + "app_launcher_visible": true, + "service_auth_401_redirect": true, + "custom_non_identity_deny_url": "https://blocked.com", + "tags": ["engineers"], + "scim_config": { + "enabled": true, + "remote_uri": "https://scim.com", + "authentication": { + "scheme": "oauthbearertoken", + "token": "1234567890" + }, + "idp_uid": "1234567", + "deactivate_on_delete": true, + "mappings": [ + { + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "enabled": true, + "filter": "title pr or userType eq \"Intern\"", + "transform_jsonata": "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])", + "operations": { + "create": true, + "update": true, + "delete": true + } + } + ] + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessApplication := AccessApplication{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Admin SCIM App", + Domain: "example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + Type: "saas", + SessionDuration: "24h", + AUD: "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893", + AllowedIdps: []string{}, + AutoRedirectToIdentity: BoolPtr(false), + EnableBindingCookie: BoolPtr(false), + AppLauncherVisible: BoolPtr(true), + ServiceAuth401Redirect: BoolPtr(true), + CustomDenyMessage: "denied!", + CustomDenyURL: "https://www.example.com", + LogoURL: "https://www.example.com/example.png", + SkipInterstitial: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + CustomNonIdentityDenyURL: "https://blocked.com", + Tags: []string{"engineers"}, + SCIMConfig: &AccessApplicationSCIMConfig{ + Enabled: BoolPtr(true), + RemoteURI: "https://scim.com", + Authentication: &AccessApplicationScimAuthenticationJson{ + Value: &AccessApplicationScimAuthenticationOauthBearerToken{ + Token: "1234567890", + baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationSchemeOauthBearerToken}, + }, + }, + IdPUID: "1234567", + DeactivateOnDelete: BoolPtr(true), + Mappings: []*AccessApplicationScimMapping{ + { + Schema: "urn:ietf:params:scim:schemas:core:2.0:User", + Enabled: BoolPtr(true), + Filter: "title pr or userType eq \"Intern\"", + TransformJsonata: "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])", + Operations: &AccessApplicationScimMappingOperations{ + Create: BoolPtr(true), + Update: BoolPtr(true), + Delete: BoolPtr(true), + }, + }, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler) + + actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{ + Name: "Admin Saas Site", + SCIMConfig: &AccessApplicationSCIMConfig{ + Enabled: BoolPtr(true), + RemoteURI: "https://scim.com", + Authentication: &AccessApplicationScimAuthenticationJson{ + Value: &AccessApplicationScimAuthenticationOauthBearerToken{ + Token: "1234567890", + baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationSchemeOauthBearerToken}, + }, + }, + IdPUID: "1234567", + DeactivateOnDelete: BoolPtr(true), + Mappings: []*AccessApplicationScimMapping{ + { + Schema: "urn:ietf:params:scim:schemas:core:2.0:User", + Enabled: BoolPtr(true), + Filter: "title pr or userType eq \"Intern\"", + TransformJsonata: "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])", + Operations: &AccessApplicationScimMappingOperations{ + Create: BoolPtr(true), + Update: BoolPtr(true), + Delete: BoolPtr(true), + }, + }, + }, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps", handler) + + actual, err = client.CreateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), CreateAccessApplicationParams{ + Name: "Admin SCIM Site", + SCIMConfig: &AccessApplicationSCIMConfig{ + Enabled: BoolPtr(true), + RemoteURI: "https://scim.com", + Authentication: &AccessApplicationScimAuthenticationJson{ + Value: &AccessApplicationScimAuthenticationOauthBearerToken{ + Token: "1234567890", + baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationSchemeOauthBearerToken}, + }, + }, + IdPUID: "1234567", + DeactivateOnDelete: BoolPtr(true), + Mappings: []*AccessApplicationScimMapping{ + { + Schema: "urn:ietf:params:scim:schemas:core:2.0:User", + Enabled: BoolPtr(true), + Filter: "title pr or userType eq \"Intern\"", + TransformJsonata: "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])", + Operations: &AccessApplicationScimMappingOperations{ + Create: BoolPtr(true), + Update: BoolPtr(true), + Delete: BoolPtr(true), + }, + }, + }, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessApplication, actual) + } +} diff --git a/pkg/cloudflare-go/access_audit_log.go b/pkg/cloudflare-go/access_audit_log.go new file mode 100644 index 000000000..62658e3fa --- /dev/null +++ b/pkg/cloudflare-go/access_audit_log.go @@ -0,0 +1,86 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/goccy/go-json" +) + +// AccessAuditLogRecord is the structure of a single Access Audit Log entry. +type AccessAuditLogRecord struct { + UserEmail string `json:"user_email"` + IPAddress string `json:"ip_address"` + AppUID string `json:"app_uid"` + AppDomain string `json:"app_domain"` + Action string `json:"action"` + Connection string `json:"connection"` + Allowed bool `json:"allowed"` + CreatedAt *time.Time `json:"created_at"` + RayID string `json:"ray_id"` +} + +// AccessAuditLogListResponse represents the response from the list +// access applications endpoint. +type AccessAuditLogListResponse struct { + Result []AccessAuditLogRecord `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessAuditLogFilterOptions provides the structure of available audit log +// filters. +type AccessAuditLogFilterOptions struct { + Direction string + Since *time.Time + Until *time.Time + Limit int +} + +// AccessAuditLogs retrieves all audit logs for the Access service. +// +// API reference: https://api.cloudflare.com/#access-requests-access-requests-audit +func (api *API) AccessAuditLogs(ctx context.Context, accountID string, opts AccessAuditLogFilterOptions) ([]AccessAuditLogRecord, error) { + uri := fmt.Sprintf("/accounts/%s/access/logs/access-requests?%s", accountID, opts.Encode()) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessAuditLogRecord{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessAuditLogListResponse AccessAuditLogListResponse + err = json.Unmarshal(res, &accessAuditLogListResponse) + if err != nil { + return []AccessAuditLogRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessAuditLogListResponse.Result, nil +} + +// Encode is a custom method for encoding the filter options into a usable HTTP +// query parameter string. +func (a AccessAuditLogFilterOptions) Encode() string { + v := url.Values{} + + if a.Direction != "" { + v.Set("direction", a.Direction) + } + + if a.Limit > 0 { + v.Set("limit", strconv.Itoa(a.Limit)) + } + + if a.Since != nil { + v.Set("since", a.Since.Format(time.RFC3339)) + } + + if a.Until != nil { + v.Set("until", a.Until.Format(time.RFC3339)) + } + + return v.Encode() +} diff --git a/pkg/cloudflare-go/access_audit_log_example_test.go b/pkg/cloudflare-go/access_audit_log_example_test.go new file mode 100644 index 000000000..1c44de5ab --- /dev/null +++ b/pkg/cloudflare-go/access_audit_log_example_test.go @@ -0,0 +1,26 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + "github.com/goccy/go-json" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_AccessAuditLogs() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + filterOpts := cloudflare.AccessAuditLogFilterOptions{} + results, _ := api.AccessAuditLogs(context.Background(), "someaccountid", filterOpts) + + for _, record := range results { + b, _ := json.Marshal(record) + fmt.Println(string(b)) + } +} diff --git a/pkg/cloudflare-go/access_audit_log_test.go b/pkg/cloudflare-go/access_audit_log_test.go new file mode 100644 index 000000000..b71c3f99b --- /dev/null +++ b/pkg/cloudflare-go/access_audit_log_test.go @@ -0,0 +1,93 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAccessAuditLogs(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "user_email": "michelle@example.com", + "ip_address": "198.51.100.1", + "app_uid": "df7e2w5f-02b7-4d9d-af26-8d1988fca630", + "app_domain": "test.example.com/admin", + "action": "login", + "connection": "saml", + "allowed": false, + "created_at": "2014-01-01T05:20:00.12345Z", + "ray_id": "187d944c61940c77" + } + ] +} + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/access/logs/access-requests", handler) + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []AccessAuditLogRecord{{ + UserEmail: "michelle@example.com", + IPAddress: "198.51.100.1", + AppUID: "df7e2w5f-02b7-4d9d-af26-8d1988fca630", + AppDomain: "test.example.com/admin", + Action: "login", + Connection: "saml", + Allowed: false, + CreatedAt: &createdAt, + RayID: "187d944c61940c77", + }} + + actual, err := client.AccessAuditLogs(context.Background(), "01a7362d577a6c3019a474fd6f485823", AccessAuditLogFilterOptions{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessAuditLogsEncodeAllParametersDefined(t *testing.T) { + since, _ := time.Parse(time.RFC3339, "2020-07-01T00:00:00Z") + until, _ := time.Parse(time.RFC3339, "2020-07-02T00:00:00Z") + + opts := AccessAuditLogFilterOptions{ + Direction: "desc", + Since: &since, + Until: &until, + Limit: 10, + } + + assert.Equal(t, "direction=desc&limit=10&since=2020-07-01T00%3A00%3A00Z&until=2020-07-02T00%3A00%3A00Z", opts.Encode()) +} + +func TestAccessAuditLogsEncodeOnlyDatesDefined(t *testing.T) { + since, _ := time.Parse(time.RFC3339, "2020-07-01T00:00:00Z") + until, _ := time.Parse(time.RFC3339, "2020-07-02T00:00:00Z") + + opts := AccessAuditLogFilterOptions{ + Since: &since, + Until: &until, + } + + assert.Equal(t, "since=2020-07-01T00%3A00%3A00Z&until=2020-07-02T00%3A00%3A00Z", opts.Encode()) +} + +func TestAccessAuditLogsEncodeEmptyValues(t *testing.T) { + opts := AccessAuditLogFilterOptions{} + + assert.Equal(t, "", opts.Encode()) +} diff --git a/pkg/cloudflare-go/access_bookmark.go b/pkg/cloudflare-go/access_bookmark.go new file mode 100644 index 000000000..769c861da --- /dev/null +++ b/pkg/cloudflare-go/access_bookmark.go @@ -0,0 +1,206 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AccessBookmark represents an Access bookmark application. +type AccessBookmark struct { + ID string `json:"id,omitempty"` + Domain string `json:"domain"` + Name string `json:"name"` + LogoURL string `json:"logo_url,omitempty"` + AppLauncherVisible *bool `json:"app_launcher_visible,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// AccessBookmarkListResponse represents the response from the list +// access bookmarks endpoint. +type AccessBookmarkListResponse struct { + Result []AccessBookmark `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessBookmarkDetailResponse is the API response, containing a single +// access bookmark. +type AccessBookmarkDetailResponse struct { + Response + Result AccessBookmark `json:"result"` +} + +// AccessBookmarks returns all bookmarks within an account. +// +// API reference: https://api.cloudflare.com/#access-bookmarks-list-access-bookmarks +func (api *API) AccessBookmarks(ctx context.Context, accountID string, pageOpts PaginationOptions) ([]AccessBookmark, ResultInfo, error) { + return api.accessBookmarks(ctx, accountID, pageOpts, AccountRouteRoot) +} + +// ZoneLevelAccessBookmarks returns all bookmarks within a zone. +// +// API reference: https://api.cloudflare.com/#zone-level-access-bookmarks-list-access-bookmarks +func (api *API) ZoneLevelAccessBookmarks(ctx context.Context, zoneID string, pageOpts PaginationOptions) ([]AccessBookmark, ResultInfo, error) { + return api.accessBookmarks(ctx, zoneID, pageOpts, ZoneRouteRoot) +} + +func (api *API) accessBookmarks(ctx context.Context, id string, pageOpts PaginationOptions, routeRoot RouteRoot) ([]AccessBookmark, ResultInfo, error) { + uri := buildURI(fmt.Sprintf("/%s/%s/access/bookmarks", routeRoot, id), pageOpts) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessBookmark{}, ResultInfo{}, err + } + + var accessBookmarkListResponse AccessBookmarkListResponse + err = json.Unmarshal(res, &accessBookmarkListResponse) + if err != nil { + return []AccessBookmark{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessBookmarkListResponse.Result, accessBookmarkListResponse.ResultInfo, nil +} + +// AccessBookmark returns a single bookmark based on the +// bookmark ID. +// +// API reference: https://api.cloudflare.com/#access-bookmarks-access-bookmarks-details +func (api *API) AccessBookmark(ctx context.Context, accountID, bookmarkID string) (AccessBookmark, error) { + return api.accessBookmark(ctx, accountID, bookmarkID, AccountRouteRoot) +} + +// ZoneLevelAccessBookmark returns a single zone level bookmark based on the +// bookmark ID. +// +// API reference: https://api.cloudflare.com/#zone-level-access-bookmarks-access-bookmarks-details +func (api *API) ZoneLevelAccessBookmark(ctx context.Context, zoneID, bookmarkID string) (AccessBookmark, error) { + return api.accessBookmark(ctx, zoneID, bookmarkID, ZoneRouteRoot) +} + +func (api *API) accessBookmark(ctx context.Context, id, bookmarkID string, routeRoot RouteRoot) (AccessBookmark, error) { + uri := fmt.Sprintf( + "/%s/%s/access/bookmarks/%s", + routeRoot, + id, + bookmarkID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessBookmark{}, err + } + + var accessBookmarkDetailResponse AccessBookmarkDetailResponse + err = json.Unmarshal(res, &accessBookmarkDetailResponse) + if err != nil { + return AccessBookmark{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessBookmarkDetailResponse.Result, nil +} + +// CreateAccessBookmark creates a new access bookmark. +// +// API reference: https://api.cloudflare.com/#access-bookmarks-create-access-bookmark +func (api *API) CreateAccessBookmark(ctx context.Context, accountID string, accessBookmark AccessBookmark) (AccessBookmark, error) { + return api.createAccessBookmark(ctx, accountID, accessBookmark, AccountRouteRoot) +} + +// CreateZoneLevelAccessBookmark creates a new zone level access bookmark. +// +// API reference: https://api.cloudflare.com/#zone-level-access-bookmarks-create-access-bookmark +func (api *API) CreateZoneLevelAccessBookmark(ctx context.Context, zoneID string, accessBookmark AccessBookmark) (AccessBookmark, error) { + return api.createAccessBookmark(ctx, zoneID, accessBookmark, ZoneRouteRoot) +} + +func (api *API) createAccessBookmark(ctx context.Context, id string, accessBookmark AccessBookmark, routeRoot RouteRoot) (AccessBookmark, error) { + uri := fmt.Sprintf("/%s/%s/access/bookmarks", routeRoot, id) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, accessBookmark) + if err != nil { + return AccessBookmark{}, err + } + + var accessBookmarkDetailResponse AccessBookmarkDetailResponse + err = json.Unmarshal(res, &accessBookmarkDetailResponse) + if err != nil { + return AccessBookmark{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessBookmarkDetailResponse.Result, nil +} + +// UpdateAccessBookmark updates an existing access bookmark. +// +// API reference: https://api.cloudflare.com/#access-bookmarks-update-access-bookmark +func (api *API) UpdateAccessBookmark(ctx context.Context, accountID string, accessBookmark AccessBookmark) (AccessBookmark, error) { + return api.updateAccessBookmark(ctx, accountID, accessBookmark, AccountRouteRoot) +} + +// UpdateZoneLevelAccessBookmark updates an existing zone level access bookmark. +// +// API reference: https://api.cloudflare.com/#zone-level-access-bookmarks-update-access-bookmark +func (api *API) UpdateZoneLevelAccessBookmark(ctx context.Context, zoneID string, accessBookmark AccessBookmark) (AccessBookmark, error) { + return api.updateAccessBookmark(ctx, zoneID, accessBookmark, ZoneRouteRoot) +} + +func (api *API) updateAccessBookmark(ctx context.Context, id string, accessBookmark AccessBookmark, routeRoot RouteRoot) (AccessBookmark, error) { + if accessBookmark.ID == "" { + return AccessBookmark{}, fmt.Errorf("access bookmark ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/access/bookmarks/%s", + routeRoot, + id, + accessBookmark.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, accessBookmark) + if err != nil { + return AccessBookmark{}, err + } + + var accessBookmarkDetailResponse AccessBookmarkDetailResponse + err = json.Unmarshal(res, &accessBookmarkDetailResponse) + if err != nil { + return AccessBookmark{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessBookmarkDetailResponse.Result, nil +} + +// DeleteAccessBookmark deletes an access bookmark. +// +// API reference: https://api.cloudflare.com/#access-bookmarks-delete-access-bookmark +func (api *API) DeleteAccessBookmark(ctx context.Context, accountID, bookmarkID string) error { + return api.deleteAccessBookmark(ctx, accountID, bookmarkID, AccountRouteRoot) +} + +// DeleteZoneLevelAccessBookmark deletes a zone level access bookmark. +// +// API reference: https://api.cloudflare.com/#zone-level-access-bookmarks-delete-access-bookmark +func (api *API) DeleteZoneLevelAccessBookmark(ctx context.Context, zoneID, bookmarkID string) error { + return api.deleteAccessBookmark(ctx, zoneID, bookmarkID, ZoneRouteRoot) +} + +func (api *API) deleteAccessBookmark(ctx context.Context, id, bookmarkID string, routeRoot RouteRoot) error { + uri := fmt.Sprintf( + "/%s/%s/access/bookmarks/%s", + routeRoot, + id, + bookmarkID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/access_bookmark_test.go b/pkg/cloudflare-go/access_bookmark_test.go new file mode 100644 index 000000000..7b082b140 --- /dev/null +++ b/pkg/cloudflare-go/access_bookmark_test.go @@ -0,0 +1,283 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAccessBookmarks(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "Example Site", + "domain": "example.com", + "logo_url": "https://www.example.com/example.png", + "app_launcher_visible": true, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []AccessBookmark{{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Example Site", + Domain: "example.com", + LogoURL: "https://www.example.com/example.png", + AppLauncherVisible: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/access/bookmarks", handler) + + actual, _, err := client.AccessBookmarks(context.Background(), testAccountID, PaginationOptions{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/bookmarks", handler) + + actual, _, err = client.ZoneLevelAccessBookmarks(context.Background(), testZoneID, PaginationOptions{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessBookmark(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "Example Site", + "domain": "example.com", + "logo_url": "https://www.example.com/example.png", + "app_launcher_visible": true, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessBookmark{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Example Site", + Domain: "example.com", + LogoURL: "https://www.example.com/example.png", + AppLauncherVisible: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/bookmarks/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.AccessBookmark(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/bookmarks/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err = client.ZoneLevelAccessBookmark(context.Background(), testZoneID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessBookmarks(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "Example Site", + "domain": "example.com", + "logo_url": "https://www.example.com/example.png", + "app_launcher_visible": true, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessBookmark := AccessBookmark{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Example Site", + Domain: "example.com", + LogoURL: "https://www.example.com/example.png", + AppLauncherVisible: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/bookmarks", handler) + + actual, err := client.CreateAccessBookmark(context.Background(), testAccountID, AccessBookmark{ + Name: "Example Site", + Domain: "example.com", + LogoURL: "https://www.example.com/example.png", + AppLauncherVisible: BoolPtr(true), + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessBookmark, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/bookmarks", handler) + + actual, err = client.CreateZoneLevelAccessBookmark(context.Background(), testZoneID, AccessBookmark{ + Name: "Example Site", + Domain: "example.com", + LogoURL: "https://www.example.com/example.png", + AppLauncherVisible: BoolPtr(true), + }) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessBookmark, actual) + } +} + +func TestUpdateAccessBookmark(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "Example Site", + "domain": "example.com", + "logo_url": "https://www.example.com/example.png", + "app_launcher_visible": true, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + fullAccessBookmark := AccessBookmark{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "Example Site", + Domain: "example.com", + LogoURL: "https://www.example.com/example.png", + AppLauncherVisible: BoolPtr(true), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/bookmarks/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.UpdateAccessBookmark(context.Background(), testAccountID, fullAccessBookmark) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessBookmark, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/bookmarks/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err = client.UpdateZoneLevelAccessBookmark(context.Background(), testZoneID, fullAccessBookmark) + + if assert.NoError(t, err) { + assert.Equal(t, fullAccessBookmark, actual) + } +} + +func TestUpdateAccessBookmarkWithMissingID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessBookmark(context.Background(), testZoneID, AccessBookmark{}) + assert.EqualError(t, err, "access bookmark ID cannot be empty") + + _, err = client.UpdateZoneLevelAccessBookmark(context.Background(), testZoneID, AccessBookmark{}) + assert.EqualError(t, err, "access bookmark ID cannot be empty") +} + +func TestDeleteAccessBookmark(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/bookmarks/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + err := client.DeleteAccessBookmark(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/bookmarks/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + err = client.DeleteZoneLevelAccessBookmark(context.Background(), testZoneID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/access_ca_certificate.go b/pkg/cloudflare-go/access_ca_certificate.go new file mode 100644 index 000000000..8f167875e --- /dev/null +++ b/pkg/cloudflare-go/access_ca_certificate.go @@ -0,0 +1,153 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// AccessCACertificate is the structure of the CA certificate used for +// short-lived certificates. +type AccessCACertificate struct { + ID string `json:"id"` + Aud string `json:"aud"` + PublicKey string `json:"public_key"` +} + +// AccessCACertificateListResponse represents the response of all CA +// certificates within Access. +type AccessCACertificateListResponse struct { + Response + Result []AccessCACertificate `json:"result"` + ResultInfo +} + +// AccessCACertificateResponse represents the response of a single CA +// certificate. +type AccessCACertificateResponse struct { + Response + Result AccessCACertificate `json:"result"` +} + +type ListAccessCACertificatesParams struct { + ResultInfo +} + +type CreateAccessCACertificateParams struct { + ApplicationID string +} + +// ListAccessCACertificates returns all AccessCACertificate within Access. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-short-lived-certificate-c-as-list-short-lived-certificate-c-as +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-short-lived-certificate-c-as-list-short-lived-certificate-c-as +func (api *API) ListAccessCACertificates(ctx context.Context, rc *ResourceContainer, params ListAccessCACertificatesParams) ([]AccessCACertificate, *ResultInfo, error) { + baseURL := fmt.Sprintf("/%s/%s/access/apps/ca", rc.Level, rc.Identifier) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var accessCACertificates []AccessCACertificate + var r AccessCACertificateListResponse + + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessCACertificate{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessCACertificate{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + accessCACertificates = append(accessCACertificates, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return accessCACertificates, &r.ResultInfo, nil +} + +// GetAccessCACertificate returns a single CA certificate associated within +// Access. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-short-lived-certificate-c-as-get-a-short-lived-certificate-ca +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-short-lived-certificate-c-as-get-a-short-lived-certificate-ca +func (api *API) GetAccessCACertificate(ctx context.Context, rc *ResourceContainer, applicationID string) (AccessCACertificate, error) { + uri := fmt.Sprintf("/%s/%s/access/apps/%s/ca", rc.Level, rc.Identifier, applicationID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessCACertificate{}, err + } + + var accessCAResponse AccessCACertificateResponse + err = json.Unmarshal(res, &accessCAResponse) + if err != nil { + return AccessCACertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessCAResponse.Result, nil +} + +// CreateAccessCACertificate creates a new CA certificate for an AccessApplication. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-short-lived-certificate-c-as-create-a-short-lived-certificate-ca +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-short-lived-certificate-c-as-create-a-short-lived-certificate-ca +func (api *API) CreateAccessCACertificate(ctx context.Context, rc *ResourceContainer, params CreateAccessCACertificateParams) (AccessCACertificate, error) { + uri := fmt.Sprintf( + "/%s/%s/access/apps/%s/ca", + rc.Level, + rc.Identifier, + params.ApplicationID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return AccessCACertificate{}, err + } + + var accessCACertificate AccessCACertificateResponse + err = json.Unmarshal(res, &accessCACertificate) + if err != nil { + return AccessCACertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessCACertificate.Result, nil +} + +// DeleteAccessCACertificate deletes an Access CA certificate on a defined +// AccessApplication. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-short-lived-certificate-c-as-delete-a-short-lived-certificate-ca +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-short-lived-certificate-c-as-delete-a-short-lived-certificate-ca +func (api *API) DeleteAccessCACertificate(ctx context.Context, rc *ResourceContainer, applicationID string) error { + uri := fmt.Sprintf( + "/%s/%s/access/apps/%s/ca", + rc.Level, + rc.Identifier, + applicationID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/access_ca_certificate_test.go b/pkg/cloudflare-go/access_ca_certificate_test.go new file mode 100644 index 000000000..bb75b90a1 --- /dev/null +++ b/pkg/cloudflare-go/access_ca_certificate_test.go @@ -0,0 +1,170 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccessCACertificate(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "id": "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4", + "aud": "7d1996154eb606c19e31dd777fe6981f57a5ab66735c5c00fefd01b1200ba9d0", + "public_key": "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTI...3urg/XpGMdgaSs5ZdptUPw= open-ssh-ca@cloudflareaccess.org" + }, + "success": true, + "errors": [], + "messages": [] +} + `) + } + + want := AccessCACertificate{ + ID: "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4", + Aud: "7d1996154eb606c19e31dd777fe6981f57a5ab66735c5c00fefd01b1200ba9d0", + PublicKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTI...3urg/XpGMdgaSs5ZdptUPw= open-ssh-ca@cloudflareaccess.org", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+testAccessApplicationID+"/ca", handler) + + actual, err := client.GetAccessCACertificate(context.Background(), testAccountRC, testAccessApplicationID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+testAccessApplicationID+"/ca", handler) + + actual, err = client.GetAccessCACertificate(context.Background(), testZoneRC, testAccessApplicationID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessCACertificates(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [{ + "id": "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4", + "aud": "7d1996154eb606c19e31dd777fe6981f57a5ab66735c5c00fefd01b1200ba9d0", + "public_key": "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTI...3urg/XpGMdgaSs5ZdptUPw= open-ssh-ca@cloudflareaccess.org" + }], + "success": true, + "errors": [], + "messages": [] +} + `) + } + + want := []AccessCACertificate{{ + ID: "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4", + Aud: "7d1996154eb606c19e31dd777fe6981f57a5ab66735c5c00fefd01b1200ba9d0", + PublicKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTI...3urg/XpGMdgaSs5ZdptUPw= open-ssh-ca@cloudflareaccess.org", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/ca", handler) + + actual, _, err := client.ListAccessCACertificates(context.Background(), testAccountRC, ListAccessCACertificatesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/ca", handler) + + actual, _, err = client.ListAccessCACertificates(context.Background(), testZoneRC, ListAccessCACertificatesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessCACertificates(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "id": "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4", + "aud": "7d1996154eb606c19e31dd777fe6981f57a5ab66735c5c00fefd01b1200ba9d0", + "public_key": "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTI...3urg/XpGMdgaSs5ZdptUPw= open-ssh-ca@cloudflareaccess.org" + }, + "success": true, + "errors": [], + "messages": [] +} + `) + } + + want := AccessCACertificate{ + ID: "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4", + Aud: "7d1996154eb606c19e31dd777fe6981f57a5ab66735c5c00fefd01b1200ba9d0", + PublicKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTI...3urg/XpGMdgaSs5ZdptUPw= open-ssh-ca@cloudflareaccess.org", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/ca", handler) + + actual, err := client.CreateAccessCACertificate(context.Background(), testAccountRC, CreateAccessCACertificateParams{ApplicationID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/ca", handler) + + actual, err = client.CreateAccessCACertificate(context.Background(), testZoneRC, CreateAccessCACertificateParams{ApplicationID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteAccessCACertificates(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "id": "4f74df465b2a53271d4219ac2ce2598e24b5e2c60c7924f4" + }, + "success": true, + "errors": [], + "messages": [] +} + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/ca", handler) + + err := client.DeleteAccessCACertificate(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/ca", handler) + + err = client.DeleteAccessCACertificate(context.Background(), testZoneRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/access_custom_page.go b/pkg/cloudflare-go/access_custom_page.go new file mode 100644 index 000000000..cb7e2ccb2 --- /dev/null +++ b/pkg/cloudflare-go/access_custom_page.go @@ -0,0 +1,127 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +var ErrMissingUID = errors.New("required UID missing") + +type AccessCustomPageType string + +const ( + Forbidden AccessCustomPageType = "forbidden" + IdentityDenied AccessCustomPageType = "identity_denied" +) + +type AccessCustomPage struct { + // The HTML content of the custom page. + CustomHTML string `json:"custom_html,omitempty"` + Name string `json:"name,omitempty"` + AppCount int `json:"app_count,omitempty"` + Type AccessCustomPageType `json:"type,omitempty"` + UID string `json:"uid,omitempty"` +} + +type AccessCustomPageListResponse struct { + Response + Result []AccessCustomPage `json:"result"` + ResultInfo `json:"result_info"` +} + +type AccessCustomPageResponse struct { + Response + Result AccessCustomPage `json:"result"` +} + +type ListAccessCustomPagesParams struct{} + +type CreateAccessCustomPageParams struct { + CustomHTML string `json:"custom_html,omitempty"` + Name string `json:"name,omitempty"` + Type AccessCustomPageType `json:"type,omitempty"` +} + +type UpdateAccessCustomPageParams struct { + CustomHTML string `json:"custom_html,omitempty"` + Name string `json:"name,omitempty"` + Type AccessCustomPageType `json:"type,omitempty"` + UID string `json:"uid,omitempty"` +} + +func (api *API) ListAccessCustomPages(ctx context.Context, rc *ResourceContainer, params ListAccessCustomPagesParams) ([]AccessCustomPage, error) { + uri := buildURI(fmt.Sprintf("/%s/%s/access/custom_pages", rc.Level, rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessCustomPage{}, err + } + + var customPagesResponse AccessCustomPageListResponse + err = json.Unmarshal(res, &customPagesResponse) + if err != nil { + return []AccessCustomPage{}, err + } + return customPagesResponse.Result, nil +} + +func (api *API) GetAccessCustomPage(ctx context.Context, rc *ResourceContainer, id string) (AccessCustomPage, error) { + uri := fmt.Sprintf("/%s/%s/access/custom_pages/%s", rc.Level, rc.Identifier, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessCustomPage{}, err + } + + var customPageResponse AccessCustomPageResponse + err = json.Unmarshal(res, &customPageResponse) + if err != nil { + return AccessCustomPage{}, err + } + return customPageResponse.Result, nil +} + +func (api *API) CreateAccessCustomPage(ctx context.Context, rc *ResourceContainer, params CreateAccessCustomPageParams) (AccessCustomPage, error) { + uri := fmt.Sprintf("/%s/%s/access/custom_pages", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessCustomPage{}, err + } + + var customPageResponse AccessCustomPageResponse + err = json.Unmarshal(res, &customPageResponse) + if err != nil { + return AccessCustomPage{}, err + } + return customPageResponse.Result, nil +} + +func (api *API) DeleteAccessCustomPage(ctx context.Context, rc *ResourceContainer, id string) error { + uri := fmt.Sprintf("/%s/%s/access/custom_pages/%s", rc.Level, rc.Identifier, id) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + return nil +} + +func (api *API) UpdateAccessCustomPage(ctx context.Context, rc *ResourceContainer, params UpdateAccessCustomPageParams) (AccessCustomPage, error) { + if params.UID == "" { + return AccessCustomPage{}, ErrMissingUID + } + + uri := fmt.Sprintf("/%s/%s/access/custom_pages/%s", rc.Level, rc.Identifier, params.UID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessCustomPage{}, err + } + + var customPageResponse AccessCustomPageResponse + err = json.Unmarshal(res, &customPageResponse) + if err != nil { + return AccessCustomPage{}, err + } + return customPageResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_custom_page_test.go b/pkg/cloudflare-go/access_custom_page_test.go new file mode 100644 index 000000000..c81f034cd --- /dev/null +++ b/pkg/cloudflare-go/access_custom_page_test.go @@ -0,0 +1,196 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccessCustomPages(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet, "HTTP method") + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "name": "Forbidden", + "app_count": 0, + "type": "forbidden", + "uid": "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + want := []AccessCustomPage{ + { + Name: "Forbidden", + AppCount: 0, + Type: Forbidden, + UID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", + }, + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/custom_pages", handler) + actual, err := client.ListAccessCustomPages(context.Background(), AccountIdentifier(testAccountID), ListAccessCustomPagesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessCustomPage(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "custom_html": "

Forbidden

", + "name": "Forbidden", + "app_count": 0, + "type": "forbidden", + "uid": "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc" + } + }`) + } + + want := AccessCustomPage{ + Name: "Forbidden", + AppCount: 0, + Type: Forbidden, + UID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", + CustomHTML: "

Forbidden

", + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/custom_pages/480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", handler) + actual, err := client.GetAccessCustomPage(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessCustomPage(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "custom_html": "

Forbidden

", + "name": "Forbidden", + "app_count": 0, + "type": "forbidden", + "uid": "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc" + } + }`) + } + + customPage := AccessCustomPage{ + Name: "Forbidden", + AppCount: 0, + Type: Forbidden, + UID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", + CustomHTML: "

Forbidden

", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/custom_pages", handler) + actual, err := client.CreateAccessCustomPage(context.Background(), AccountIdentifier(testAccountID), CreateAccessCustomPageParams{ + Name: "Forbidden", + Type: Forbidden, + CustomHTML: "

Forbidden

", + }) + + if assert.NoError(t, err) { + assert.Equal(t, customPage, actual) + } +} + +func TestUpdateAccessCustomPage(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPut, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "custom_html": "

Forbidden

", + "name": "Forbidden", + "app_count": 0, + "type": "forbidden", + "uid": "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc" + } + }`) + } + + customPage := AccessCustomPage{ + Name: "Forbidden", + AppCount: 0, + Type: Forbidden, + UID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", + CustomHTML: "

Forbidden

", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/custom_pages/480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", handler) + actual, err := client.UpdateAccessCustomPage(context.Background(), AccountIdentifier(testAccountID), UpdateAccessCustomPageParams{ + UID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", + Name: "Forbidden", + Type: Forbidden, + CustomHTML: "

Forbidden

", + }) + + if assert.NoError(t, err) { + assert.Equal(t, customPage, actual) + } +} + +func TestDeleteAccessCustomPage(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodDelete, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + result: { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/custom_pages/480f4f69-1a28-4fdd-9240-1ed29f0ac1dc", handler) + err := client.DeleteAccessCustomPage(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1dc") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/access_group.go b/pkg/cloudflare-go/access_group.go new file mode 100644 index 000000000..a521db8e0 --- /dev/null +++ b/pkg/cloudflare-go/access_group.go @@ -0,0 +1,405 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AccessGroup defines a group for allowing or disallowing access to +// one or more Access applications. +type AccessGroup struct { + ID string `json:"id,omitempty"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Name string `json:"name"` + + // The include group works like an OR logical operator. The user must + // satisfy one of the rules. + Include []interface{} `json:"include"` + + // The exclude group works like a NOT logical operator. The user must + // not satisfy all the rules in exclude. + Exclude []interface{} `json:"exclude"` + + // The require group works like a AND logical operator. The user must + // satisfy all the rules in require. + Require []interface{} `json:"require"` +} + +// AccessGroupEmail is used for managing access based on the email. +// For example, restrict access to users with the email addresses +// `test@example.com` or `someone@example.com`. +type AccessGroupEmail struct { + Email struct { + Email string `json:"email"` + } `json:"email"` +} + +// AccessGroupEmailList is used for managing access based on the email +// list. For example, restrict access to users with the email addresses +// in the email list with the ID `1234567890abcdef1234567890abcdef`. +type AccessGroupEmailList struct { + EmailList struct { + ID string `json:"id"` + } `json:"email_list"` +} + +// AccessGroupEmailDomain is used for managing access based on an email +// domain such as `example.com` instead of individual addresses. +type AccessGroupEmailDomain struct { + EmailDomain struct { + Domain string `json:"domain"` + } `json:"email_domain"` +} + +// AccessGroupIP is used for managing access based in the IP. It +// accepts individual IPs or CIDRs. +type AccessGroupIP struct { + IP struct { + IP string `json:"ip"` + } `json:"ip"` +} + +// AccessGroupGeo is used for managing access based on the country code. +type AccessGroupGeo struct { + Geo struct { + CountryCode string `json:"country_code"` + } `json:"geo"` +} + +// AccessGroupEveryone is used for managing access to everyone. +type AccessGroupEveryone struct { + Everyone struct{} `json:"everyone"` +} + +// AccessGroupServiceToken is used for managing access based on a specific +// service token. +type AccessGroupServiceToken struct { + ServiceToken struct { + ID string `json:"token_id"` + } `json:"service_token"` +} + +// AccessGroupAnyValidServiceToken is used for managing access for all valid +// service tokens (not restricted). +type AccessGroupAnyValidServiceToken struct { + AnyValidServiceToken struct{} `json:"any_valid_service_token"` +} + +// AccessGroupAccessGroup is used for managing access based on an +// access group. +type AccessGroupAccessGroup struct { + Group struct { + ID string `json:"id"` + } `json:"group"` +} + +// AccessGroupCertificate is used for managing access to based on a valid +// mTLS certificate being presented. +type AccessGroupCertificate struct { + Certificate struct{} `json:"certificate"` +} + +// AccessGroupCertificateCommonName is used for managing access based on a +// common name within a certificate. +type AccessGroupCertificateCommonName struct { + CommonName struct { + CommonName string `json:"common_name"` + } `json:"common_name"` +} + +// AccessGroupExternalEvaluation is used for passing user identity to an external url. +type AccessGroupExternalEvaluation struct { + ExternalEvaluation struct { + EvaluateURL string `json:"evaluate_url"` + KeysURL string `json:"keys_url"` + } `json:"external_evaluation"` +} + +// AccessGroupGSuite is used to configure access based on GSuite group. +type AccessGroupGSuite struct { + Gsuite struct { + Email string `json:"email"` + IdentityProviderID string `json:"identity_provider_id"` + } `json:"gsuite"` +} + +// AccessGroupGitHub is used to configure access based on a GitHub organisation. +type AccessGroupGitHub struct { + GitHubOrganization struct { + Name string `json:"name"` + Team string `json:"team,omitempty"` + IdentityProviderID string `json:"identity_provider_id"` + } `json:"github-organization"` +} + +// AccessGroupAzure is used to configure access based on a Azure group. +type AccessGroupAzure struct { + AzureAD struct { + ID string `json:"id"` + IdentityProviderID string `json:"identity_provider_id"` + } `json:"azureAD"` +} + +// AccessGroupOkta is used to configure access based on a Okta group. +type AccessGroupOkta struct { + Okta struct { + Name string `json:"name"` + IdentityProviderID string `json:"identity_provider_id"` + } `json:"okta"` +} + +// AccessGroupSAML is used to allow SAML users with a specific attribute +// configuration. +type AccessGroupSAML struct { + Saml struct { + AttributeName string `json:"attribute_name"` + AttributeValue string `json:"attribute_value"` + IdentityProviderID string `json:"identity_provider_id"` + } `json:"saml"` +} + +// AccessGroupAzureAuthContext is used to configure access based on Azure auth contexts. +type AccessGroupAzureAuthContext struct { + AuthContext struct { + ID string `json:"id"` + IdentityProviderID string `json:"identity_provider_id"` + ACID string `json:"ac_id"` + } `json:"auth_context"` +} + +// AccessGroupAuthMethod is used for managing access by the "amr" +// (Authentication Methods References) identifier. For example, an +// application may want to require that users authenticate using a hardware +// key by setting the "auth_method" to "swk". A list of values are listed +// here: https://tools.ietf.org/html/rfc8176#section-2. Custom values are +// supported as well. +type AccessGroupAuthMethod struct { + AuthMethod struct { + AuthMethod string `json:"auth_method"` + } `json:"auth_method"` +} + +// AccessGroupLoginMethod restricts the application to specific IdP instances. +type AccessGroupLoginMethod struct { + LoginMethod struct { + ID string `json:"id"` + } `json:"login_method"` +} + +// AccessGroupDevicePosture restricts the application to specific devices. +type AccessGroupDevicePosture struct { + DevicePosture struct { + ID string `json:"integration_uid"` + } `json:"device_posture"` +} + +// AccessGroupListResponse represents the response from the list +// access group endpoint. +type AccessGroupListResponse struct { + Result []AccessGroup `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessGroupIPList restricts the application to specific teams_list of ips. +type AccessGroupIPList struct { + IPList struct { + ID string `json:"id"` + } `json:"ip_list"` +} + +// AccessGroupDetailResponse is the API response, containing a single +// access group. +type AccessGroupDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessGroup `json:"result"` +} + +type ListAccessGroupsParams struct { + ResultInfo +} + +type CreateAccessGroupParams struct { + Name string `json:"name"` + + // The include group works like an OR logical operator. The user must + // satisfy one of the rules. + Include []interface{} `json:"include"` + + // The exclude group works like a NOT logical operator. The user must + // not satisfy all the rules in exclude. + Exclude []interface{} `json:"exclude"` + + // The require group works like a AND logical operator. The user must + // satisfy all the rules in require. + Require []interface{} `json:"require"` +} + +type UpdateAccessGroupParams struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + + // The include group works like an OR logical operator. The user must + // satisfy one of the rules. + Include []interface{} `json:"include"` + + // The exclude group works like a NOT logical operator. The user must + // not satisfy all the rules in exclude. + Exclude []interface{} `json:"exclude"` + + // The require group works like a AND logical operator. The user must + // satisfy all the rules in require. + Require []interface{} `json:"require"` +} + +// ListAccessGroups returns all access groups for an access application. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-groups-list-access-groups +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-groups-list-access-groups +func (api *API) ListAccessGroups(ctx context.Context, rc *ResourceContainer, params ListAccessGroupsParams) ([]AccessGroup, *ResultInfo, error) { + baseURL := fmt.Sprintf("/%s/%s/access/groups", rc.Level, rc.Identifier) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var accessGroups []AccessGroup + var r AccessGroupListResponse + + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessGroup{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessGroup{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + accessGroups = append(accessGroups, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return accessGroups, &r.ResultInfo, nil +} + +// GetAccessGroup returns a single group based on the group ID. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-groups-get-an-access-group +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-groups-get-an-access-group +func (api *API) GetAccessGroup(ctx context.Context, rc *ResourceContainer, groupID string) (AccessGroup, error) { + uri := fmt.Sprintf( + "/%s/%s/access/groups/%s", + rc.Level, + rc.Identifier, + groupID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessGroup{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessGroupDetailResponse AccessGroupDetailResponse + err = json.Unmarshal(res, &accessGroupDetailResponse) + if err != nil { + return AccessGroup{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessGroupDetailResponse.Result, nil +} + +// CreateAccessGroup creates a new access group. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-groups-create-an-access-group +// Zone API Reference:https://developers.cloudflare.com/api/operations/zone-level-access-groups-create-an-access-group +func (api *API) CreateAccessGroup(ctx context.Context, rc *ResourceContainer, params CreateAccessGroupParams) (AccessGroup, error) { + uri := fmt.Sprintf( + "/%s/%s/access/groups", + rc.Level, + rc.Identifier, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessGroup{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessGroupDetailResponse AccessGroupDetailResponse + err = json.Unmarshal(res, &accessGroupDetailResponse) + if err != nil { + return AccessGroup{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessGroupDetailResponse.Result, nil +} + +// UpdateAccessGroup updates an existing access group. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-groups-update-an-access-group +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-groups-update-an-access-group +func (api *API) UpdateAccessGroup(ctx context.Context, rc *ResourceContainer, params UpdateAccessGroupParams) (AccessGroup, error) { + if params.ID == "" { + return AccessGroup{}, fmt.Errorf("access group ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/access/groups/%s", + rc.Level, + rc.Identifier, + params.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessGroup{}, err + } + + var accessGroupDetailResponse AccessGroupDetailResponse + err = json.Unmarshal(res, &accessGroupDetailResponse) + if err != nil { + return AccessGroup{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessGroupDetailResponse.Result, nil +} + +// DeleteAccessGroup deletes an access group +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-groups-delete-an-access-group +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-groups-delete-an-access-group +func (api *API) DeleteAccessGroup(ctx context.Context, rc *ResourceContainer, groupID string) error { + uri := fmt.Sprintf( + "/%s/%s/access/groups/%s", + rc.Level, + rc.Identifier, + groupID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/access_group_test.go b/pkg/cloudflare-go/access_group_test.go new file mode 100644 index 000000000..3acb9acae --- /dev/null +++ b/pkg/cloudflare-go/access_group_test.go @@ -0,0 +1,471 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + accessGroupID = "699d98642c564d2e855e9661899b7252" + + expectedAccessGroup = AccessGroup{ + ID: "699d98642c564d2e855e9661899b7252", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Exclude: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Require: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + } + + expectedAccessGroupIpList = AccessGroup{ + ID: "899d98642c564d2e855e9661899b7252", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"ip_list": map[string]interface{}{"id": "989d98642c564d2e855e9661899b7252"}}, + }, + } + + expectedAccessGroupEmailList = AccessGroup{ + ID: "a99d98642c564d2e855e9661899b7252", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"email_list": map[string]interface{}{"id": "8a9d98642c564d2e855e9661899b7252"}}, + }, + } +) + +func TestAccessGroups(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups", handler) + + actual, _, err := client.ListAccessGroups(context.Background(), testAccountRC, ListAccessGroupsParams{ResultInfo{}}) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessGroup{expectedAccessGroup}, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups", handler) + + actual, _, err = client.ListAccessGroups(context.Background(), testZoneRC, ListAccessGroupsParams{ResultInfo{}}) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessGroup{expectedAccessGroup}, actual) + } +} + +func TestAccessGroup(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups/"+accessGroupID, handler) + + actual, err := client.GetAccessGroup(context.Background(), testAccountRC, accessGroupID) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroup, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups/"+accessGroupID, handler) + + actual, err = client.GetAccessGroup(context.Background(), testZoneRC, accessGroupID) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroup, actual) + } +} + +func TestCreateAccessGroup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups", handler) + + params := CreateAccessGroupParams{ + Name: "Allow devs", + Include: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + Exclude: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + Require: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + } + + actual, err := client.CreateAccessGroup(context.Background(), testAccountRC, params) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroup, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups", handler) + + actual, err = client.CreateAccessGroup(context.Background(), testZoneRC, params) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroup, actual) + } +} + +func TestUpdateAccessGroup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ] + } + } + `) + } + + params := UpdateAccessGroupParams{ + ID: "699d98642c564d2e855e9661899b7252", + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Exclude: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Require: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups/"+accessGroupID, handler) + actual, err := client.UpdateAccessGroup(context.Background(), testAccountRC, params) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroup, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups/"+accessGroupID, handler) + actual, err = client.UpdateAccessGroup(context.Background(), testZoneRC, params) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroup, actual) + } +} + +func TestUpdateAccessGroupWithMissingID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessGroup(context.Background(), testAccountRC, UpdateAccessGroupParams{}) + assert.EqualError(t, err, "access group ID cannot be empty") + + _, err = client.UpdateAccessGroup(context.Background(), testZoneRC, UpdateAccessGroupParams{}) + assert.EqualError(t, err, "access group ID cannot be empty") +} + +func TestDeleteAccessGroup(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups/"+accessGroupID, handler) + err := client.DeleteAccessGroup(context.Background(), testAccountRC, accessGroupID) + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups/"+accessGroupID, handler) + err = client.DeleteAccessGroup(context.Background(), testZoneRC, accessGroupID) + + assert.NoError(t, err) +} + +func TestCreateIPListAccessGroup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "899d98642c564d2e855e9661899b7252", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "ip_list": { + "id": "989d98642c564d2e855e9661899b7252" + } + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups", handler) + + accessGroup := CreateAccessGroupParams{ + Name: "Allow devs by iplist", + Include: []interface{}{ + AccessGroupIPList{struct { + ID string `json:"id"` + }{ID: "989d98642c564d2e855e9661899b7252"}}, + }, + } + + actual, err := client.CreateAccessGroup(context.Background(), testAccountRC, accessGroup) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroupIpList, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups", handler) + + actual, err = client.CreateAccessGroup(context.Background(), testZoneRC, accessGroup) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroupIpList, actual) + } +} + +func TestCreateEmailListAccessGroup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "a99d98642c564d2e855e9661899b7252", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email_list": { + "id": "8a9d98642c564d2e855e9661899b7252" + } + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/groups", handler) + + accessGroup := CreateAccessGroupParams{ + Name: "Allow devs by email_list", + Include: []interface{}{ + AccessGroupEmailList{struct { + ID string `json:"id"` + }{ID: "989d98642c564d2e855e9661899b7252"}}, + }, + } + + actual, err := client.CreateAccessGroup(context.Background(), testAccountRC, accessGroup) + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroupEmailList, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/groups", handler) + + actual, err = client.CreateAccessGroup(context.Background(), testZoneRC, accessGroup) + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessGroupEmailList, actual) + } +} diff --git a/pkg/cloudflare-go/access_identity_provider.go b/pkg/cloudflare-go/access_identity_provider.go new file mode 100644 index 000000000..a1ffc8ced --- /dev/null +++ b/pkg/cloudflare-go/access_identity_provider.go @@ -0,0 +1,306 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// AccessIdentityProvider is the structure of the provider object. +type AccessIdentityProvider struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Config AccessIdentityProviderConfiguration `json:"config"` + ScimConfig AccessIdentityProviderScimConfiguration `json:"scim_config"` +} + +// AccessIdentityProviderConfiguration is the combined structure of *all* +// identity provider configuration fields. This is done to simplify the use of +// Access products and their relationship to each other. +// +// API reference: https://developers.cloudflare.com/access/configuring-identity-providers/ +type AccessIdentityProviderConfiguration struct { + APIToken string `json:"api_token,omitempty"` + AppsDomain string `json:"apps_domain,omitempty"` + Attributes []string `json:"attributes,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + CentrifyAccount string `json:"centrify_account,omitempty"` + CentrifyAppID string `json:"centrify_app_id,omitempty"` + CertsURL string `json:"certs_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + Claims []string `json:"claims,omitempty"` + Scopes []string `json:"scopes,omitempty"` + DirectoryID string `json:"directory_id,omitempty"` + EmailAttributeName string `json:"email_attribute_name,omitempty"` + EmailClaimName string `json:"email_claim_name,omitempty"` + IdpPublicCert string `json:"idp_public_cert,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + OktaAccount string `json:"okta_account,omitempty"` + OktaAuthorizationServerID string `json:"authorization_server_id,omitempty"` + OneloginAccount string `json:"onelogin_account,omitempty"` + PingEnvID string `json:"ping_env_id,omitempty"` + RedirectURL string `json:"redirect_url,omitempty"` + SignRequest bool `json:"sign_request,omitempty"` + SsoTargetURL string `json:"sso_target_url,omitempty"` + SupportGroups bool `json:"support_groups,omitempty"` + TokenURL string `json:"token_url,omitempty"` + PKCEEnabled *bool `json:"pkce_enabled,omitempty"` + ConditionalAccessEnabled bool `json:"conditional_access_enabled,omitempty"` +} + +type AccessIdentityProviderScimConfiguration struct { + Enabled bool `json:"enabled,omitempty"` + Secret string `json:"secret,omitempty"` + UserDeprovision bool `json:"user_deprovision,omitempty"` + SeatDeprovision bool `json:"seat_deprovision,omitempty"` + GroupMemberDeprovision bool `json:"group_member_deprovision,omitempty"` +} + +// AccessIdentityProvidersListResponse is the API response for multiple +// Access Identity Providers. +type AccessIdentityProvidersListResponse struct { + Response + Result []AccessIdentityProvider `json:"result"` + ResultInfo `json:"result_info"` +} + +// AccessIdentityProviderResponse is the API response for a single +// Access Identity Provider. +type AccessIdentityProviderResponse struct { + Response + Result AccessIdentityProvider `json:"result"` +} + +type ListAccessIdentityProvidersParams struct { + ResultInfo +} + +type CreateAccessIdentityProviderParams struct { + Name string `json:"name"` + Type string `json:"type"` + Config AccessIdentityProviderConfiguration `json:"config"` + ScimConfig AccessIdentityProviderScimConfiguration `json:"scim_config"` +} + +type UpdateAccessIdentityProviderParams struct { + ID string `json:"-"` + Name string `json:"name"` + Type string `json:"type"` + Config AccessIdentityProviderConfiguration `json:"config"` + ScimConfig AccessIdentityProviderScimConfiguration `json:"scim_config"` +} + +// AccessAuthContext represents an Access Azure Identity Provider Auth Context. +type AccessAuthContext struct { + ID string `json:"id"` + UID string `json:"uid"` + ACID string `json:"ac_id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` +} + +// AccessAuthContextsListResponse represents the response from the list +// Access Auth Contexts endpoint. +type AccessAuthContextsListResponse struct { + Result []AccessAuthContext `json:"result"` + Response +} + +// ListAccessIdentityProviders returns all Access Identity Providers for an +// account or zone. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-list-access-identity-providers +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-list-access-identity-providers +func (api *API) ListAccessIdentityProviders(ctx context.Context, rc *ResourceContainer, params ListAccessIdentityProvidersParams) ([]AccessIdentityProvider, *ResultInfo, error) { + baseURL := fmt.Sprintf("/%s/%s/access/identity_providers", rc.Level, rc.Identifier) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var accessProviders []AccessIdentityProvider + var r AccessIdentityProvidersListResponse + + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessIdentityProvider{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessIdentityProvider{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + accessProviders = append(accessProviders, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return accessProviders, &r.ResultInfo, nil +} + +// GetAccessIdentityProvider returns a single Access Identity +// Provider for an account or zone. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-get-an-access-identity-provider +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-get-an-access-identity-provider +func (api *API) GetAccessIdentityProvider(ctx context.Context, rc *ResourceContainer, identityProviderID string) (AccessIdentityProvider, error) { + uri := fmt.Sprintf( + "/%s/%s/access/identity_providers/%s", + rc.Level, + rc.Identifier, + identityProviderID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessIdentityProviderResponse AccessIdentityProviderResponse + err = json.Unmarshal(res, &accessIdentityProviderResponse) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessIdentityProviderResponse.Result, nil +} + +// CreateAccessIdentityProvider creates a new Access Identity Provider. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-add-an-access-identity-provider +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-add-an-access-identity-provider +func (api *API) CreateAccessIdentityProvider(ctx context.Context, rc *ResourceContainer, params CreateAccessIdentityProviderParams) (AccessIdentityProvider, error) { + uri := fmt.Sprintf("/%s/%s/access/identity_providers", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessIdentityProviderResponse AccessIdentityProviderResponse + err = json.Unmarshal(res, &accessIdentityProviderResponse) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessIdentityProviderResponse.Result, nil +} + +// UpdateAccessIdentityProvider updates an existing Access Identity +// Provider. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-update-an-access-identity-provider +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-update-an-access-identity-provider +func (api *API) UpdateAccessIdentityProvider(ctx context.Context, rc *ResourceContainer, params UpdateAccessIdentityProviderParams) (AccessIdentityProvider, error) { + uri := fmt.Sprintf( + "/%s/%s/access/identity_providers/%s", + rc.Level, + rc.Identifier, + params.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessIdentityProvider{}, err + } + + var accessIdentityProviderResponse AccessIdentityProviderResponse + err = json.Unmarshal(res, &accessIdentityProviderResponse) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessIdentityProviderResponse.Result, nil +} + +// DeleteAccessIdentityProvider deletes an Access Identity Provider. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-delete-an-access-identity-provider +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-delete-an-access-identity-provider +func (api *API) DeleteAccessIdentityProvider(ctx context.Context, rc *ResourceContainer, identityProviderUUID string) (AccessIdentityProvider, error) { + uri := fmt.Sprintf( + "/%s/%s/access/identity_providers/%s", + rc.Level, + rc.Identifier, + identityProviderUUID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessIdentityProviderResponse AccessIdentityProviderResponse + err = json.Unmarshal(res, &accessIdentityProviderResponse) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessIdentityProviderResponse.Result, nil +} + +// ListAccessIdentityProviderAuthContexts returns an identity provider's auth contexts +// AzureAD only +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-get-an-access-identity-provider +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-get-an-access-identity-provider +func (api *API) ListAccessIdentityProviderAuthContexts(ctx context.Context, rc *ResourceContainer, identityProviderID string) ([]AccessAuthContext, error) { + uri := fmt.Sprintf("/%s/%s/access/identity_providers/%s/auth_context", rc.Level, rc.Identifier, identityProviderID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessAuthContext{}, err + } + + var accessAuthContextListResponse AccessAuthContextsListResponse + err = json.Unmarshal(res, &accessAuthContextListResponse) + if err != nil { + return []AccessAuthContext{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessAuthContextListResponse.Result, nil +} + +// UpdateAccessIdentityProviderAuthContexts updates an existing Access Identity +// Provider. +// AzureAD only +// Account API Reference: https://developers.cloudflare.com/api/operations/access-identity-providers-refresh-an-access-identity-provider-auth-contexts +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-identity-providers-update-an-access-identity-provider +func (api *API) UpdateAccessIdentityProviderAuthContexts(ctx context.Context, rc *ResourceContainer, identityProviderID string) (AccessIdentityProvider, error) { + uri := fmt.Sprintf( + "/%s/%s/access/identity_providers/%s/auth_context", + rc.Level, + rc.Identifier, + identityProviderID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, nil) + if err != nil { + return AccessIdentityProvider{}, err + } + + var accessIdentityProviderResponse AccessIdentityProviderResponse + err = json.Unmarshal(res, &accessIdentityProviderResponse) + if err != nil { + return AccessIdentityProvider{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessIdentityProviderResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_identity_provider_test.go b/pkg/cloudflare-go/access_identity_provider_test.go new file mode 100644 index 000000000..e92854151 --- /dev/null +++ b/pkg/cloudflare-go/access_identity_provider_test.go @@ -0,0 +1,416 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListAccessIdentityProviders(t *testing.T) { + setup() + defer teardown() + + 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, "1", r.URL.Query().Get("page")) + assert.Equal(t, "25", r.URL.Query().Get("per_page")) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "Widget Corps OTP", + "type": "github", + "config": { + "client_id": "example_id", + "client_secret": "a-secret-key" + } + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1 + } + } + `) + } + + want := []AccessIdentityProvider{ + { + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers", handler) + + actual, _, err := client.ListAccessIdentityProviders(context.Background(), testAccountRC, ListAccessIdentityProvidersParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers", handler) + + actual, _, err = client.ListAccessIdentityProviders(context.Background(), testZoneRC, ListAccessIdentityProvidersParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessIdentityProviderDetails(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "Widget Corps OTP", + "type": "github", + "config": { + "client_id": "example_id", + "client_secret": "a-secret-key" + } + } + } + `) + } + + want := AccessIdentityProvider{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc841", handler) + + actual, err := client.GetAccessIdentityProvider(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc841") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc841", handler) + + actual, err = client.GetAccessIdentityProvider(context.Background(), testZoneRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc841") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessIdentityProvider(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "Widget Corps OTP", + "type": "github", + "config": { + "client_id": "example_id", + "client_secret": "a-secret-key", + "conditional_access_enabled": true + } + } + } + `) + } + + newIdentityProvider := CreateAccessIdentityProviderParams{ + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + ConditionalAccessEnabled: true, + }, + } + + want := AccessIdentityProvider{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + ConditionalAccessEnabled: true, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers", handler) + + actual, err := client.CreateAccessIdentityProvider(context.Background(), testAccountRC, newIdentityProvider) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers", handler) + + actual, err = client.CreateAccessIdentityProvider(context.Background(), testZoneRC, newIdentityProvider) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} +func TestUpdateAccessIdentityProvider(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "Widget Corps OTP", + "type": "github", + "config": { + "client_id": "example_id", + "client_secret": "a-secret-key" + } + } + } + `) + } + + updatedIdentityProvider := UpdateAccessIdentityProviderParams{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + }, + } + + want := AccessIdentityProvider{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err := client.UpdateAccessIdentityProvider(context.Background(), testAccountRC, updatedIdentityProvider) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err = client.UpdateAccessIdentityProvider(context.Background(), testZoneRC, updatedIdentityProvider) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteAccessIdentityProvider(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "Widget Corps OTP", + "type": "github", + "config": { + "client_id": "example_id", + "client_secret": "a-secret-key" + } + } + } + `) + } + + want := AccessIdentityProvider{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps OTP", + Type: "github", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err := client.DeleteAccessIdentityProvider(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err = client.DeleteAccessIdentityProvider(context.Background(), testZoneRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListAccessIdentityProviderAuthContexts(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "04709095-568a-40c4-bf23-5d9edbefe21e", + "uid": "04709095-568a-40c4-bf23-5d9edbefe21e", + "ac_id": "c1", + "display_name": "test_c1", + "description": "" + }, + { + "id": "a6c9b024-8fd1-48b7-9a05-8bca3a43f758", + "uid": "a6c9b024-8fd1-48b7-9a05-8bca3a43f758", + "ac_id": "c25", + "display_name": "test_c25", + "description": "" + } + ] + } + `) + } + + want := []AccessAuthContext{ + { + ID: "04709095-568a-40c4-bf23-5d9edbefe21e", + UID: "04709095-568a-40c4-bf23-5d9edbefe21e", + ACID: "c1", + DisplayName: "test_c1", + Description: "", + }, + { + ID: "a6c9b024-8fd1-48b7-9a05-8bca3a43f758", + UID: "a6c9b024-8fd1-48b7-9a05-8bca3a43f758", + ACID: "c25", + DisplayName: "test_c25", + Description: "", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/auth_context", handler) + + actual, err := client.ListAccessIdentityProviderAuthContexts(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/auth_context", handler) + + actual, err = client.ListAccessIdentityProviderAuthContexts(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccessIdentityProviderAuthContext(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "Widget Corps", + "type": "AzureAD", + "config": { + "client_id": "example_id", + "client_secret": "a-secret-key", + "conditional_access_enabled": true + } + } + } + `) + } + + want := AccessIdentityProvider{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Widget Corps", + Type: "AzureAD", + Config: AccessIdentityProviderConfiguration{ + ClientID: "example_id", + ClientSecret: "a-secret-key", + ConditionalAccessEnabled: true, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/auth_context", handler) + + actual, err := client.UpdateAccessIdentityProviderAuthContexts(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/identity_providers/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/auth_context", handler) + + actual, err = client.UpdateAccessIdentityProviderAuthContexts(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/access_keys.go b/pkg/cloudflare-go/access_keys.go new file mode 100644 index 000000000..fbc714def --- /dev/null +++ b/pkg/cloudflare-go/access_keys.go @@ -0,0 +1,64 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type AccessKeysConfig struct { + KeyRotationIntervalDays int `json:"key_rotation_interval_days"` + LastKeyRotationAt time.Time `json:"last_key_rotation_at"` + DaysUntilNextRotation int `json:"days_until_next_rotation"` +} + +type AccessKeysConfigUpdateRequest struct { + KeyRotationIntervalDays int `json:"key_rotation_interval_days"` +} + +type accessKeysConfigResponse struct { + Response + Result AccessKeysConfig `json:"result"` +} + +// AccessKeysConfig returns the Access Keys Configuration for an account. +// +// API reference: https://api.cloudflare.com/#access-keys-configuration-get-access-keys-configuration +func (api *API) AccessKeysConfig(ctx context.Context, accountID string) (AccessKeysConfig, error) { + uri := fmt.Sprintf("/%s/%s/access/keys", AccountRouteRoot, accountID) + + return api.accessKeysRequest(ctx, http.MethodGet, uri, nil) +} + +// UpdateAccessKeysConfig updates the Access Keys Configuration for an account. +// +// API reference: https://api.cloudflare.com/#access-keys-configuration-update-access-keys-configuration +func (api *API) UpdateAccessKeysConfig(ctx context.Context, accountID string, request AccessKeysConfigUpdateRequest) (AccessKeysConfig, error) { + uri := fmt.Sprintf("/%s/%s/access/keys", AccountRouteRoot, accountID) + + return api.accessKeysRequest(ctx, http.MethodPut, uri, request) +} + +// RotateAccessKeys rotates the Access Keys for an account and returns the updated Access Keys Configuration +// +// API reference: https://api.cloudflare.com/#access-keys-configuration-rotate-access-keys +func (api *API) RotateAccessKeys(ctx context.Context, accountID string) (AccessKeysConfig, error) { + uri := fmt.Sprintf("/%s/%s/access/keys/rotate", AccountRouteRoot, accountID) + return api.accessKeysRequest(ctx, http.MethodPost, uri, nil) +} + +func (api *API) accessKeysRequest(ctx context.Context, method, uri string, params interface{}) (AccessKeysConfig, error) { + res, err := api.makeRequestContext(ctx, method, uri, params) + if err != nil { + return AccessKeysConfig{}, err + } + + var keysConfigResponse accessKeysConfigResponse + if err := json.Unmarshal(res, &keysConfigResponse); err != nil { + return AccessKeysConfig{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return keysConfigResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_keys_test.go b/pkg/cloudflare-go/access_keys_test.go new file mode 100644 index 000000000..789bca638 --- /dev/null +++ b/pkg/cloudflare-go/access_keys_test.go @@ -0,0 +1,124 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +func TestAccessKeysConfig(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "key_rotation_interval_days": 42, + "last_key_rotation_at": "2014-01-01T05:20:00.12345Z", + "days_until_next_rotation": 3 + } + }`) + } + + wantLastRotationAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := AccessKeysConfig{ + KeyRotationIntervalDays: 42, + LastKeyRotationAt: wantLastRotationAt, + DaysUntilNextRotation: 3, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/keys", handler) + + actual, err := client.AccessKeysConfig(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccessKeysConfig(t *testing.T) { + setup() + defer teardown() + + wantRequest := AccessKeysConfigUpdateRequest{ + KeyRotationIntervalDays: 33, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + var gotRequest AccessKeysConfigUpdateRequest + assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotRequest)) + assert.Equal(t, wantRequest, gotRequest) + + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "key_rotation_interval_days": 42, + "last_key_rotation_at": "2014-01-01T05:20:00.12345Z", + "days_until_next_rotation": 3 + } + }`) + } + + wantLastRotationAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := AccessKeysConfig{ + KeyRotationIntervalDays: 42, + LastKeyRotationAt: wantLastRotationAt, + DaysUntilNextRotation: 3, + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/keys", handler) + + actual, err := client.UpdateAccessKeysConfig(context.Background(), testAccountID, wantRequest) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestRotateAccessKeys(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "key_rotation_interval_days": 42, + "last_key_rotation_at": "2014-01-01T05:20:00.12345Z", + "days_until_next_rotation": 3 + } + }`) + } + + wantLastRotationAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := AccessKeysConfig{ + KeyRotationIntervalDays: 42, + LastKeyRotationAt: wantLastRotationAt, + DaysUntilNextRotation: 3, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/keys/rotate", handler) + + actual, err := client.RotateAccessKeys(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/access_mutual_tls_certificates.go b/pkg/cloudflare-go/access_mutual_tls_certificates.go new file mode 100644 index 000000000..9d4e413b1 --- /dev/null +++ b/pkg/cloudflare-go/access_mutual_tls_certificates.go @@ -0,0 +1,279 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AccessMutualTLSCertificate is the structure of a single Access Mutual TLS +// certificate. +type AccessMutualTLSCertificate struct { + ID string `json:"id,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + ExpiresOn time.Time `json:"expires_on,omitempty"` + Name string `json:"name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Certificate string `json:"certificate,omitempty"` + AssociatedHostnames []string `json:"associated_hostnames,omitempty"` +} + +// AccessMutualTLSCertificateListResponse is the API response for all Access +// Mutual TLS certificates. +type AccessMutualTLSCertificateListResponse struct { + Response + Result []AccessMutualTLSCertificate `json:"result"` + ResultInfo `json:"result_info"` +} + +// AccessMutualTLSCertificateDetailResponse is the API response for a single +// Access Mutual TLS certificate. +type AccessMutualTLSCertificateDetailResponse struct { + Response + Result AccessMutualTLSCertificate `json:"result"` +} + +type ListAccessMutualTLSCertificatesParams struct { + ResultInfo +} + +type CreateAccessMutualTLSCertificateParams struct { + ExpiresOn time.Time `json:"expires_on,omitempty"` + Name string `json:"name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Certificate string `json:"certificate,omitempty"` + AssociatedHostnames []string `json:"associated_hostnames,omitempty"` +} + +type UpdateAccessMutualTLSCertificateParams struct { + ID string `json:"-"` + ExpiresOn time.Time `json:"expires_on,omitempty"` + Name string `json:"name,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Certificate string `json:"certificate,omitempty"` + AssociatedHostnames []string `json:"associated_hostnames,omitempty"` +} + +type AccessMutualTLSHostnameSettings struct { + ChinaNetwork *bool `json:"china_network,omitempty"` + ClientCertificateForwarding *bool `json:"client_certificate_forwarding,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +type GetAccessMutualTLSHostnameSettingsResponse struct { + Response + Result []AccessMutualTLSHostnameSettings `json:"result"` +} + +type UpdateAccessMutualTLSHostnameSettingsParams struct { + Settings []AccessMutualTLSHostnameSettings `json:"settings,omitempty"` +} + +// ListAccessMutualTLSCertificates returns all Access TLS certificates +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-list-mtls-certificates +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-list-mtls-certificates +func (api *API) ListAccessMutualTLSCertificates(ctx context.Context, rc *ResourceContainer, params ListAccessMutualTLSCertificatesParams) ([]AccessMutualTLSCertificate, *ResultInfo, error) { + baseURL := fmt.Sprintf( + "/%s/%s/access/certificates", + rc.Level, + rc.Identifier, + ) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var accessCertificates []AccessMutualTLSCertificate + var r AccessMutualTLSCertificateListResponse + + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessMutualTLSCertificate{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessMutualTLSCertificate{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + accessCertificates = append(accessCertificates, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return accessCertificates, &r.ResultInfo, nil +} + +// GetAccessMutualTLSCertificate returns a single Access Mutual TLS +// certificate. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-get-an-mtls-certificate +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-get-an-mtls-certificate +func (api *API) GetAccessMutualTLSCertificate(ctx context.Context, rc *ResourceContainer, certificateID string) (AccessMutualTLSCertificate, error) { + uri := fmt.Sprintf( + "/%s/%s/access/certificates/%s", + rc.Level, + rc.Identifier, + certificateID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessMutualTLSCertificate{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessMutualTLSCertificateDetailResponse AccessMutualTLSCertificateDetailResponse + err = json.Unmarshal(res, &accessMutualTLSCertificateDetailResponse) + if err != nil { + return AccessMutualTLSCertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessMutualTLSCertificateDetailResponse.Result, nil +} + +// CreateAccessMutualTLSCertificate creates an Access TLS Mutual +// certificate. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-add-an-mtls-certificate +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-add-an-mtls-certificate +func (api *API) CreateAccessMutualTLSCertificate(ctx context.Context, rc *ResourceContainer, params CreateAccessMutualTLSCertificateParams) (AccessMutualTLSCertificate, error) { + uri := fmt.Sprintf( + "/%s/%s/access/certificates", + rc.Level, + rc.Identifier, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessMutualTLSCertificate{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessMutualTLSCertificateDetailResponse AccessMutualTLSCertificateDetailResponse + err = json.Unmarshal(res, &accessMutualTLSCertificateDetailResponse) + if err != nil { + return AccessMutualTLSCertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessMutualTLSCertificateDetailResponse.Result, nil +} + +// UpdateAccessMutualTLSCertificate updates an account level Access TLS Mutual +// certificate. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-update-an-mtls-certificate +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-update-an-mtls-certificate +func (api *API) UpdateAccessMutualTLSCertificate(ctx context.Context, rc *ResourceContainer, params UpdateAccessMutualTLSCertificateParams) (AccessMutualTLSCertificate, error) { + uri := fmt.Sprintf( + "/%s/%s/access/certificates/%s", + rc.Level, + rc.Identifier, + params.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessMutualTLSCertificate{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessMutualTLSCertificateDetailResponse AccessMutualTLSCertificateDetailResponse + err = json.Unmarshal(res, &accessMutualTLSCertificateDetailResponse) + if err != nil { + return AccessMutualTLSCertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessMutualTLSCertificateDetailResponse.Result, nil +} + +// DeleteAccessMutualTLSCertificate destroys an Access Mutual +// TLS certificate. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-delete-an-mtls-certificate +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-delete-an-mtls-certificate +func (api *API) DeleteAccessMutualTLSCertificate(ctx context.Context, rc *ResourceContainer, certificateID string) error { + uri := fmt.Sprintf( + "/%s/%s/access/certificates/%s", + rc.Level, + rc.Identifier, + certificateID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessMutualTLSCertificateDetailResponse AccessMutualTLSCertificateDetailResponse + err = json.Unmarshal(res, &accessMutualTLSCertificateDetailResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// GetAccessMutualTLSHostnameSettings returns all Access mTLS hostname settings. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-update-an-mtls-certificate-settings +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-list-mtls-certificates-hostname-settings +func (api *API) GetAccessMutualTLSHostnameSettings(ctx context.Context, rc *ResourceContainer) ([]AccessMutualTLSHostnameSettings, error) { + uri := fmt.Sprintf( + "/%s/%s/access/certificates/settings", + rc.Level, + rc.Identifier, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessMutualTLSHostnameSettings{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessMutualTLSHostnameSettingsResponse GetAccessMutualTLSHostnameSettingsResponse + err = json.Unmarshal(res, &accessMutualTLSHostnameSettingsResponse) + if err != nil { + return []AccessMutualTLSHostnameSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessMutualTLSHostnameSettingsResponse.Result, nil +} + +// UpdateAccessMutualTLSHostnameSettings updates Access mTLS certificate hostname settings. +// +// Account API Reference: https://developers.cloudflare.com/api/operations/access-mtls-authentication-update-an-mtls-certificate-settings +// Zone API Reference: https://developers.cloudflare.com/api/operations/zone-level-access-mtls-authentication-update-an-mtls-certificate-settings +func (api *API) UpdateAccessMutualTLSHostnameSettings(ctx context.Context, rc *ResourceContainer, params UpdateAccessMutualTLSHostnameSettingsParams) ([]AccessMutualTLSHostnameSettings, error) { + uri := fmt.Sprintf( + "/%s/%s/access/certificates/settings", + rc.Level, + rc.Identifier, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return []AccessMutualTLSHostnameSettings{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessMutualTLSHostnameSettingsResponse GetAccessMutualTLSHostnameSettingsResponse + err = json.Unmarshal(res, &accessMutualTLSHostnameSettingsResponse) + if err != nil { + return []AccessMutualTLSHostnameSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessMutualTLSHostnameSettingsResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_mutual_tls_certificates_test.go b/pkg/cloudflare-go/access_mutual_tls_certificates_test.go new file mode 100644 index 000000000..a83a197da --- /dev/null +++ b/pkg/cloudflare-go/access_mutual_tls_certificates_test.go @@ -0,0 +1,405 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAccessMutualTLSCertificates(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_on": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "fingerprint": "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + "associated_hostnames": [ + "admin.example.com" + ] + } + ] + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []AccessMutualTLSCertificate{{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + ExpiresOn: expiresOn, + Name: "Allow devs", + Fingerprint: "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + AssociatedHostnames: []string{"admin.example.com"}, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates", handler) + + actual, _, err := client.ListAccessMutualTLSCertificates(context.Background(), testAccountRC, ListAccessMutualTLSCertificatesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates", handler) + + actual, _, err = client.ListAccessMutualTLSCertificates(context.Background(), testZoneRC, ListAccessMutualTLSCertificatesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessMutualTLSCertificate(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_on": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "fingerprint": "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + "associated_hostnames": [ + "admin.example.com" + ] + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessMutualTLSCertificate{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + ExpiresOn: expiresOn, + Name: "Allow devs", + Fingerprint: "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + AssociatedHostnames: []string{"admin.example.com"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err := client.GetAccessMutualTLSCertificate(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err = client.GetAccessMutualTLSCertificate(context.Background(), testZoneRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessMutualTLSCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_on": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "fingerprint": "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + "associated_hostnames": [ + "admin.example.com" + ] + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + certificate := CreateAccessMutualTLSCertificateParams{ + Name: "Allow devs", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIGAjCCA+qgAwIBAgIJAI7kymlF7CWT...N4RI7KKB7nikiuUf8vhULKy5IX10\nDrUtmu/B\n-----END CERTIFICATE-----", + } + + want := AccessMutualTLSCertificate{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + ExpiresOn: expiresOn, + Name: "Allow devs", + Fingerprint: "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + AssociatedHostnames: []string{"admin.example.com"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates", handler) + + actual, err := client.CreateAccessMutualTLSCertificate(context.Background(), testAccountRC, certificate) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates", handler) + + actual, err = client.CreateAccessMutualTLSCertificate(context.Background(), testZoneRC, certificate) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccessMutualTLSCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_on": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "fingerprint": "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + "associated_hostnames": [ + "admin.example.com" + ] + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + certificate := UpdateAccessMutualTLSCertificateParams{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "Allow devs", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIGAjCCA+qgAwIBAgIJAI7kymlF7CWT...N4RI7KKB7nikiuUf8vhULKy5IX10\nDrUtmu/B\n-----END CERTIFICATE-----", + } + + want := AccessMutualTLSCertificate{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + ExpiresOn: expiresOn, + Name: "Allow devs", + Fingerprint: "MD5 Fingerprint=1E:80:0F:7A:FD:31:55:96:DE:D5:CB:E2:F0:91:F6:91", + AssociatedHostnames: []string{"admin.example.com"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err := client.UpdateAccessMutualTLSCertificate(context.Background(), testAccountRC, certificate) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err = client.UpdateAccessMutualTLSCertificate(context.Background(), testZoneRC, certificate) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteAccessMutualTLSCertificate(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + err := client.DeleteAccessMutualTLSCertificate(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + err = client.DeleteAccessMutualTLSCertificate(context.Background(), testZoneRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + assert.NoError(t, err) +} + +func TestGetAccessMutualTLSHostnameSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "china_network": false, + "client_certificate_forwarding": true, + "hostname": "admin.example.com" + }, + { + "china_network": true, + "client_certificate_forwarding": false, + "hostname": "foobar.example.com" + } + ] + }`) + } + + want := []AccessMutualTLSHostnameSettings{ + { + ChinaNetwork: BoolPtr(false), + ClientCertificateForwarding: BoolPtr(true), + Hostname: "admin.example.com", + }, + { + ChinaNetwork: BoolPtr(true), + ClientCertificateForwarding: BoolPtr(false), + Hostname: "foobar.example.com", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates/settings", handler) + + actual, err := client.GetAccessMutualTLSHostnameSettings(context.Background(), testAccountRC) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates/settings", handler) + + actual, err = client.GetAccessMutualTLSHostnameSettings(context.Background(), testZoneRC) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccessMutualTLSHostnameSettings(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "china_network": false, + "client_certificate_forwarding": true, + "hostname": "admin.example.com" + }, + { + "china_network": true, + "client_certificate_forwarding": false, + "hostname": "foobar.example.com" + } + ] + }`) + } + + certificateSettings := UpdateAccessMutualTLSHostnameSettingsParams{ + Settings: []AccessMutualTLSHostnameSettings{ + { + ChinaNetwork: BoolPtr(false), + ClientCertificateForwarding: BoolPtr(true), + Hostname: "admin.example.com", + }, + { + ChinaNetwork: BoolPtr(true), + ClientCertificateForwarding: BoolPtr(false), + Hostname: "foobar.example.com", + }, + }, + } + + want := []AccessMutualTLSHostnameSettings{ + { + ChinaNetwork: BoolPtr(false), + ClientCertificateForwarding: BoolPtr(true), + Hostname: "admin.example.com", + }, + { + ChinaNetwork: BoolPtr(true), + ClientCertificateForwarding: BoolPtr(false), + Hostname: "foobar.example.com", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/certificates/settings", handler) + + actual, err := client.UpdateAccessMutualTLSHostnameSettings(context.Background(), testAccountRC, certificateSettings) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/certificates/settings", handler) + + actual, err = client.UpdateAccessMutualTLSHostnameSettings(context.Background(), testZoneRC, certificateSettings) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/access_organization.go b/pkg/cloudflare-go/access_organization.go new file mode 100644 index 000000000..f7eea16af --- /dev/null +++ b/pkg/cloudflare-go/access_organization.go @@ -0,0 +1,143 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AccessOrganization represents an Access organization. +type AccessOrganization struct { + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Name string `json:"name"` + AuthDomain string `json:"auth_domain"` + LoginDesign AccessOrganizationLoginDesign `json:"login_design"` + IsUIReadOnly *bool `json:"is_ui_read_only,omitempty"` + UIReadOnlyToggleReason string `json:"ui_read_only_toggle_reason,omitempty"` + UserSeatExpirationInactiveTime string `json:"user_seat_expiration_inactive_time,omitempty"` + AutoRedirectToIdentity *bool `json:"auto_redirect_to_identity,omitempty"` + SessionDuration *string `json:"session_duration,omitempty"` + CustomPages AccessOrganizationCustomPages `json:"custom_pages,omitempty"` + WarpAuthSessionDuration *string `json:"warp_auth_session_duration,omitempty"` + AllowAuthenticateViaWarp *bool `json:"allow_authenticate_via_warp,omitempty"` +} + +// AccessOrganizationLoginDesign represents the login design options. +type AccessOrganizationLoginDesign struct { + BackgroundColor string `json:"background_color"` + LogoPath string `json:"logo_path"` + TextColor string `json:"text_color"` + HeaderText string `json:"header_text"` + FooterText string `json:"footer_text"` +} + +type AccessOrganizationCustomPages struct { + Forbidden AccessCustomPageType `json:"forbidden,omitempty"` + IdentityDenied AccessCustomPageType `json:"identity_denied,omitempty"` +} + +// AccessOrganizationListResponse represents the response from the list +// access organization endpoint. +type AccessOrganizationListResponse struct { + Result AccessOrganization `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessOrganizationDetailResponse is the API response, containing a +// single access organization. +type AccessOrganizationDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessOrganization `json:"result"` +} + +type GetAccessOrganizationParams struct{} + +type CreateAccessOrganizationParams struct { + Name string `json:"name"` + AuthDomain string `json:"auth_domain"` + LoginDesign AccessOrganizationLoginDesign `json:"login_design"` + IsUIReadOnly *bool `json:"is_ui_read_only,omitempty"` + UIReadOnlyToggleReason string `json:"ui_read_only_toggle_reason,omitempty"` + UserSeatExpirationInactiveTime string `json:"user_seat_expiration_inactive_time,omitempty"` + AutoRedirectToIdentity *bool `json:"auto_redirect_to_identity,omitempty"` + SessionDuration *string `json:"session_duration,omitempty"` + CustomPages AccessOrganizationCustomPages `json:"custom_pages,omitempty"` + WarpAuthSessionDuration *string `json:"warp_auth_session_duration,omitempty"` + AllowAuthenticateViaWarp *bool `json:"allow_authenticate_via_warp,omitempty"` +} + +type UpdateAccessOrganizationParams struct { + Name string `json:"name"` + AuthDomain string `json:"auth_domain"` + LoginDesign AccessOrganizationLoginDesign `json:"login_design"` + IsUIReadOnly *bool `json:"is_ui_read_only,omitempty"` + UIReadOnlyToggleReason string `json:"ui_read_only_toggle_reason,omitempty"` + UserSeatExpirationInactiveTime string `json:"user_seat_expiration_inactive_time,omitempty"` + AutoRedirectToIdentity *bool `json:"auto_redirect_to_identity,omitempty"` + SessionDuration *string `json:"session_duration,omitempty"` + CustomPages AccessOrganizationCustomPages `json:"custom_pages,omitempty"` + WarpAuthSessionDuration *string `json:"warp_auth_session_duration,omitempty"` + AllowAuthenticateViaWarp *bool `json:"allow_authenticate_via_warp,omitempty"` +} + +func (api *API) GetAccessOrganization(ctx context.Context, rc *ResourceContainer, params GetAccessOrganizationParams) (AccessOrganization, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/access/organizations", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessOrganization{}, ResultInfo{}, err + } + + var accessOrganizationListResponse AccessOrganizationListResponse + err = json.Unmarshal(res, &accessOrganizationListResponse) + if err != nil { + return AccessOrganization{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessOrganizationListResponse.Result, accessOrganizationListResponse.ResultInfo, nil +} + +func (api *API) CreateAccessOrganization(ctx context.Context, rc *ResourceContainer, params CreateAccessOrganizationParams) (AccessOrganization, error) { + uri := fmt.Sprintf("/%s/%s/access/organizations", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessOrganization{}, err + } + + var accessOrganizationDetailResponse AccessOrganizationDetailResponse + err = json.Unmarshal(res, &accessOrganizationDetailResponse) + if err != nil { + return AccessOrganization{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessOrganizationDetailResponse.Result, nil +} + +// UpdateAccessOrganization updates the Access organisation details. +// +// Account API reference: https://api.cloudflare.com/#access-organizations-update-access-organization +// Zone API reference: https://api.cloudflare.com/#zone-level-access-organizations-update-access-organization +func (api *API) UpdateAccessOrganization(ctx context.Context, rc *ResourceContainer, params UpdateAccessOrganizationParams) (AccessOrganization, error) { + uri := fmt.Sprintf("/%s/%s/access/organizations", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessOrganization{}, err + } + + var accessOrganizationDetailResponse AccessOrganizationDetailResponse + err = json.Unmarshal(res, &accessOrganizationDetailResponse) + if err != nil { + return AccessOrganization{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessOrganizationDetailResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_organization_test.go b/pkg/cloudflare-go/access_organization_test.go new file mode 100644 index 000000000..fc9cfb4bf --- /dev/null +++ b/pkg/cloudflare-go/access_organization_test.go @@ -0,0 +1,281 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAccessOrganization(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Widget Corps Internal Applications", + "auth_domain": "test.cloudflareaccess.com", + "is_ui_read_only": false, + "user_seat_expiration_inactive_time": "720h", + "auto_redirect_to_identity": true, + "allow_authenticate_via_warp": true, + "warp_auth_session_duration": "24h", + "session_duration": "12h", + "login_design": { + "background_color": "#c5ed1b", + "logo_path": "https://example.com/logo.png", + "text_color": "#c5ed1b", + "header_text": "Widget Corp", + "footer_text": "© Widget Corp" + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessOrganization{ + Name: "Widget Corps Internal Applications", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AuthDomain: "test.cloudflareaccess.com", + AllowAuthenticateViaWarp: BoolPtr(true), + WarpAuthSessionDuration: StringPtr("24h"), + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + IsUIReadOnly: BoolPtr(false), + SessionDuration: StringPtr("12h"), + UserSeatExpirationInactiveTime: "720h", + AutoRedirectToIdentity: BoolPtr(true), + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/organizations", handler) + + actual, _, err := client.GetAccessOrganization(context.Background(), testAccountRC, GetAccessOrganizationParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/organizations", handler) + + actual, _, err = client.GetAccessOrganization(context.Background(), testZoneRC, GetAccessOrganizationParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessOrganization(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Widget Corps Internal Applications", + "auth_domain": "test.cloudflareaccess.com", + "allow_authenticate_via_warp": true, + "warp_auth_session_duration": "24h", + "is_ui_read_only": true, + "session_duration": "12h", + "login_design": { + "background_color": "#c5ed1b", + "logo_path": "https://example.com/logo.png", + "text_color": "#c5ed1b", + "header_text": "Widget Corp", + "footer_text": "© Widget Corp" + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessOrganization{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Widget Corps Internal Applications", + AuthDomain: "test.cloudflareaccess.com", + AllowAuthenticateViaWarp: BoolPtr(true), + WarpAuthSessionDuration: StringPtr("24h"), + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + IsUIReadOnly: BoolPtr(true), + SessionDuration: StringPtr("12h"), + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/organizations", handler) + + actual, err := client.CreateAccessOrganization(context.Background(), testAccountRC, CreateAccessOrganizationParams{ + Name: "Widget Corps Internal Applications", + AuthDomain: "test.cloudflareaccess.com", + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + IsUIReadOnly: BoolPtr(true), + SessionDuration: StringPtr("12h"), + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/organizations", handler) + + actual, err = client.CreateAccessOrganization(context.Background(), testZoneRC, CreateAccessOrganizationParams{ + Name: "Widget Corps Internal Applications", + AuthDomain: "test.cloudflareaccess.com", + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + IsUIReadOnly: BoolPtr(true), + SessionDuration: StringPtr("12h"), + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccessOrganization(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Widget Corps Internal Applications", + "auth_domain": "test.cloudflareaccess.com", + "allow_authenticate_via_warp": false, + "warp_auth_session_duration": "18h", + "login_design": { + "background_color": "#c5ed1b", + "logo_path": "https://example.com/logo.png", + "text_color": "#c5ed1b", + "header_text": "Widget Corp", + "footer_text": "© Widget Corp" + }, + "is_ui_read_only": false, + "ui_read_only_toggle_reason": "this is my reason", + "session_duration": "12h" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AccessOrganization{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Widget Corps Internal Applications", + AuthDomain: "test.cloudflareaccess.com", + WarpAuthSessionDuration: StringPtr("18h"), + AllowAuthenticateViaWarp: BoolPtr(false), + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + IsUIReadOnly: BoolPtr(false), + UIReadOnlyToggleReason: "this is my reason", + SessionDuration: StringPtr("12h"), + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/organizations", handler) + + actual, err := client.UpdateAccessOrganization(context.Background(), testAccountRC, UpdateAccessOrganizationParams{ + Name: "Widget Corps Internal Applications", + AuthDomain: "test.cloudflareaccess.com", + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + WarpAuthSessionDuration: StringPtr("18h"), + AllowAuthenticateViaWarp: BoolPtr(false), + IsUIReadOnly: BoolPtr(false), + SessionDuration: StringPtr("12h"), + UIReadOnlyToggleReason: "this is my reason", + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/organizations", handler) + + actual, err = client.UpdateAccessOrganization(context.Background(), testZoneRC, UpdateAccessOrganizationParams{ + Name: "Widget Corps Internal Applications", + AuthDomain: "test.cloudflareaccess.com", + LoginDesign: AccessOrganizationLoginDesign{ + BackgroundColor: "#c5ed1b", + LogoPath: "https://example.com/logo.png", + TextColor: "#c5ed1b", + HeaderText: "Widget Corp", + FooterText: "© Widget Corp", + }, + WarpAuthSessionDuration: StringPtr("18h"), + AllowAuthenticateViaWarp: BoolPtr(false), + IsUIReadOnly: BoolPtr(false), + UIReadOnlyToggleReason: "this is my reason", + SessionDuration: StringPtr("12h"), + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/access_policy.go b/pkg/cloudflare-go/access_policy.go new file mode 100644 index 000000000..a70ceede5 --- /dev/null +++ b/pkg/cloudflare-go/access_policy.go @@ -0,0 +1,356 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type AccessApprovalGroup struct { + EmailListUuid string `json:"email_list_uuid,omitempty"` + EmailAddresses []string `json:"email_addresses,omitempty"` + ApprovalsNeeded int `json:"approvals_needed,omitempty"` +} + +// AccessPolicy defines a policy for allowing or disallowing access to +// one or more Access applications. +type AccessPolicy struct { + ID string `json:"id,omitempty"` + // Precedence is the order in which the policy is executed in an Access application. + // As a general rule, lower numbers take precedence over higher numbers. + // This field can only be zero when a reusable policy is requested outside the context + // of an Access application. + Precedence int `json:"precedence"` + Decision string `json:"decision"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + Reusable *bool `json:"reusable,omitempty"` + Name string `json:"name"` + + IsolationRequired *bool `json:"isolation_required,omitempty"` + SessionDuration *string `json:"session_duration,omitempty"` + PurposeJustificationRequired *bool `json:"purpose_justification_required,omitempty"` + PurposeJustificationPrompt *string `json:"purpose_justification_prompt,omitempty"` + ApprovalRequired *bool `json:"approval_required,omitempty"` + ApprovalGroups []AccessApprovalGroup `json:"approval_groups"` + + // The include policy works like an OR logical operator. The user must + // satisfy one of the rules. + Include []interface{} `json:"include"` + + // The exclude policy works like a NOT logical operator. The user must + // not satisfy all the rules in exclude. + Exclude []interface{} `json:"exclude"` + + // The require policy works like a AND logical operator. The user must + // satisfy all the rules in require. + Require []interface{} `json:"require"` +} + +// AccessPolicyListResponse represents the response from the list +// access policies endpoint. +type AccessPolicyListResponse struct { + Result []AccessPolicy `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessPolicyDetailResponse is the API response, containing a single +// access policy. +type AccessPolicyDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessPolicy `json:"result"` +} + +type ListAccessPoliciesParams struct { + // ApplicationID is the application ID to list attached access policies for. + // If omitted, only reusable policies for the account are returned. + ApplicationID string `json:"-"` + ResultInfo +} + +type GetAccessPolicyParams struct { + PolicyID string `json:"-"` + // ApplicationID is the application ID for which to scope the policy for. + // Optional, but if included, the policy returned will include its execution precedence within the application. + ApplicationID string `json:"-"` +} + +type CreateAccessPolicyParams struct { + // ApplicationID is the application ID for which to create the policy for. + // Pass an empty value to create a reusable policy. + ApplicationID string `json:"-"` + + // Precedence is the order in which the policy is executed in an Access application. + // As a general rule, lower numbers take precedence over higher numbers. + // This field is ignored when creating a reusable policy. + // Read more here https://developers.cloudflare.com/cloudflare-one/policies/access/#order-of-execution + Precedence int `json:"precedence"` + Decision string `json:"decision"` + Name string `json:"name"` + + IsolationRequired *bool `json:"isolation_required,omitempty"` + SessionDuration *string `json:"session_duration,omitempty"` + PurposeJustificationRequired *bool `json:"purpose_justification_required,omitempty"` + PurposeJustificationPrompt *string `json:"purpose_justification_prompt,omitempty"` + ApprovalRequired *bool `json:"approval_required,omitempty"` + ApprovalGroups []AccessApprovalGroup `json:"approval_groups"` + + // The include policy works like an OR logical operator. The user must + // satisfy one of the rules. + Include []interface{} `json:"include"` + + // The exclude policy works like a NOT logical operator. The user must + // not satisfy all the rules in exclude. + Exclude []interface{} `json:"exclude"` + + // The require policy works like a AND logical operator. The user must + // satisfy all the rules in require. + Require []interface{} `json:"require"` +} + +type UpdateAccessPolicyParams struct { + // ApplicationID is the application ID that owns the existing policy. + // Pass an empty value if the existing policy is reusable. + ApplicationID string `json:"-"` + PolicyID string `json:"-"` + + // Precedence is the order in which the policy is executed in an Access application. + // As a general rule, lower numbers take precedence over higher numbers. + // This field is ignored when updating a reusable policy. + Precedence int `json:"precedence"` + Decision string `json:"decision"` + Name string `json:"name"` + + IsolationRequired *bool `json:"isolation_required,omitempty"` + SessionDuration *string `json:"session_duration,omitempty"` + PurposeJustificationRequired *bool `json:"purpose_justification_required,omitempty"` + PurposeJustificationPrompt *string `json:"purpose_justification_prompt,omitempty"` + ApprovalRequired *bool `json:"approval_required,omitempty"` + ApprovalGroups []AccessApprovalGroup `json:"approval_groups"` + + // The include policy works like an OR logical operator. The user must + // satisfy one of the rules. + Include []interface{} `json:"include"` + + // The exclude policy works like a NOT logical operator. The user must + // not satisfy all the rules in exclude. + Exclude []interface{} `json:"exclude"` + + // The require policy works like a AND logical operator. The user must + // satisfy all the rules in require. + Require []interface{} `json:"require"` +} + +type DeleteAccessPolicyParams struct { + // ApplicationID is the application ID the policy belongs to. + // If the existing policy is reusable, this field must be omitted. Otherwise, it is required. + ApplicationID string `json:"-"` + PolicyID string `json:"-"` +} + +// ListAccessPolicies returns all access policies that match the parameters. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-policies-list-access-policies +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-list-access-policies +func (api *API) ListAccessPolicies(ctx context.Context, rc *ResourceContainer, params ListAccessPoliciesParams) ([]AccessPolicy, *ResultInfo, error) { + var baseURL string + if params.ApplicationID != "" { + baseURL = fmt.Sprintf( + "/%s/%s/access/apps/%s/policies", + rc.Level, + rc.Identifier, + params.ApplicationID, + ) + } else { + baseURL = fmt.Sprintf( + "/%s/%s/access/policies", + rc.Level, + rc.Identifier, + ) + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var accessPolicies []AccessPolicy + var r AccessPolicyListResponse + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessPolicy{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessPolicy{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + accessPolicies = append(accessPolicies, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return accessPolicies, &r.ResultInfo, nil +} + +// GetAccessPolicy returns a single policy based on the policy ID. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-policies-get-an-access-policy +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-get-an-access-policy +func (api *API) GetAccessPolicy(ctx context.Context, rc *ResourceContainer, params GetAccessPolicyParams) (AccessPolicy, error) { + var uri string + if params.ApplicationID != "" { + uri = fmt.Sprintf( + "/%s/%s/access/apps/%s/policies/%s", + rc.Level, + rc.Identifier, + params.ApplicationID, + params.PolicyID, + ) + } else { + uri = fmt.Sprintf( + "/%s/%s/access/policies/%s", + rc.Level, + rc.Identifier, + params.PolicyID, + ) + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessPolicy{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessPolicyDetailResponse AccessPolicyDetailResponse + err = json.Unmarshal(res, &accessPolicyDetailResponse) + if err != nil { + return AccessPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessPolicyDetailResponse.Result, nil +} + +// CreateAccessPolicy creates a new access policy. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-policies-create-an-access-policy +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-create-an-access-policy +func (api *API) CreateAccessPolicy(ctx context.Context, rc *ResourceContainer, params CreateAccessPolicyParams) (AccessPolicy, error) { + var uri string + if params.ApplicationID != "" { + uri = fmt.Sprintf( + "/%s/%s/access/apps/%s/policies", + rc.Level, + rc.Identifier, + params.ApplicationID, + ) + } else { + uri = fmt.Sprintf( + "/%s/%s/access/policies", + rc.Level, + rc.Identifier, + ) + } + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessPolicy{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessPolicyDetailResponse AccessPolicyDetailResponse + err = json.Unmarshal(res, &accessPolicyDetailResponse) + if err != nil { + return AccessPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessPolicyDetailResponse.Result, nil +} + +// UpdateAccessPolicy updates an existing access policy. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-policies-update-an-access-policy +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-update-an-access-policy +func (api *API) UpdateAccessPolicy(ctx context.Context, rc *ResourceContainer, params UpdateAccessPolicyParams) (AccessPolicy, error) { + if params.PolicyID == "" { + return AccessPolicy{}, fmt.Errorf("access policy ID cannot be empty") + } + + var uri string + if params.ApplicationID != "" { + uri = fmt.Sprintf( + "/%s/%s/access/apps/%s/policies/%s", + rc.Level, + rc.Identifier, + params.ApplicationID, + params.PolicyID, + ) + } else { + uri = fmt.Sprintf( + "/%s/%s/access/policies/%s", + rc.Level, + rc.Identifier, + params.PolicyID, + ) + } + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessPolicy{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accessPolicyDetailResponse AccessPolicyDetailResponse + err = json.Unmarshal(res, &accessPolicyDetailResponse) + if err != nil { + return AccessPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessPolicyDetailResponse.Result, nil +} + +// DeleteAccessPolicy deletes an access policy. +// +// Account API reference: https://developers.cloudflare.com/api/operations/access-policies-delete-an-access-policy +// Zone API reference: https://developers.cloudflare.com/api/operations/zone-level-access-policies-delete-an-access-policy +func (api *API) DeleteAccessPolicy(ctx context.Context, rc *ResourceContainer, params DeleteAccessPolicyParams) error { + var uri string + if params.ApplicationID != "" { + uri = fmt.Sprintf( + "/%s/%s/access/apps/%s/policies/%s", + rc.Level, + rc.Identifier, + params.ApplicationID, + params.PolicyID, + ) + } else { + uri = fmt.Sprintf( + "/%s/%s/access/policies/%s", + rc.Level, + rc.Identifier, + params.PolicyID, + ) + } + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/access_policy_test.go b/pkg/cloudflare-go/access_policy_test.go new file mode 100644 index 000000000..756421382 --- /dev/null +++ b/pkg/cloudflare-go/access_policy_test.go @@ -0,0 +1,693 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + pageOptions = PaginationOptions{} + accessApplicationID = "6e1c88f1-6b06-4b8a-a9e9-3ec7da2ee0c1" + accessPolicyID = "699d98642c564d2e855e9661899b7252" + + createdAt, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expiresAt, _ = time.Parse(time.RFC3339, "2015-01-01T05:20:00.12345Z") + + isolationRequired = true + purposeJustificationRequired = true + purposeJustificationPrompt = "Please provide a business reason for your need to access before continuing." + approvalRequired = true + + expectedAccessPolicy = AccessPolicy{ + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Exclude: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Require: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + IsolationRequired: &isolationRequired, + SessionDuration: StringPtr("12h"), + PurposeJustificationRequired: &purposeJustificationRequired, + ApprovalRequired: &approvalRequired, + PurposeJustificationPrompt: &purposeJustificationPrompt, + ApprovalGroups: []AccessApprovalGroup{ + { + EmailListUuid: "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + ApprovalsNeeded: 3, + }, + { + EmailAddresses: []string{"email1@example.com", "email2@example.com"}, + ApprovalsNeeded: 1, + }, + }, + } +) + +func TestAccessPolicies(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "isolation_required": true, + "purpose_justification_required": true, + "purpose_justification_prompt": "Please provide a business reason for your need to access before continuing.", + "approval_required": true, + "session_duration": "12h", + "approval_groups": [ + { + "email_list_uuid": "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + "approvals_needed": 3 + }, + { + "email_addresses": [ + "email1@example.com", + "email2@example.com" + ], + "approvals_needed": 1 + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 20 + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+accessApplicationID+"/policies", handler) + + actual, _, err := client.ListAccessPolicies(context.Background(), testAccountRC, ListAccessPoliciesParams{ApplicationID: accessApplicationID}) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+accessApplicationID+"/policies", handler) + + actual, _, err = client.ListAccessPolicies(context.Background(), testZoneRC, ListAccessPoliciesParams{ApplicationID: accessApplicationID}) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual) + } + + // Test Listing reusable policies + mux.HandleFunc("/accounts/"+testAccountID+"/access/policies", handler) + + actual, _, err = client.ListAccessPolicies(context.Background(), testAccountRC, ListAccessPoliciesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/policies", handler) + + actual, _, err = client.ListAccessPolicies(context.Background(), testZoneRC, ListAccessPoliciesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessPolicy{expectedAccessPolicy}, actual) + } +} + +func TestAccessPolicy(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "isolation_required": true, + "purpose_justification_required": true, + "purpose_justification_prompt": "Please provide a business reason for your need to access before continuing.", + "approval_required": true, + "session_duration": "12h", + "approval_groups": [ + { + "email_list_uuid": "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + "approvals_needed": 3 + }, + { + "email_addresses": ["email1@example.com", "email2@example.com"], + "approvals_needed": 1 + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+accessApplicationID+"/policies/"+accessPolicyID, handler) + + actual, err := client.GetAccessPolicy(context.Background(), testAccountRC, GetAccessPolicyParams{ApplicationID: accessApplicationID, PolicyID: accessPolicyID}) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+accessApplicationID+"/policies/"+accessPolicyID, handler) + + actual, err = client.GetAccessPolicy(context.Background(), testZoneRC, GetAccessPolicyParams{ApplicationID: accessApplicationID, PolicyID: accessPolicyID}) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + // Test getting a reusable policy + mux.HandleFunc("/accounts/"+testAccountID+"/access/policies/"+accessPolicyID, handler) + + actual, err = client.GetAccessPolicy(context.Background(), testAccountRC, GetAccessPolicyParams{PolicyID: accessPolicyID}) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/policies/"+accessPolicyID, handler) + + actual, err = client.GetAccessPolicy(context.Background(), testZoneRC, GetAccessPolicyParams{PolicyID: accessPolicyID}) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } +} + +func TestCreateAccessPolicy(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "isolation_required": true, + "purpose_justification_required": true, + "purpose_justification_prompt": "Please provide a business reason for your need to access before continuing.", + "approval_required": true, + "session_duration": "12h", + "approval_groups": [ + { + "email_list_uuid": "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + "approvals_needed": 3 + }, + { + "email_addresses": ["email1@example.com", "email2@example.com"], + "approvals_needed": 1 + } + ] + } + } + `) + } + + accessPolicy := CreateAccessPolicyParams{ + ApplicationID: accessApplicationID, + Name: "Allow devs", + Include: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + Exclude: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + Require: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + Decision: "allow", + PurposeJustificationRequired: &purposeJustificationRequired, + PurposeJustificationPrompt: &purposeJustificationPrompt, + SessionDuration: StringPtr("12h"), + ApprovalGroups: []AccessApprovalGroup{ + { + EmailListUuid: "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + ApprovalsNeeded: 3, + }, + { + EmailAddresses: []string{"email1@example.com", "email2@example.com"}, + ApprovalsNeeded: 1, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+accessApplicationID+"/policies", handler) + + actual, err := client.CreateAccessPolicy(context.Background(), testAccountRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+accessApplicationID+"/policies", handler) + + actual, err = client.CreateAccessPolicy(context.Background(), testZoneRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + // Test creating a reusable policy + accessPolicy.ApplicationID = "" + mux.HandleFunc("/accounts/"+testAccountID+"/access/policies", handler) + + actual, err = client.CreateAccessPolicy(context.Background(), testAccountRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/policies", handler) + + actual, err = client.CreateAccessPolicy(context.Background(), testZoneRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } +} + +func TestCreateAccessPolicyAuthContextRule(t *testing.T) { + setup() + defer teardown() + + expectedAccessPolicyAuthContext := AccessPolicy{ + ID: "699d98642c564d2e855e9661899b7252", + Precedence: 1, + Decision: "allow", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Exclude: []interface{}{}, + Require: []interface{}{ + map[string]interface{}{"auth_context": map[string]interface{}{"id": "authContextID123", "identity_provider_id": "IDPIDtest123", "ac_id": "c1"}}, + }, + IsolationRequired: &isolationRequired, + PurposeJustificationRequired: &purposeJustificationRequired, + ApprovalRequired: &approvalRequired, + PurposeJustificationPrompt: &purposeJustificationPrompt, + SessionDuration: StringPtr("12h"), + ApprovalGroups: []AccessApprovalGroup{ + { + EmailListUuid: "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + ApprovalsNeeded: 3, + }, + { + EmailAddresses: []string{"email1@example.com", "email2@example.com"}, + ApprovalsNeeded: 1, + }, + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [], + "require": [ + { + "auth_context": { + "id": "authContextID123", + "identity_provider_id": "IDPIDtest123", + "ac_id": "c1" + } + } + ], + "isolation_required": true, + "purpose_justification_required": true, + "purpose_justification_prompt": "Please provide a business reason for your need to access before continuing.", + "approval_required": true, + "session_duration": "12h", + "approval_groups": [ + { + "email_list_uuid": "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + "approvals_needed": 3 + }, + { + "email_addresses": ["email1@example.com", "email2@example.com"], + "approvals_needed": 1 + } + ] + } + } + `) + } + + accessPolicy := CreateAccessPolicyParams{ + ApplicationID: accessApplicationID, + Name: "Allow devs", + Include: []interface{}{ + AccessGroupEmail{struct { + Email string `json:"email"` + }{Email: "test@example.com"}}, + }, + Exclude: []interface{}{}, + Require: []interface{}{ + AccessGroupAzureAuthContext{struct { + ID string `json:"id"` + IdentityProviderID string `json:"identity_provider_id"` + ACID string `json:"ac_id"` + }{ + ID: "authContextID123", + IdentityProviderID: "IDPIDtest123", + ACID: "c1", + }}, + }, + Decision: "allow", + PurposeJustificationRequired: &purposeJustificationRequired, + PurposeJustificationPrompt: &purposeJustificationPrompt, + ApprovalGroups: []AccessApprovalGroup{ + { + EmailListUuid: "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + ApprovalsNeeded: 3, + }, + { + EmailAddresses: []string{"email1@example.com", "email2@example.com"}, + ApprovalsNeeded: 1, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+accessApplicationID+"/policies", handler) + + actual, err := client.CreateAccessPolicy(context.Background(), testAccountRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicyAuthContext, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+accessApplicationID+"/policies", handler) + + actual, err = client.CreateAccessPolicy(context.Background(), testZoneRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicyAuthContext, actual) + } +} + +func TestUpdateAccessPolicy(t *testing.T) { + setup() + defer teardown() + + accessPolicy := UpdateAccessPolicyParams{ + ApplicationID: accessApplicationID, + PolicyID: accessPolicyID, + Precedence: 1, + Decision: "allow", + Name: "Allow devs", + Include: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Exclude: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + Require: []interface{}{ + map[string]interface{}{"email": map[string]interface{}{"email": "test@example.com"}}, + }, + IsolationRequired: &isolationRequired, + PurposeJustificationRequired: &purposeJustificationRequired, + ApprovalRequired: &approvalRequired, + PurposeJustificationPrompt: &purposeJustificationPrompt, + SessionDuration: StringPtr("12h"), + ApprovalGroups: []AccessApprovalGroup{ + { + EmailListUuid: "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + ApprovalsNeeded: 3, + }, + { + EmailAddresses: []string{"email1@example.com", "email2@example.com"}, + ApprovalsNeeded: 1, + }, + }, + } + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "precedence": 1, + "decision": "allow", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "name": "Allow devs", + "session_duration": "12h", + "include": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "exclude": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "require": [ + { + "email": { + "email": "test@example.com" + } + } + ], + "isolation_required": true, + "purpose_justification_required": true, + "purpose_justification_prompt": "Please provide a business reason for your need to access before continuing.", + "approval_required": true, + "approval_groups": [ + { + "email_list_uuid": "2413b6d7-bbe5-48bd-8fbb-e52069c85561", + "approvals_needed": 3 + }, + { + "email_addresses": ["email1@example.com", "email2@example.com"], + "approvals_needed": 1 + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+accessApplicationID+"/policies/"+accessPolicyID, handler) + actual, err := client.UpdateAccessPolicy(context.Background(), testAccountRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+accessApplicationID+"/policies/"+accessPolicyID, handler) + actual, err = client.UpdateAccessPolicy(context.Background(), testZoneRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + // Test updating reusable policies + accessPolicy.ApplicationID = "" + mux.HandleFunc("/accounts/"+testAccountID+"/access/policies/"+accessPolicyID, handler) + actual, err = client.UpdateAccessPolicy(context.Background(), testAccountRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/policies/"+accessPolicyID, handler) + actual, err = client.UpdateAccessPolicy(context.Background(), testZoneRC, accessPolicy) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccessPolicy, actual) + } +} + +func TestUpdateAccessPolicyWithMissingID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessPolicy(context.Background(), testAccountRC, UpdateAccessPolicyParams{ApplicationID: accessApplicationID}) + assert.EqualError(t, err, "access policy ID cannot be empty") + + _, err = client.UpdateAccessPolicy(context.Background(), testZoneRC, UpdateAccessPolicyParams{ApplicationID: accessApplicationID}) + assert.EqualError(t, err, "access policy ID cannot be empty") +} + +func TestDeleteAccessPolicy(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/apps/"+accessApplicationID+"/policies/"+accessPolicyID, handler) + err := client.DeleteAccessPolicy(context.Background(), testAccountRC, DeleteAccessPolicyParams{ApplicationID: accessApplicationID, PolicyID: accessPolicyID}) + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/apps/"+accessApplicationID+"/policies/"+accessPolicyID, handler) + err = client.DeleteAccessPolicy(context.Background(), testZoneRC, DeleteAccessPolicyParams{ApplicationID: accessApplicationID, PolicyID: accessPolicyID}) + + assert.NoError(t, err) + + // Test deleting a reusable policy + mux.HandleFunc("/accounts/"+testAccountID+"/access/policies/"+accessPolicyID, handler) + err = client.DeleteAccessPolicy(context.Background(), testAccountRC, DeleteAccessPolicyParams{PolicyID: accessPolicyID}) + + assert.NoError(t, err) + + mux.HandleFunc("/zones/"+testZoneID+"/access/policies/"+accessPolicyID, handler) + err = client.DeleteAccessPolicy(context.Background(), testZoneRC, DeleteAccessPolicyParams{PolicyID: accessPolicyID}) + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/access_seats.go b/pkg/cloudflare-go/access_seats.go new file mode 100644 index 000000000..ea44eebc7 --- /dev/null +++ b/pkg/cloudflare-go/access_seats.go @@ -0,0 +1,111 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var errMissingAccessSeatUID = errors.New("missing required access seat UID") + +// AccessUpdateAccessUserSeatResult represents a Access User Seat. +type AccessUpdateAccessUserSeatResult struct { + AccessSeat *bool `json:"access_seat"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + GatewaySeat *bool `json:"gateway_seat"` + SeatUID string `json:"seat_uid,omitempty"` +} + +// UpdateAccessUserSeatParams represents the update payload for access seats. +type UpdateAccessUserSeatParams struct { + SeatUID string `json:"seat_uid,omitempty"` + AccessSeat *bool `json:"access_seat"` + GatewaySeat *bool `json:"gateway_seat"` +} + +// UpdateAccessUsersSeatsParams represents the update payload for multiple access seats. +type UpdateAccessUsersSeatsParams []struct { + SeatUID string `json:"seat_uid,omitempty"` + AccessSeat *bool `json:"access_seat"` + GatewaySeat *bool `json:"gateway_seat"` +} + +// AccessUserSeatResponse represents the response from the access user seat endpoints. +type UpdateAccessUserSeatResponse struct { + Response + Result []AccessUpdateAccessUserSeatResult `json:"result"` + ResultInfo `json:"result_info"` +} + +// UpdateAccessUserSeat updates a single Access User Seat. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-seats-update-a-user-seat +func (api *API) UpdateAccessUserSeat(ctx context.Context, rc *ResourceContainer, params UpdateAccessUserSeatParams) ([]AccessUpdateAccessUserSeatResult, error) { + if rc.Level != AccountRouteLevel { + return []AccessUpdateAccessUserSeatResult{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if params.SeatUID == "" { + return []AccessUpdateAccessUserSeatResult{}, errMissingAccessSeatUID + } + + uri := fmt.Sprintf( + "/%s/%s/access/seats", + rc.Level, + rc.Identifier, + ) + + // this requests expects an array of params, but this method only accepts a single param + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, []UpdateAccessUserSeatParams{params}) + if err != nil { + return []AccessUpdateAccessUserSeatResult{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var updateAccessUserSeatResponse UpdateAccessUserSeatResponse + err = json.Unmarshal(res, &updateAccessUserSeatResponse) + if err != nil { + return []AccessUpdateAccessUserSeatResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return updateAccessUserSeatResponse.Result, nil +} + +// UpdateAccessUsersSeats updates many Access User Seats. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-seats-update-a-user-seat +func (api *API) UpdateAccessUsersSeats(ctx context.Context, rc *ResourceContainer, params UpdateAccessUsersSeatsParams) ([]AccessUpdateAccessUserSeatResult, error) { + if rc.Level != AccountRouteLevel { + return []AccessUpdateAccessUserSeatResult{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + for _, param := range params { + if param.SeatUID == "" { + return []AccessUpdateAccessUserSeatResult{}, errMissingAccessSeatUID + } + } + + uri := fmt.Sprintf( + "/%s/%s/access/seats", + rc.Level, + rc.Identifier, + ) + + // this requests expects an array of params, but this method only accepts a single param + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return []AccessUpdateAccessUserSeatResult{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var updateAccessUserSeatResponse UpdateAccessUserSeatResponse + err = json.Unmarshal(res, &updateAccessUserSeatResponse) + if err != nil { + return []AccessUpdateAccessUserSeatResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return updateAccessUserSeatResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_seats_test.go b/pkg/cloudflare-go/access_seats_test.go new file mode 100644 index 000000000..90ef47511 --- /dev/null +++ b/pkg/cloudflare-go/access_seats_test.go @@ -0,0 +1,182 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var testAccessGroupSeatUID = "access-group-seat-uid" +var testAccessGroupSeatUID2 = "access-group-seat-uid2" + +func TestUpdateAccessUserSeat_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessUserSeat(context.Background(), testZoneRC, UpdateAccessUserSeatParams{}) + assert.EqualError(t, err, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel)) +} + +func TestUpdateAccessUserSeat_MissingUID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessUserSeat(context.Background(), testAccountRC, UpdateAccessUserSeatParams{}) + assert.EqualError(t, err, "missing required access seat UID") +} + +func TestUpdateAccessUsersSeats_MissingUID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccessUsersSeats(context.Background(), testAccountRC, UpdateAccessUsersSeatsParams{{GatewaySeat: BoolPtr(false), SeatUID: "seat_id"}, {SeatUID: "", AccessSeat: BoolPtr(true)}}) + assert.EqualError(t, err, "missing required access seat UID") +} + +func TestUpdateAccessUserSeat(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + req := []UpdateAccessUserSeatParams{} + + // Try to decode the request body into the struct. + err := json.NewDecoder(r.Body).Decode(&req) + assert.NoError(t, err, "Failed to decode request body into UpdateAccessUserSeatParams") + assert.Equal(t, len(req), 1, "Expected 1 seat to be updated, got %d", len(req)) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": [ + { + "access_seat": false, + "created_at": "2014-01-01T05:20:00.12345Z", + "gateway_seat": false, + "seat_uid": null, + "updated_at": "2014-01-01T05:20:00.12345Z" + } + ], + "success": true, + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 2000 + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/seats", handler) + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := []AccessUpdateAccessUserSeatResult{ + { + AccessSeat: BoolPtr(false), + CreatedAt: &createdAt, + GatewaySeat: BoolPtr(false), + SeatUID: "", + UpdatedAt: &updatedAt, + }, + } + + actual, err := client.UpdateAccessUserSeat(context.Background(), testAccountRC, UpdateAccessUserSeatParams{ + SeatUID: testAccessGroupSeatUID, + AccessSeat: BoolPtr(false), + GatewaySeat: BoolPtr(false), + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccessUsersSeats(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + req := []UpdateAccessUserSeatParams{} + + // Try to decode the request body into the struct. + err := json.NewDecoder(r.Body).Decode(&req) + assert.NoError(t, err, "Failed to decode request body into UpdateAccessUserSeatParams") + assert.Equal(t, len(req), 2, "Expected 2 seat to be updated, got %d", len(req)) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": [ + { + "access_seat": false, + "created_at": "2014-01-01T05:20:00.12345Z", + "gateway_seat": false, + "seat_uid": "%s", + "updated_at": "2014-01-01T05:20:00.12345Z" + }, + { + "access_seat": false, + "created_at": "2014-01-01T05:20:00.12345Z", + "gateway_seat": false, + "seat_uid": "%s", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + ], + "success": true, + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 2000 + } + } + `, testAccessGroupSeatUID, testAccessGroupSeatUID2) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/seats", handler) + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := []AccessUpdateAccessUserSeatResult{ + { + AccessSeat: BoolPtr(false), + CreatedAt: &createdAt, + GatewaySeat: BoolPtr(false), + SeatUID: testAccessGroupSeatUID, + UpdatedAt: &updatedAt, + }, + { + AccessSeat: BoolPtr(false), + CreatedAt: &createdAt, + GatewaySeat: BoolPtr(false), + SeatUID: testAccessGroupSeatUID2, + UpdatedAt: &updatedAt, + }, + } + + actual, err := client.UpdateAccessUsersSeats(context.Background(), testAccountRC, UpdateAccessUsersSeatsParams{ + { + SeatUID: testAccessGroupSeatUID, + AccessSeat: BoolPtr(false), + GatewaySeat: BoolPtr(false), + }, + { + SeatUID: testAccessGroupSeatUID2, + AccessSeat: BoolPtr(false), + GatewaySeat: BoolPtr(false), + }, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/access_service_tokens.go b/pkg/cloudflare-go/access_service_tokens.go new file mode 100644 index 000000000..5202b2491 --- /dev/null +++ b/pkg/cloudflare-go/access_service_tokens.go @@ -0,0 +1,257 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingServiceTokenUUID = errors.New("missing required service token UUID") +) + +// AccessServiceToken represents an Access Service Token. +type AccessServiceToken struct { + ClientID string `json:"client_id"` + CreatedAt *time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at"` + ID string `json:"id"` + Name string `json:"name"` + UpdatedAt *time.Time `json:"updated_at"` + Duration string `json:"duration,omitempty"` +} + +// AccessServiceTokenUpdateResponse represents the response from the API +// when a new Service Token is updated. This base struct is also used in the +// Create as they are very similar responses. +type AccessServiceTokenUpdateResponse struct { + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + ExpiresAt *time.Time `json:"expires_at"` + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + Duration string `json:"duration,omitempty"` +} + +// AccessServiceTokenRefreshResponse represents the response from the API +// when an existing service token is refreshed to last longer. +type AccessServiceTokenRefreshResponse struct { + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + ExpiresAt *time.Time `json:"expires_at"` + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + Duration string `json:"duration,omitempty"` +} + +// AccessServiceTokenCreateResponse is the same API response as the Update +// operation with the exception that the `ClientSecret` is present in a +// Create operation. +type AccessServiceTokenCreateResponse struct { + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + ExpiresAt *time.Time `json:"expires_at"` + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Duration string `json:"duration,omitempty"` +} + +// AccessServiceTokenRotateResponse is the same API response as the Create +// operation. +type AccessServiceTokenRotateResponse struct { + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` + ExpiresAt *time.Time `json:"expires_at"` + ID string `json:"id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Duration string `json:"duration,omitempty"` +} + +// AccessServiceTokensListResponse represents the response from the list +// Access Service Tokens endpoint. +type AccessServiceTokensListResponse struct { + Result []AccessServiceToken `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessServiceTokensDetailResponse is the API response, containing a single +// Access Service Token. +type AccessServiceTokensDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessServiceToken `json:"result"` +} + +// AccessServiceTokensCreationDetailResponse is the API response, containing a +// single Access Service Token. +type AccessServiceTokensCreationDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessServiceTokenCreateResponse `json:"result"` +} + +// AccessServiceTokensUpdateDetailResponse is the API response, containing a +// single Access Service Token. +type AccessServiceTokensUpdateDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessServiceTokenUpdateResponse `json:"result"` +} + +// AccessServiceTokensRefreshDetailResponse is the API response, containing a +// single Access Service Token. +type AccessServiceTokensRefreshDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessServiceTokenRefreshResponse `json:"result"` +} + +// AccessServiceTokensRotateSecretDetailResponse is the API response, containing a +// single Access Service Token. +type AccessServiceTokensRotateSecretDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccessServiceTokenRotateResponse `json:"result"` +} + +type ListAccessServiceTokensParams struct{} + +type CreateAccessServiceTokenParams struct { + Name string `json:"name"` + Duration string `json:"duration,omitempty"` +} + +type UpdateAccessServiceTokenParams struct { + Name string `json:"name"` + UUID string `json:"-"` + Duration string `json:"duration,omitempty"` +} + +func (api *API) ListAccessServiceTokens(ctx context.Context, rc *ResourceContainer, params ListAccessServiceTokensParams) ([]AccessServiceToken, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/access/service_tokens", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessServiceToken{}, ResultInfo{}, err + } + + var accessServiceTokensListResponse AccessServiceTokensListResponse + err = json.Unmarshal(res, &accessServiceTokensListResponse) + if err != nil { + return []AccessServiceToken{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessServiceTokensListResponse.Result, accessServiceTokensListResponse.ResultInfo, nil +} + +func (api *API) CreateAccessServiceToken(ctx context.Context, rc *ResourceContainer, params CreateAccessServiceTokenParams) (AccessServiceTokenCreateResponse, error) { + uri := fmt.Sprintf("/%s/%s/access/service_tokens", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + + if err != nil { + return AccessServiceTokenCreateResponse{}, err + } + + var accessServiceTokenCreation AccessServiceTokensCreationDetailResponse + err = json.Unmarshal(res, &accessServiceTokenCreation) + if err != nil { + return AccessServiceTokenCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessServiceTokenCreation.Result, nil +} + +func (api *API) UpdateAccessServiceToken(ctx context.Context, rc *ResourceContainer, params UpdateAccessServiceTokenParams) (AccessServiceTokenUpdateResponse, error) { + if params.UUID == "" { + return AccessServiceTokenUpdateResponse{}, ErrMissingServiceTokenUUID + } + + uri := fmt.Sprintf("/%s/%s/access/service_tokens/%s", rc.Level, rc.Identifier, params.UUID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AccessServiceTokenUpdateResponse{}, err + } + + var accessServiceTokenUpdate AccessServiceTokensUpdateDetailResponse + err = json.Unmarshal(res, &accessServiceTokenUpdate) + if err != nil { + return AccessServiceTokenUpdateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessServiceTokenUpdate.Result, nil +} + +func (api *API) DeleteAccessServiceToken(ctx context.Context, rc *ResourceContainer, uuid string) (AccessServiceTokenUpdateResponse, error) { + uri := fmt.Sprintf("/%s/%s/access/service_tokens/%s", rc.Level, rc.Identifier, uuid) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return AccessServiceTokenUpdateResponse{}, err + } + + var accessServiceTokenUpdate AccessServiceTokensUpdateDetailResponse + err = json.Unmarshal(res, &accessServiceTokenUpdate) + if err != nil { + return AccessServiceTokenUpdateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessServiceTokenUpdate.Result, nil +} + +// RefreshAccessServiceToken updates the expiry of an Access Service Token +// in place. +// +// API reference: https://api.cloudflare.com/#access-service-tokens-refresh-a-service-token +func (api *API) RefreshAccessServiceToken(ctx context.Context, rc *ResourceContainer, id string) (AccessServiceTokenRefreshResponse, error) { + uri := fmt.Sprintf("/%s/%s/access/service_tokens/%s/refresh", rc.Level, rc.Identifier, id) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return AccessServiceTokenRefreshResponse{}, err + } + + var accessServiceTokenRefresh AccessServiceTokensRefreshDetailResponse + err = json.Unmarshal(res, &accessServiceTokenRefresh) + if err != nil { + return AccessServiceTokenRefreshResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessServiceTokenRefresh.Result, nil +} + +// RotateAccessServiceToken rotates the client secret of an Access Service +// Token in place. +// API reference: https://api.cloudflare.com/#access-service-tokens-rotate-a-service-token +func (api *API) RotateAccessServiceToken(ctx context.Context, rc *ResourceContainer, id string) (AccessServiceTokenRotateResponse, error) { + uri := fmt.Sprintf("/%s/%s/access/service_tokens/%s/rotate", rc.Level, rc.Identifier, id) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return AccessServiceTokenRotateResponse{}, err + } + + var accessServiceTokenRotate AccessServiceTokensRotateSecretDetailResponse + err = json.Unmarshal(res, &accessServiceTokenRotate) + if err != nil { + return AccessServiceTokenRotateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accessServiceTokenRotate.Result, nil +} diff --git a/pkg/cloudflare-go/access_service_tokens_test.go b/pkg/cloudflare-go/access_service_tokens_test.go new file mode 100644 index 000000000..1f9ed8120 --- /dev/null +++ b/pkg/cloudflare-go/access_service_tokens_test.go @@ -0,0 +1,313 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAccessServiceTokens(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_at": "2015-01-01T05:20:00.12345Z", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "CI/CD token", + "client_id": "88bf3b6d86161464f6509f7219099e57.access.example.com", + "duration": "8760h" + } + ] + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expiresAt, _ := time.Parse(time.RFC3339, "2015-01-01T05:20:00.12345Z") + + want := []AccessServiceToken{ + { + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + ExpiresAt: &expiresAt, + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "CI/CD token", + ClientID: "88bf3b6d86161464f6509f7219099e57.access.example.com", + Duration: "8760h", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/service_tokens", handler) + + actual, _, err := client.ListAccessServiceTokens(context.Background(), testAccountRC, ListAccessServiceTokensParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/service_tokens", handler) + + actual, _, err = client.ListAccessServiceTokens(context.Background(), testZoneRC, ListAccessServiceTokensParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessServiceToken(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_at": "2015-01-01T05:20:00.12345Z", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "CI/CD token", + "client_id": "88bf3b6d86161464f6509f7219099e57.access.example.com", + "client_secret": "bdd31cbc4dec990953e39163fbbb194c93313ca9f0a6e420346af9d326b1d2a5", + "duration": "8760h" + } + } + `) + } + + expected := AccessServiceTokenCreateResponse{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + ExpiresAt: &expiresAt, + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "CI/CD token", + ClientID: "88bf3b6d86161464f6509f7219099e57.access.example.com", + ClientSecret: "bdd31cbc4dec990953e39163fbbb194c93313ca9f0a6e420346af9d326b1d2a5", + Duration: "8760h", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/service_tokens", handler) + + actual, err := client.CreateAccessServiceToken(context.Background(), testAccountRC, CreateAccessServiceTokenParams{Name: "CI/CD token"}) + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/service_tokens", handler) + + actual, err = client.CreateAccessServiceToken(context.Background(), testZoneRC, CreateAccessServiceTokenParams{Name: "CI/CD token"}) + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestUpdateAccessServiceToken(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_at": "2015-01-01T05:20:00.12345Z", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "CI/CD token", + "client_id": "88bf3b6d86161464f6509f7219099e57.access.example.com", + "duration": "8760h" + } + } + `) + } + + expected := AccessServiceTokenUpdateResponse{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + ExpiresAt: &expiresAt, + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "CI/CD token", + ClientID: "88bf3b6d86161464f6509f7219099e57.access.example.com", + Duration: "8760h", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/service_tokens/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err := client.UpdateAccessServiceToken(context.Background(), testAccountRC, UpdateAccessServiceTokenParams{UUID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", Name: "CI/CD token"}) + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/service_tokens/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err = client.UpdateAccessServiceToken(context.Background(), testZoneRC, UpdateAccessServiceTokenParams{UUID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", Name: "CI/CD token"}) + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestRefreshAccessServiceToken(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_at": "2015-01-01T05:20:00.12345Z", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "CI/CD token", + "client_id": "88bf3b6d86161464f6509f7219099e57.access.example.com", + "duration": "8760h" + } + } + `) + } + + expected := AccessServiceTokenRefreshResponse{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + ExpiresAt: &expiresAt, + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "CI/CD token", + ClientID: "88bf3b6d86161464f6509f7219099e57.access.example.com", + Duration: "8760h", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/service_tokens/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/refresh", handler) + + actual, err := client.RefreshAccessServiceToken(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestRotateAccessServiceToken(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_at": "2015-01-01T05:20:00.12345Z", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "CI/CD token", + "client_id": "88bf3b6d86161464f6509f7219099e57.access.example.com", + "client_secret": "bdd31cbc4dec990953e39163fbbb194c93313ca9f0a6e420346af9d326b1d2a5", + "duration": "8760h" + } + } + `) + } + + expected := AccessServiceTokenRotateResponse{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + ExpiresAt: &expiresAt, + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "CI/CD token", + ClientID: "88bf3b6d86161464f6509f7219099e57.access.example.com", + ClientSecret: "bdd31cbc4dec990953e39163fbbb194c93313ca9f0a6e420346af9d326b1d2a5", + Duration: "8760h", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/service_tokens/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/rotate", handler) + + actual, err := client.RotateAccessServiceToken(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestDeleteAccessServiceToken(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "expires_at": "2015-01-01T05:20:00.12345Z", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "CI/CD token", + "client_id": "88bf3b6d86161464f6509f7219099e57.access.example.com", + "duration": "8760h" + } + } + `) + } + + expected := AccessServiceTokenUpdateResponse{ + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + ExpiresAt: &expiresAt, + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "CI/CD token", + ClientID: "88bf3b6d86161464f6509f7219099e57.access.example.com", + Duration: "8760h", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/service_tokens/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err := client.DeleteAccessServiceToken(context.Background(), testAccountRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/service_tokens/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + actual, err = client.DeleteAccessServiceToken(context.Background(), testZoneRC, "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} diff --git a/pkg/cloudflare-go/access_tag.go b/pkg/cloudflare-go/access_tag.go new file mode 100644 index 000000000..9bba613d1 --- /dev/null +++ b/pkg/cloudflare-go/access_tag.go @@ -0,0 +1,85 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type AccessTag struct { + Name string `json:"name,omitempty"` + AppCount int `json:"app_count,omitempty"` +} + +type AccessTagListResponse struct { + Response + Result []AccessTag `json:"result"` + ResultInfo `json:"result_info"` +} + +type AccessTagResponse struct { + Response + Result AccessTag `json:"result"` +} + +type ListAccessTagsParams struct{} + +type CreateAccessTagParams struct { + Name string `json:"name,omitempty"` +} + +func (api *API) ListAccessTags(ctx context.Context, rc *ResourceContainer, params ListAccessTagsParams) ([]AccessTag, error) { + uri := buildURI(fmt.Sprintf("/%s/%s/access/tags", rc.Level, rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessTag{}, err + } + + var TagsResponse AccessTagListResponse + err = json.Unmarshal(res, &TagsResponse) + if err != nil { + return []AccessTag{}, err + } + return TagsResponse.Result, nil +} + +func (api *API) GetAccessTag(ctx context.Context, rc *ResourceContainer, tagName string) (AccessTag, error) { + uri := fmt.Sprintf("/%s/%s/access/tags/%s", rc.Level, rc.Identifier, tagName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccessTag{}, err + } + + var TagResponse AccessTagResponse + err = json.Unmarshal(res, &TagResponse) + if err != nil { + return AccessTag{}, err + } + return TagResponse.Result, nil +} + +func (api *API) CreateAccessTag(ctx context.Context, rc *ResourceContainer, params CreateAccessTagParams) (AccessTag, error) { + uri := fmt.Sprintf("/%s/%s/access/tags", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AccessTag{}, err + } + + var TagResponse AccessTagResponse + err = json.Unmarshal(res, &TagResponse) + if err != nil { + return AccessTag{}, err + } + return TagResponse.Result, nil +} + +func (api *API) DeleteAccessTag(ctx context.Context, rc *ResourceContainer, tagName string) error { + uri := fmt.Sprintf("/%s/%s/access/tags/%s", rc.Level, rc.Identifier, tagName) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cloudflare-go/access_tag_test.go b/pkg/cloudflare-go/access_tag_test.go new file mode 100644 index 000000000..612a9e6c5 --- /dev/null +++ b/pkg/cloudflare-go/access_tag_test.go @@ -0,0 +1,136 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccessTags(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet, "HTTP method") + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "name": "engineers", + "app_count": 0 + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + want := []AccessTag{ + { + Name: "engineers", + AppCount: 0, + }, + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/tags", handler) + actual, err := client.ListAccessTags(context.Background(), AccountIdentifier(testAccountID), ListAccessTagsParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccessTag(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "engineers", + "app_count": 0 + } + }`) + } + + want := AccessTag{ + Name: "engineers", + AppCount: 0, + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/tags/engineers", handler) + actual, err := client.GetAccessTag(context.Background(), AccountIdentifier(testAccountID), "engineers") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateAccessTag(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodPost, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "sales", + "app_count": 0 + } + }`) + } + + Tag := AccessTag{ + Name: "sales", + AppCount: 0, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/tags", handler) + actual, err := client.CreateAccessTag(context.Background(), AccountIdentifier(testAccountID), CreateAccessTagParams{ + Name: "sales", + }) + + if assert.NoError(t, err) { + assert.Equal(t, Tag, actual) + } +} + +func TestDeleteAccessTag(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodDelete, "HTTP method") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + result: { + "id": "engineers" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/tags/engineers", handler) + err := client.DeleteAccessTag(context.Background(), AccountIdentifier(testAccountID), "engineers") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/access_user_tokens.go b/pkg/cloudflare-go/access_user_tokens.go new file mode 100644 index 000000000..ba32d7fbf --- /dev/null +++ b/pkg/cloudflare-go/access_user_tokens.go @@ -0,0 +1,24 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" +) + +type RevokeAccessUserTokensParams struct { + Email string `json:"email"` +} + +// RevokeAccessUserTokens revokes any outstanding tokens issued for a specific user +// Access User. +func (api *API) RevokeAccessUserTokens(ctx context.Context, rc *ResourceContainer, params RevokeAccessUserTokensParams) error { + uri := fmt.Sprintf("/%s/%s/access/organizations/revoke_user", rc.Level, rc.Identifier) + + _, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/access_user_tokens_test.go b/pkg/cloudflare-go/access_user_tokens_test.go new file mode 100644 index 000000000..921c9b85c --- /dev/null +++ b/pkg/cloudflare-go/access_user_tokens_test.go @@ -0,0 +1,52 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRevokeAccessUserTokens(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "result": true + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/organizations/revoke_user", handler) + + err := client.RevokeAccessUserTokens(context.Background(), testAccountRC, RevokeAccessUserTokensParams{Email: "test@example.com"}) + + assert.NoError(t, err) +} + +func TestRevokeZoneLevelAccessUserTokens(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "result": true + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/access/organizations/revoke_user", handler) + + err := client.RevokeAccessUserTokens(context.Background(), testZoneRC, RevokeAccessUserTokensParams{Email: "test@example.com"}) + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/access_users.go b/pkg/cloudflare-go/access_users.go new file mode 100644 index 000000000..233ceb2bb --- /dev/null +++ b/pkg/cloudflare-go/access_users.go @@ -0,0 +1,362 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type AccessUserActiveSessionsResponse struct { + Result []AccessUserActiveSessionResult `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +type AccessUserActiveSessionResult struct { + Expiration int64 `json:"expiration"` + Metadata AccessUserActiveSessionMetadata `json:"metadata"` + Name string `json:"name"` +} + +type AccessUserActiveSessionMetadata struct { + Apps map[string]AccessUserActiveSessionMetadataApp `json:"apps"` + Expires int64 `json:"expires"` + IAT int64 `json:"iat"` + Nonce string `json:"nonce"` + TTL int64 `json:"ttl"` +} + +type AccessUserActiveSessionMetadataApp struct { + Hostname string `json:"hostname"` + Name string `json:"name"` + Type string `json:"type"` + UID string `json:"uid"` +} + +type AccessUserDevicePosture struct { + Check AccessUserDevicePostureCheck `json:"check"` + Data map[string]interface{} `json:"data"` + Description string `json:"description"` + Error string `json:"error"` + ID string `json:"id"` + RuleName string `json:"rule_name"` + Success *bool `json:"success"` + Timestamp string `json:"timestamp"` + Type string `json:"type"` +} + +type AccessUserDeviceSession struct { + LastAuthenticated int64 `json:"last_authenticated"` +} + +type AccessUserFailedLoginsResponse struct { + Result []AccessUserFailedLoginResult `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +type AccessUserFailedLoginResult struct { + Expiration int64 `json:"expiration"` + Metadata AccessUserFailedLoginMetadata `json:"metadata"` +} + +type AccessUserFailedLoginMetadata struct { + AppName string `json:"app_name"` + Aud string `json:"aud"` + Datetime string `json:"datetime"` + RayID string `json:"ray_id"` + UserEmail string `json:"user_email"` + UserUUID string `json:"user_uuid"` +} + +type AccessUserLastSeenIdentityResponse struct { + Result AccessUserLastSeenIdentityResult `json:"result"` + ResultInfo ResultInfo `json:"result_info"` + Response +} + +type AccessUserLastSeenIdentityResult struct { + AccountID string `json:"account_id"` + AuthStatus string `json:"auth_status"` + CommonName string `json:"common_name"` + DeviceID string `json:"device_id"` + DevicePosture map[string]AccessUserDevicePosture `json:"devicePosture"` + DeviceSessions map[string]AccessUserDeviceSession `json:"device_sessions"` + Email string `json:"email"` + Geo AccessUserIdentityGeo `json:"geo"` + IAT int64 `json:"iat"` + IDP AccessUserIDP `json:"idp"` + IP string `json:"ip"` + IsGateway *bool `json:"is_gateway"` + IsWarp *bool `json:"is_warp"` + MtlsAuth AccessUserMTLSAuth `json:"mtls_auth"` + ServiceTokenID string `json:"service_token_id"` + ServiceTokenStatus *bool `json:"service_token_status"` + UserUUID string `json:"user_uuid"` + Version int `json:"version"` +} + +type AccessUserLastSeenIdentitySessionResponse struct { + Result GetAccessUserLastSeenIdentityResult `json:"result"` + ResultInfo ResultInfo `json:"result_info"` + Response +} + +type GetAccessUserLastSeenIdentityResult struct { + AccountID string `json:"account_id"` + AuthStatus string `json:"auth_status"` + CommonName string `json:"common_name"` + DevicePosture map[string]AccessUserDevicePosture `json:"devicePosture"` + DeviceSessions map[string]AccessUserDeviceSession `json:"device_sessions"` + DeviceID string `json:"device_id"` + Email string `json:"email"` + Geo AccessUserIdentityGeo `json:"geo"` + IAT int64 `json:"iat"` + IDP AccessUserIDP `json:"idp"` + IP string `json:"ip"` + IsGateway *bool `json:"is_gateway"` + IsWarp *bool `json:"is_warp"` + MtlsAuth AccessUserMTLSAuth `json:"mtls_auth"` + ServiceTokenID string `json:"service_token_id"` + ServiceTokenStatus *bool `json:"service_token_status"` + UserUUID string `json:"user_uuid"` + Version int `json:"version"` +} + +type AccessUserDevicePostureCheck struct { + Exists *bool `json:"exists"` + Path string `json:"path"` +} + +type AccessUserIdentityGeo struct { + Country string `json:"country"` +} + +type AccessUserIDP struct { + ID string `json:"id"` + Type string `json:"type"` +} + +type AccessUserMTLSAuth struct { + AuthStatus string `json:"auth_status"` + CertIssuerDN string `json:"cert_issuer_dn"` + CertIssuerSKI string `json:"cert_issuer_ski"` + CertPresented *bool `json:"cert_presented"` + CertSerial string `json:"cert_serial"` +} + +type AccessUserListResponse struct { + Result []AccessUser `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +type AccessUser struct { + ID string `json:"id"` + AccessSeat *bool `json:"access_seat"` + ActiveDeviceCount int `json:"active_device_count"` + CreatedAt string `json:"created_at"` + Email string `json:"email"` + GatewaySeat *bool `json:"gateway_seat"` + LastSuccessfulLogin string `json:"last_successful_login"` + Name string `json:"name"` + SeatUID string `json:"seat_uid"` + UID string `json:"uid"` + UpdatedAt string `json:"updated_at"` +} + +type AccessUserParams struct { + ResultInfo +} + +type GetAccessUserSingleActiveSessionResponse struct { + Result GetAccessUserSingleActiveSessionResult `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +type GetAccessUserSingleActiveSessionResult struct { + AccountID string `json:"account_id"` + AuthStatus string `json:"auth_status"` + CommonName string `json:"common_name"` + DevicePosture map[string]AccessUserDevicePosture `json:"devicePosture"` + DeviceSessions map[string]AccessUserDeviceSession `json:"device_sessions"` + DeviceID string `json:"device_id"` + Email string `json:"email"` + Geo AccessUserIdentityGeo `json:"geo"` + IAT int64 `json:"iat"` + IDP AccessUserIDP `json:"idp"` + IP string `json:"ip"` + IsGateway *bool `json:"is_gateway"` + IsWarp *bool `json:"is_warp"` + MtlsAuth AccessUserMTLSAuth `json:"mtls_auth"` + ServiceTokenID string `json:"service_token_id"` + ServiceTokenStatus *bool `json:"service_token_status"` + UserUUID string `json:"user_uuid"` + Version int `json:"version"` + IsActive *bool `json:"isActive"` +} + +// ListAccessUsers returns a list of users for a single cloudflare access/zerotrust account. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-users-get-users +func (api *API) ListAccessUsers(ctx context.Context, rc *ResourceContainer, params AccessUserParams) ([]AccessUser, *ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return []AccessUser{}, &ResultInfo{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + baseURL := fmt.Sprintf("/%s/%s/access/users", rc.Level, rc.Identifier) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var accessUsers []AccessUser + var resultInfo *ResultInfo = nil + + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessUser{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + var r AccessUserListResponse + resultInfo = &r.ResultInfo + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccessUser{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + accessUsers = append(accessUsers, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return accessUsers, resultInfo, nil +} + +// GetAccessUserActiveSessions returns a list of active sessions for an user. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-users-get-active-sessions +func (api *API) GetAccessUserActiveSessions(ctx context.Context, rc *ResourceContainer, userID string) ([]AccessUserActiveSessionResult, error) { + if rc.Level != AccountRouteLevel { + return []AccessUserActiveSessionResult{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf( + "/%s/%s/access/users/%s/active_sessions", + rc.Level, + rc.Identifier, + userID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessUserActiveSessionResult{}, err + } + + var accessUserActiveSessionsResponse AccessUserActiveSessionsResponse + err = json.Unmarshal(res, &accessUserActiveSessionsResponse) + if err != nil { + return []AccessUserActiveSessionResult{}, err + } + return accessUserActiveSessionsResponse.Result, nil +} + +// GetAccessUserSingleActiveSession returns a single active session for a user. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-users-get-active-session +func (api *API) GetAccessUserSingleActiveSession(ctx context.Context, rc *ResourceContainer, userID string, sessionID string) (GetAccessUserSingleActiveSessionResult, error) { + if rc.Level != AccountRouteLevel { + return GetAccessUserSingleActiveSessionResult{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf( + "/%s/%s/access/users/%s/active_sessions/%s", + rc.Level, + rc.Identifier, + userID, + sessionID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return GetAccessUserSingleActiveSessionResult{}, err + } + + var accessUserActiveSingleSessionsResponse GetAccessUserSingleActiveSessionResponse + err = json.Unmarshal(res, &accessUserActiveSingleSessionsResponse) + if err != nil { + return GetAccessUserSingleActiveSessionResult{}, err + } + return accessUserActiveSingleSessionsResponse.Result, nil +} + +// GetAccessUserFailedLogins returns a list of failed logins for a user. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-users-get-failed-logins +func (api *API) GetAccessUserFailedLogins(ctx context.Context, rc *ResourceContainer, userID string) ([]AccessUserFailedLoginResult, error) { + if rc.Level != AccountRouteLevel { + return []AccessUserFailedLoginResult{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf( + "/%s/%s/access/users/%s/failed_logins", + rc.Level, + rc.Identifier, + userID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccessUserFailedLoginResult{}, err + } + + var accessUserFailedLoginsResponse AccessUserFailedLoginsResponse + err = json.Unmarshal(res, &accessUserFailedLoginsResponse) + if err != nil { + return []AccessUserFailedLoginResult{}, err + } + return accessUserFailedLoginsResponse.Result, nil +} + +// GetAccessUserLastSeenIdentity returns the last seen identity for a user. +// +// API documentation: https://developers.cloudflare.com/api/operations/zero-trust-users-get-last-seen-identity +func (api *API) GetAccessUserLastSeenIdentity(ctx context.Context, rc *ResourceContainer, userID string) (GetAccessUserLastSeenIdentityResult, error) { + if rc.Level != AccountRouteLevel { + return GetAccessUserLastSeenIdentityResult{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf( + "/%s/%s/access/users/%s/last_seen_identity", + rc.Level, + rc.Identifier, + userID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return GetAccessUserLastSeenIdentityResult{}, err + } + + var accessUserLastSeenIdentityResponse AccessUserLastSeenIdentitySessionResponse + err = json.Unmarshal(res, &accessUserLastSeenIdentityResponse) + if err != nil { + return GetAccessUserLastSeenIdentityResult{}, err + } + return accessUserLastSeenIdentityResponse.Result, nil +} diff --git a/pkg/cloudflare-go/access_users_test.go b/pkg/cloudflare-go/access_users_test.go new file mode 100644 index 000000000..dd24d3f1f --- /dev/null +++ b/pkg/cloudflare-go/access_users_test.go @@ -0,0 +1,616 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + testAccessUserID = "access-user-id" + testAccessUserSessionID = "access-user-session-id" + + expectedListAccessUserResult = []AccessUser{ + { + AccessSeat: BoolPtr(false), + ActiveDeviceCount: 2, + CreatedAt: "2014-01-01T05:20:00.12345Z", + Email: "jdoe@example.com", + GatewaySeat: BoolPtr(false), + ID: "f3b12456-80dd-4e89-9f5f-ba3dfff12365", + LastSuccessfulLogin: "2020-07-01T05:20:00Z", + Name: "Jane Doe", + SeatUID: "", + UID: "", + UpdatedAt: "2014-01-01T05:20:00.12345Z", + }, + { + AccessSeat: BoolPtr(true), + ActiveDeviceCount: 2, + CreatedAt: "2024-01-01T05:20:00.12345Z", + Email: "jhondoe@example.com", + GatewaySeat: BoolPtr(true), + ID: "c3b12456-80dd-4e89-9f5f-ba3dfff12367", + LastSuccessfulLogin: "2020-07-01T05:20:00Z", + Name: "Jhon Doe", + SeatUID: "", + UID: "", + UpdatedAt: "2014-01-01T05:20:00.12345Z", + }, + } + + expectedGetAccessUserActiveSessionsResult = AccessUserActiveSessionResult{ + Expiration: 1694813506, + Metadata: AccessUserActiveSessionMetadata{ + Apps: map[string]AccessUserActiveSessionMetadataApp{ + "property1": { + Hostname: "test.example.com", + Name: "app name", + Type: "self_hosted", + UID: "cc2a8145-0128-4429-87f3-872c4d380c4e", + }, + "property2": { + Hostname: "test.example.com", + Name: "app name", + Type: "self_hosted", + UID: "cc2a8145-0128-4429-87f3-872c4d380c4e", + }, + }, + Expires: 1694813506, + IAT: 1694791905, + Nonce: "X1aXj1lFVcqqyoXF", + TTL: 21600, + }, + Name: "string", + } + + expectedGetAccessUserFailedLoginsResult = AccessUserFailedLoginResult{ + Expiration: 0, + Metadata: AccessUserFailedLoginMetadata{ + AppName: "Test App", + Aud: "39691c1480a2352a18ece567debc2b32552686cbd38eec0887aa18d5d3f00c04", + Datetime: "2022-02-02T21:54:34.914Z", + RayID: "6d76a8a42ead4133", + UserEmail: "test@cloudflare.com", + UserUUID: "57171132-e453-4ee8-b2a5-8cbaad333207", + }, + } + + expectedGetAccessUserLastSeenIdentityResult = GetAccessUserLastSeenIdentityResult{ + AccountID: "1234567890", + AuthStatus: "NONE", + CommonName: "", + DevicePosture: map[string]AccessUserDevicePosture{ + "property1": { + Check: AccessUserDevicePostureCheck{ + Exists: BoolPtr(true), + Path: "string", + }, + Data: map[string]interface{}{}, + Description: "string", + Error: "string", + ID: "string", + RuleName: "string", + Success: BoolPtr(true), + Timestamp: "string", + Type: "string", + }, + "property2": { + Check: AccessUserDevicePostureCheck{ + Exists: BoolPtr(true), + Path: "string", + }, + Data: map[string]interface{}{}, + Description: "string", + Error: "string", + ID: "string", + RuleName: "string", + Success: BoolPtr(true), + Timestamp: "string", + Type: "string", + }, + }, + DeviceID: "", + DeviceSessions: map[string]AccessUserDeviceSession{ + "property1": { + LastAuthenticated: 1638832687, + }, + "property2": { + LastAuthenticated: 1638832687, + }, + }, + Email: "test@cloudflare.com", + Geo: AccessUserIdentityGeo{ + Country: "US", + }, + IAT: 1694791905, + IDP: AccessUserIDP{ + ID: "string", + Type: "string", + }, + IP: "127.0.0.0", + IsGateway: BoolPtr(false), + IsWarp: BoolPtr(false), + MtlsAuth: AccessUserMTLSAuth{ + AuthStatus: "string", + CertIssuerDN: "string", + CertIssuerSKI: "string", + CertPresented: BoolPtr(true), + CertSerial: "string", + }, + ServiceTokenID: "", + ServiceTokenStatus: BoolPtr(false), + UserUUID: "57cf8cf2-f55a-4588-9ac9-f5e41e9f09b4", + Version: 2, + } + + expectedGetAccessUserSingleActiveSessionResult = GetAccessUserSingleActiveSessionResult{ + AccountID: "1234567890", + AuthStatus: "NONE", + CommonName: "", + DevicePosture: map[string]AccessUserDevicePosture{ + "property1": { + Check: AccessUserDevicePostureCheck{ + Exists: BoolPtr(true), + Path: "string", + }, + Data: map[string]interface{}{}, + Description: "string", + Error: "string", + ID: "string", + RuleName: "string", + Success: BoolPtr(true), + Timestamp: "string", + Type: "string", + }, + "property2": { + Check: AccessUserDevicePostureCheck{ + Exists: BoolPtr(true), + Path: "string", + }, + Data: map[string]interface{}{}, + Description: "string", + Error: "string", + ID: "string", + RuleName: "string", + Success: BoolPtr(true), + Timestamp: "string", + Type: "string", + }, + }, + DeviceID: "", + DeviceSessions: map[string]AccessUserDeviceSession{ + "property1": { + LastAuthenticated: 1638832687, + }, + "property2": { + LastAuthenticated: 1638832687, + }, + }, + Email: "test@cloudflare.com", + Geo: AccessUserIdentityGeo{ + Country: "US", + }, + IAT: 1694791905, + IDP: AccessUserIDP{ + ID: "string", + Type: "string", + }, + IP: "127.0.0.0", + IsGateway: BoolPtr(false), + IsWarp: BoolPtr(false), + MtlsAuth: AccessUserMTLSAuth{ + AuthStatus: "string", + CertIssuerDN: "string", + CertIssuerSKI: "string", + CertPresented: BoolPtr(true), + CertSerial: "string", + }, + ServiceTokenID: "", + ServiceTokenStatus: BoolPtr(false), + UserUUID: "57cf8cf2-f55a-4588-9ac9-f5e41e9f09b4", + Version: 2, + IsActive: BoolPtr(true), + } +) + +func TestListAccessUsers_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, _, err := client.ListAccessUsers(context.Background(), testZoneRC, AccessUserParams{}) + assert.EqualError(t, err, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel)) +} + +func TestListAccessUsers(t *testing.T) { + setup() + defer teardown() + + 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") + userList, err := json.Marshal(expectedListAccessUserResult) + assert.NoError(t, err, "Error marshaling expectedListAccessUserResult") + + fmt.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": %s, + "success": true, + "result_info": { + "count": 2, + "page": 1, + "per_page": 100, + "total_count": 2 + } + }`, string(userList)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/users", handler) + + actual, _, err := client.ListAccessUsers(context.Background(), testAccountRC, AccessUserParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, expectedListAccessUserResult, actual) + } +} + +func TestListAccessUsersWithPagination(t *testing.T) { + setup() + defer teardown() + // page 1 of 2 + page := 1 + + 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") + + userList, err := json.Marshal(expectedListAccessUserResult) + assert.NoError(t, err, "Error marshaling expectedListAccessUserResult") + + fmt.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": %s, + "success": true, + "result_info": { + "count": 2, + "page": %d, + "per_page": 2, + "total_count": 4 + } + }`, string(userList), page) + // increment page for the next call + page++ + } + mux.HandleFunc("/accounts/"+testAccountID+"/access/users", handler) + + actual, _, err := client.ListAccessUsers(context.Background(), testAccountRC, AccessUserParams{}) + expected := []AccessUser{} + // two pages of the same expectedResult + expected = append(expected, expectedListAccessUserResult...) + expected = append(expected, expectedListAccessUserResult...) + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestGetGetAccessUserActiveSessions_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetAccessUserActiveSessions(context.Background(), testZoneRC, testAccessUserID) + assert.EqualError(t, err, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel)) +} + +func TestGetGetAccessUserActiveSessions(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": [ + { + "expiration": 1694813506, + "metadata": { + "apps": { + "property1": { + "hostname": "test.example.com", + "name": "app name", + "type": "self_hosted", + "uid": "cc2a8145-0128-4429-87f3-872c4d380c4e" + }, + "property2": { + "hostname": "test.example.com", + "name": "app name", + "type": "self_hosted", + "uid": "cc2a8145-0128-4429-87f3-872c4d380c4e" + } + }, + "expires": 1694813506, + "iat": 1694791905, + "nonce": "X1aXj1lFVcqqyoXF", + "ttl": 21600 + }, + "name": "string" + } + ], + "success": true, + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 2000 + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/users/"+testAccessUserID+"/active_sessions", handler) + + actual, err := client.GetAccessUserActiveSessions(context.Background(), testAccountRC, testAccessUserID) + if err != nil { + t.Fatal(err) + } + + if assert.NoError(t, err) { + assert.Equal(t, []AccessUserActiveSessionResult{expectedGetAccessUserActiveSessionsResult}, actual) + } +} + +func TestGetAccessUserSingleActiveSession_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetAccessUserSingleActiveSession(context.Background(), testZoneRC, testAccessUserID, testAccessUserSessionID) + assert.EqualError(t, err, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel)) +} + +func TestGetAccessUserSingleActiveSession(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": { + "account_id": "1234567890", + "auth_status": "NONE", + "common_name": "", + "devicePosture": { + "property1": { + "check": { + "exists": true, + "path": "string" + }, + "data": {}, + "description": "string", + "error": "string", + "id": "string", + "rule_name": "string", + "success": true, + "timestamp": "string", + "type": "string" + }, + "property2": { + "check": { + "exists": true, + "path": "string" + }, + "data": {}, + "description": "string", + "error": "string", + "id": "string", + "rule_name": "string", + "success": true, + "timestamp": "string", + "type": "string" + } + }, + "device_id": "", + "device_sessions": { + "property1": { + "last_authenticated": 1638832687 + }, + "property2": { + "last_authenticated": 1638832687 + } + }, + "email": "test@cloudflare.com", + "geo": { + "country": "US" + }, + "iat": 1694791905, + "idp": { + "id": "string", + "type": "string" + }, + "ip": "127.0.0.0", + "is_gateway": false, + "is_warp": false, + "mtls_auth": { + "auth_status": "string", + "cert_issuer_dn": "string", + "cert_issuer_ski": "string", + "cert_presented": true, + "cert_serial": "string" + }, + "service_token_id": "", + "service_token_status": false, + "user_uuid": "57cf8cf2-f55a-4588-9ac9-f5e41e9f09b4", + "version": 2, + "isActive": true + }, + "success": true + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/users/"+testAccessUserID+"/active_sessions/"+testAccessUserSessionID, handler) + + actual, err := client.GetAccessUserSingleActiveSession(context.Background(), testAccountRC, testAccessUserID, testAccessUserSessionID) + + if assert.NoError(t, err) { + assert.Equal(t, expectedGetAccessUserSingleActiveSessionResult, actual) + } +} + +func TestGetAccessUserFailedLogins_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetAccessUserFailedLogins(context.Background(), testZoneRC, testAccessUserID) + assert.EqualError(t, err, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel)) +} + +func TestGetAccessUserFailedLogins(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": [ + { + "expiration": 0, + "metadata": { + "app_name": "Test App", + "aud": "39691c1480a2352a18ece567debc2b32552686cbd38eec0887aa18d5d3f00c04", + "datetime": "2022-02-02T21:54:34.914Z", + "ray_id": "6d76a8a42ead4133", + "user_email": "test@cloudflare.com", + "user_uuid": "57171132-e453-4ee8-b2a5-8cbaad333207" + } + } + ], + "success": true, + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 2000 + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/users/"+testAccessUserID+"/failed_logins", handler) + + actual, err := client.GetAccessUserFailedLogins(context.Background(), testAccountRC, testAccessUserID) + + if assert.NoError(t, err) { + assert.Equal(t, []AccessUserFailedLoginResult{expectedGetAccessUserFailedLoginsResult}, actual) + } +} + +func TestGetAccessUserLastSeenIdentity_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetAccessUserLastSeenIdentity(context.Background(), testZoneRC, testAccessUserID) + assert.EqualError(t, err, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel)) +} + +func TestGetAccessUserLastSeenIdentity(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": { + "account_id": "1234567890", + "auth_status": "NONE", + "common_name": "", + "devicePosture": { + "property1": { + "check": { + "exists": true, + "path": "string" + }, + "data": {}, + "description": "string", + "error": "string", + "id": "string", + "rule_name": "string", + "success": true, + "timestamp": "string", + "type": "string" + }, + "property2": { + "check": { + "exists": true, + "path": "string" + }, + "data": {}, + "description": "string", + "error": "string", + "id": "string", + "rule_name": "string", + "success": true, + "timestamp": "string", + "type": "string" + } + }, + "device_id": "", + "device_sessions": { + "property1": { + "last_authenticated": 1638832687 + }, + "property2": { + "last_authenticated": 1638832687 + } + }, + "email": "test@cloudflare.com", + "geo": { + "country": "US" + }, + "iat": 1694791905, + "idp": { + "id": "string", + "type": "string" + }, + "ip": "127.0.0.0", + "is_gateway": false, + "is_warp": false, + "mtls_auth": { + "auth_status": "string", + "cert_issuer_dn": "string", + "cert_issuer_ski": "string", + "cert_presented": true, + "cert_serial": "string" + }, + "service_token_id": "", + "service_token_status": false, + "user_uuid": "57cf8cf2-f55a-4588-9ac9-f5e41e9f09b4", + "version": 2 + }, + "success": true +} + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/access/users/"+testAccessUserID+"/last_seen_identity", handler) + + actual, err := client.GetAccessUserLastSeenIdentity(context.Background(), testAccountRC, testAccessUserID) + + if assert.NoError(t, err) { + assert.Equal(t, expectedGetAccessUserLastSeenIdentityResult, actual) + } +} diff --git a/pkg/cloudflare-go/account_members.go b/pkg/cloudflare-go/account_members.go new file mode 100644 index 000000000..d6ecab560 --- /dev/null +++ b/pkg/cloudflare-go/account_members.go @@ -0,0 +1,245 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// AccountMember is the definition of a member of an account. +type AccountMember struct { + ID string `json:"id"` + Code string `json:"code"` + User AccountMemberUserDetails `json:"user"` + Status string `json:"status"` + Roles []AccountRole `json:"roles,omitempty"` + Policies []Policy `json:"policies,omitempty"` +} + +// AccountMemberUserDetails outlines all the personal information about +// a member. +type AccountMemberUserDetails struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + TwoFactorAuthenticationEnabled bool `json:"two_factor_authentication_enabled"` +} + +// AccountMembersListResponse represents the response from the list +// account members endpoint. +type AccountMembersListResponse struct { + Result []AccountMember `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccountMemberDetailResponse is the API response, containing a single +// account member. +type AccountMemberDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccountMember `json:"result"` +} + +// AccountMemberInvitation represents the invitation for a new member to +// the account. +type AccountMemberInvitation struct { + Email string `json:"email"` + Roles []string `json:"roles,omitempty"` + Policies []Policy `json:"policies,omitempty"` + Status string `json:"status,omitempty"` +} + +const errMissingMemberRolesOrPolicies = "account member must be created with roles or policies (not both)" + +var ErrMissingMemberRolesOrPolicies = errors.New(errMissingMemberRolesOrPolicies) + +type CreateAccountMemberParams struct { + EmailAddress string + Roles []string + Policies []Policy + Status string +} + +// AccountMembers returns all members of an account. +// +// API reference: https://api.cloudflare.com/#accounts-list-accounts +func (api *API) AccountMembers(ctx context.Context, accountID string, pageOpts PaginationOptions) ([]AccountMember, ResultInfo, error) { + if accountID == "" { + return []AccountMember{}, ResultInfo{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/members", accountID), pageOpts) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccountMember{}, ResultInfo{}, err + } + + var accountMemberListresponse AccountMembersListResponse + err = json.Unmarshal(res, &accountMemberListresponse) + if err != nil { + return []AccountMember{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accountMemberListresponse.Result, accountMemberListresponse.ResultInfo, nil +} + +// CreateAccountMemberWithStatus invites a new member to join an account, allowing setting the status. +// +// Refer to the API reference for valid statuses. +// +// Deprecated: Use `CreateAccountMember` with a `Status` field instead. +// +// API reference: https://api.cloudflare.com/#account-members-add-member +func (api *API) CreateAccountMemberWithStatus(ctx context.Context, accountID string, emailAddress string, roles []string, status string) (AccountMember, error) { + return api.CreateAccountMember(ctx, AccountIdentifier(accountID), CreateAccountMemberParams{ + EmailAddress: emailAddress, + Roles: roles, + Status: status, + }) +} + +// CreateAccountMember invites a new member to join an account with roles. +// The member will be placed into "pending" status and receive an email confirmation. +// NOTE: If you are currently enrolled in Domain Scoped Roles, your roles will +// be converted to policies upon member invitation. +// +// API reference: https://api.cloudflare.com/#account-members-add-member +func (api *API) CreateAccountMember(ctx context.Context, rc *ResourceContainer, params CreateAccountMemberParams) (AccountMember, error) { + if rc.Level != AccountRouteLevel { + return AccountMember{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return AccountMember{}, ErrMissingAccountID + } + + invite := AccountMemberInvitation{ + Email: params.EmailAddress, + Status: params.Status, + } + + roles := []AccountRole{} + for i := 0; i < len(params.Roles); i++ { + roles = append(roles, AccountRole{ID: params.Roles[i]}) + } + err := validateRolesAndPolicies(roles, params.Policies) + if err != nil { + return AccountMember{}, err + } + + if params.Roles != nil { + invite.Roles = params.Roles + } else if params.Policies != nil { + invite.Policies = params.Policies + } + + uri := fmt.Sprintf("/accounts/%s/members", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, invite) + if err != nil { + return AccountMember{}, err + } + + var accountMemberListResponse AccountMemberDetailResponse + err = json.Unmarshal(res, &accountMemberListResponse) + if err != nil { + return AccountMember{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accountMemberListResponse.Result, nil +} + +// DeleteAccountMember removes a member from an account. +// +// API reference: https://api.cloudflare.com/#account-members-remove-member +func (api *API) DeleteAccountMember(ctx context.Context, accountID string, userID string) error { + if accountID == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/members/%s", accountID, userID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// UpdateAccountMember modifies an existing account member. +// +// API reference: https://api.cloudflare.com/#account-members-update-member +func (api *API) UpdateAccountMember(ctx context.Context, accountID string, userID string, member AccountMember) (AccountMember, error) { + if accountID == "" { + return AccountMember{}, ErrMissingAccountID + } + + err := validateRolesAndPolicies(member.Roles, member.Policies) + if err != nil { + return AccountMember{}, err + } + + uri := fmt.Sprintf("/accounts/%s/members/%s", accountID, userID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, member) + if err != nil { + return AccountMember{}, err + } + + var accountMemberListResponse AccountMemberDetailResponse + err = json.Unmarshal(res, &accountMemberListResponse) + if err != nil { + return AccountMember{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accountMemberListResponse.Result, nil +} + +// AccountMember returns details of a single account member. +// +// API reference: https://api.cloudflare.com/#account-members-member-details +func (api *API) AccountMember(ctx context.Context, accountID string, memberID string) (AccountMember, error) { + if accountID == "" { + return AccountMember{}, ErrMissingAccountID + } + + uri := fmt.Sprintf( + "/accounts/%s/members/%s", + accountID, + memberID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccountMember{}, err + } + + var accountMemberResponse AccountMemberDetailResponse + err = json.Unmarshal(res, &accountMemberResponse) + if err != nil { + return AccountMember{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accountMemberResponse.Result, nil +} + +// validateRolesAndPolicies ensures either roles or policies are provided in +// CreateAccountMember requests, but not both. +func validateRolesAndPolicies(roles []AccountRole, policies []Policy) error { + hasRoles := len(roles) > 0 + hasPolicies := len(policies) > 0 + hasRolesOrPolicies := hasRoles || hasPolicies + hasRolesAndPolicies := hasRoles && hasPolicies + hasCorrectPermissions := hasRolesOrPolicies && !hasRolesAndPolicies + if !hasCorrectPermissions { + return ErrMissingMemberRolesOrPolicies + } + return nil +} diff --git a/pkg/cloudflare-go/account_members_test.go b/pkg/cloudflare-go/account_members_test.go new file mode 100644 index 000000000..0834c110c --- /dev/null +++ b/pkg/cloudflare-go/account_members_test.go @@ -0,0 +1,633 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var expectedAccountMemberStruct = AccountMember{ + ID: "4536bcfad5faccb111b47003c79917fa", + Code: "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + User: AccountMemberUserDetails{ + ID: "7c5dae5552338874e5053f2534d2767a", + FirstName: "John", + LastName: "Appleseed", + Email: "user@example.com", + TwoFactorAuthenticationEnabled: false, + }, + Status: "accepted", + Roles: []AccountRole{ + { + ID: "3536bcfad5faccb999b47003c79917fb", + Name: "Account Administrator", + Description: "Administrative access to the entire Account", + Permissions: map[string]AccountRolePermission{ + "analytics": {Read: true, Edit: true}, + "billing": {Read: true, Edit: false}, + }, + }, + }, +} + +var expectedNewAccountMemberStruct = AccountMember{ + ID: "4536bcfad5faccb111b47003c79917fa", + Code: "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + User: AccountMemberUserDetails{ + Email: "user@example.com", + TwoFactorAuthenticationEnabled: false, + }, + Status: "pending", + Roles: []AccountRole{ + { + ID: "3536bcfad5faccb999b47003c79917fb", + Name: "Account Administrator", + Description: "Administrative access to the entire Account", + Permissions: map[string]AccountRolePermission{ + "analytics": {Read: true, Edit: true}, + "billing": {Read: true, Edit: true}, + }, + }, + }, +} + +var expectedNewAccountMemberAcceptedStruct = AccountMember{ + ID: "4536bcfad5faccb111b47003c79917fa", + Code: "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + User: AccountMemberUserDetails{ + Email: "user@example.com", + TwoFactorAuthenticationEnabled: false, + }, + Status: "accepted", + Roles: []AccountRole{ + { + ID: "3536bcfad5faccb999b47003c79917fb", + Name: "Account Administrator", + Description: "Administrative access to the entire Account", + Permissions: map[string]AccountRolePermission{ + "analytics": {Read: true, Edit: true}, + "billing": {Read: true, Edit: true}, + }, + }, + }, +} + +var newUpdatedAccountMemberStruct = AccountMember{ + ID: "4536bcfad5faccb111b47003c79917fa", + Code: "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + User: AccountMemberUserDetails{ + ID: "7c5dae5552338874e5053f2534d2767a", + FirstName: "John", + LastName: "Appleseeds", + Email: "new-user@example.com", + TwoFactorAuthenticationEnabled: false, + }, + Status: "accepted", + Roles: []AccountRole{ + { + ID: "3536bcfad5faccb999b47003c79917fb", + Name: "Account Administrator", + Description: "Administrative access to the entire Account", + Permissions: map[string]AccountRolePermission{ + "analytics": {Read: true, Edit: true}, + "billing": {Read: true, Edit: true}, + }, + }, + }, +} + +var mockPolicy = Policy{ + ID: "mock-policy-id", + PermissionGroups: []PermissionGroup{{ + ID: "mock-permission-group-id", + Name: "mock-permission-group-name", + Permissions: []Permission{{ + ID: "mock-permission-id", + Key: "mock-permission-key", + }}, + }}, + ResourceGroups: []ResourceGroup{{ + ID: "mock-resource-group-id", + Name: "mock-resource-group-name", + Scope: Scope{ + Key: "mock-resource-group-name", + ScopeObjects: []ScopeObject{{ + Key: "*", + }}, + }, + }}, + Access: "allow", +} + +var expectedNewAccountMemberWithPoliciesStruct = AccountMember{ + ID: "new-member-with-polcies-id", + Code: "new-member-with-policies-code", + User: AccountMemberUserDetails{ + ID: "new-member-with-policies-user-id", + FirstName: "John", + LastName: "Appleseed", + Email: "user@example.com", + TwoFactorAuthenticationEnabled: false, + }, + Status: "accepted", + Policies: []Policy{mockPolicy}, +} + +func TestAccountMembers(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "4536bcfad5faccb111b47003c79917fa", + "code": "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + "user": { + "id": "7c5dae5552338874e5053f2534d2767a", + "first_name": "John", + "last_name": "Appleseed", + "email": "user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "accepted", + "roles": [ + { + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "analytics": { + "read": true, + "edit": true + }, + "billing": { + "read": true, + "edit": false + } + } + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members", handler) + want := []AccountMember{expectedAccountMemberStruct} + + actual, _, err := client.AccountMembers(context.Background(), "01a7362d577a6c3019a474fd6f485823", PaginationOptions{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccountMembersWithoutAccountID(t *testing.T) { + setup() + defer teardown() + + _, _, err := client.AccountMembers(context.Background(), "", PaginationOptions{}) + + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } +} + +func TestCreateAccountMemberWithStatus(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "4536bcfad5faccb111b47003c79917fa", + "code": "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + "user": { + "id": null, + "first_name": null, + "last_name": null, + "email": "user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "accepted", + "roles": [{ + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "analytics": { + "read": true, + "edit": true + }, + "billing": { + "read": true, + "edit": true + } + } + }] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members", handler) + + actual, err := client.CreateAccountMemberWithStatus(context.Background(), "01a7362d577a6c3019a474fd6f485823", "user@example.com", []string{"3536bcfad5faccb999b47003c79917fb"}, "accepted") + + if assert.NoError(t, err) { + assert.Equal(t, expectedNewAccountMemberAcceptedStruct, actual) + } +} + +func TestCreateAccountMember(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "4536bcfad5faccb111b47003c79917fa", + "code": "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + "user": { + "id": null, + "first_name": null, + "last_name": null, + "email": "user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "pending", + "roles": [{ + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "analytics": { + "read": true, + "edit": true + }, + "billing": { + "read": true, + "edit": true + } + } + }] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members", handler) + + createAccountParams := CreateAccountMemberParams{ + EmailAddress: "user@example.com", + Roles: []string{"3536bcfad5faccb999b47003c79917fb"}, + } + + actual, err := client.CreateAccountMember(context.Background(), AccountIdentifier("01a7362d577a6c3019a474fd6f485823"), createAccountParams) + + if assert.NoError(t, err) { + assert.Equal(t, expectedNewAccountMemberStruct, actual) + } +} + +func TestCreateAccountMemberWithPolicies(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "new-member-with-polcies-id", + "code": "new-member-with-policies-code", + "user": { + "id": "new-member-with-policies-user-id", + "first_name": "John", + "last_name": "Appleseed", + "email": "user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "accepted", + "policies": [{ + "id": "mock-policy-id", + "permission_groups": [{ + "id": "mock-permission-group-id", + "name": "mock-permission-group-name", + "permissions": [{ + "id": "mock-permission-id", + "key": "mock-permission-key" + }] + }], + "resource_groups": [{ + "id": "mock-resource-group-id", + "name": "mock-resource-group-name", + "scope": { + "key": "mock-resource-group-name", + "objects": [{ + "key": "*" + }] + } + }], + "access": "allow" + }] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members", handler) + actual, err := client.CreateAccountMember(context.Background(), AccountIdentifier("01a7362d577a6c3019a474fd6f485823"), CreateAccountMemberParams{ + EmailAddress: "user@example.com", + Roles: nil, + Policies: []Policy{mockPolicy}, + Status: "", + }) + + if assert.NoError(t, err) { + assert.Equal(t, expectedNewAccountMemberWithPoliciesStruct, actual) + } +} + +func TestCreateAccountMemberWithRolesAndPoliciesErr(t *testing.T) { + setup() + defer teardown() + + accountResource := &ResourceContainer{ + Level: AccountRouteLevel, + Identifier: "01a7362d577a6c3019a474fd6f485823", + } + _, err := client.CreateAccountMember(context.Background(), accountResource, CreateAccountMemberParams{ + EmailAddress: "user@example.com", + Roles: []string{"fake-role-id"}, + Policies: []Policy{mockPolicy}, + Status: "active", + }) + + if assert.Error(t, err) { + assert.Equal(t, err, ErrMissingMemberRolesOrPolicies) + } +} + +func TestCreateAccountMemberWithoutAccountID(t *testing.T) { + setup() + defer teardown() + + accountResource := &ResourceContainer{ + Level: AccountRouteLevel, + Identifier: "", + } + _, err := client.CreateAccountMember(context.Background(), accountResource, CreateAccountMemberParams{ + EmailAddress: "user@example.com", + Roles: []string{"fake-role-id"}, + Status: "active", + }) + + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } +} + +func TestUpdateAccountMember(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "4536bcfad5faccb111b47003c79917fa", + "code": "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + "user": { + "id": "7c5dae5552338874e5053f2534d2767a", + "first_name": "John", + "last_name": "Appleseeds", + "email": "new-user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "accepted", + "roles": [{ + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "analytics": { + "read": true, + "edit": true + }, + "billing": { + "read": true, + "edit": true + } + } + }] + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members/4536bcfad5faccb111b47003c79917fa", handler) + + actual, err := client.UpdateAccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "4536bcfad5faccb111b47003c79917fa", newUpdatedAccountMemberStruct) + + if assert.NoError(t, err) { + assert.Equal(t, newUpdatedAccountMemberStruct, actual) + } +} + +func TestUpdateAccountMemberWithPolicies(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "new-member-with-polcies-id", + "code": "new-member-with-policies-code", + "user": { + "id": "new-member-with-policies-user-id", + "first_name": "John", + "last_name": "Appleseed", + "email": "user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "accepted", + "policies": [{ + "id": "mock-policy-id", + "permission_groups": [{ + "id": "mock-permission-group-id", + "name": "mock-permission-group-name", + "permissions": [{ + "id": "mock-permission-id", + "key": "mock-permission-key" + }] + }], + "resource_groups": [{ + "id": "mock-resource-group-id", + "name": "mock-resource-group-name", + "scope": { + "key": "mock-resource-group-name", + "objects": [{ + "key": "*" + }] + } + }], + "access": "allow" + }] + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members/new-member-with-polcies-id", handler) + + actual, err := client.UpdateAccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "new-member-with-polcies-id", expectedNewAccountMemberWithPoliciesStruct) + + if assert.NoError(t, err) { + assert.Equal(t, expectedNewAccountMemberWithPoliciesStruct, actual) + } +} + +func UpdateAccountMemberWithRolesAndPoliciesErr(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccountMember(context.Background(), + "01a7362d577a6c3019a474fd6f485823", + "", + AccountMember{ + Roles: []AccountRole{{ + ID: "some-role", + }}, + Policies: []Policy{mockPolicy}, + }, + ) + + if assert.Error(t, err) { + assert.Equal(t, err, ErrMissingMemberRolesOrPolicies) + } +} + +func TestUpdateAccountMemberWithoutAccountID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateAccountMember(context.Background(), "", "4536bcfad5faccb111b47003c79917fa", newUpdatedAccountMemberStruct) + + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } +} + +func TestAccountMember(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "4536bcfad5faccb111b47003c79917fa", + "code": "05dd05cce12bbed97c0d87cd78e89bc2fd41a6cee72f27f6fc84af2e45c0fac0", + "user": { + "id": "7c5dae5552338874e5053f2534d2767a", + "first_name": "John", + "last_name": "Appleseed", + "email": "user@example.com", + "two_factor_authentication_enabled": false + }, + "status": "accepted", + "roles": [ + { + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "analytics": { + "read": true, + "edit": true + }, + "billing": { + "read": true, + "edit": false + } + } + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/members/4536bcfad5faccb111b47003c79917fa", handler) + + actual, err := client.AccountMember(context.Background(), "01a7362d577a6c3019a474fd6f485823", "4536bcfad5faccb111b47003c79917fa") + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccountMemberStruct, actual) + } +} + +func TestAccountMemberWithoutAccountID(t *testing.T) { + setup() + defer teardown() + + _, err := client.AccountMember(context.Background(), "", "4536bcfad5faccb111b47003c79917fa") + + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } +} diff --git a/pkg/cloudflare-go/account_roles.go b/pkg/cloudflare-go/account_roles.go new file mode 100644 index 000000000..d216ae310 --- /dev/null +++ b/pkg/cloudflare-go/account_roles.go @@ -0,0 +1,111 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// AccountRole defines the roles that a member can have attached. +type AccountRole struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions map[string]AccountRolePermission `json:"permissions"` +} + +// AccountRolePermission is the shared structure for all permissions +// that can be assigned to a member. +type AccountRolePermission struct { + Read bool `json:"read"` + Edit bool `json:"edit"` +} + +// AccountRolesListResponse represents the list response from the +// account roles. +type AccountRolesListResponse struct { + Result []AccountRole `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccountRoleDetailResponse is the API response, containing a single +// account role. +type AccountRoleDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result AccountRole `json:"result"` +} + +type ListAccountRolesParams struct { + ResultInfo +} + +// ListAccountRoles returns all roles of an account. +// +// API reference: https://developers.cloudflare.com/api/operations/account-roles-list-roles +func (api *API) ListAccountRoles(ctx context.Context, rc *ResourceContainer, params ListAccountRolesParams) ([]AccountRole, error) { + if rc.Identifier == "" { + return []AccountRole{}, ErrMissingAccountID + } + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + var roles []AccountRole + var r AccountRolesListResponse + for { + uri := buildURI(fmt.Sprintf("/accounts/%s/roles", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AccountRole{}, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []AccountRole{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + roles = append(roles, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return roles, nil +} + +// GetAccountRole returns the details of a single account role. +// +// API reference: https://developers.cloudflare.com/api/operations/account-roles-role-details +func (api *API) GetAccountRole(ctx context.Context, rc *ResourceContainer, roleID string) (AccountRole, error) { + if rc.Identifier == "" { + return AccountRole{}, ErrMissingAccountID + } + uri := fmt.Sprintf("/accounts/%s/roles/%s", rc.Identifier, roleID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AccountRole{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var accountRole AccountRoleDetailResponse + err = json.Unmarshal(res, &accountRole) + if err != nil { + return AccountRole{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accountRole.Result, nil +} diff --git a/pkg/cloudflare-go/account_roles_test.go b/pkg/cloudflare-go/account_roles_test.go new file mode 100644 index 000000000..22d0ac905 --- /dev/null +++ b/pkg/cloudflare-go/account_roles_test.go @@ -0,0 +1,121 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var expectedAccountRole = AccountRole{ + ID: "3536bcfad5faccb999b47003c79917fb", + Name: "Account Administrator", + Description: "Administrative access to the entire Account", + Permissions: map[string]AccountRolePermission{ + "dns_records": {Read: true, Edit: true}, + "lb": {Read: true, Edit: false}, + }, +} + +func TestAccountRoles(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "dns_records": { + "read": true, + "edit": true + }, + "lb": { + "read": true, + "edit": false + } + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/roles", testAccountID), handler) + want := []AccountRole{expectedAccountRole} + _, err := client.ListAccountRoles(context.Background(), AccountIdentifier(""), ListAccountRolesParams{}) + if assert.Error(t, err, "Expected error when no account ID is provided") { + assert.Equal(t, ErrMissingAccountID, err) + } + + actual, err := client.ListAccountRoles(context.Background(), testAccountRC, ListAccountRolesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccountRole(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "3536bcfad5faccb999b47003c79917fb", + "name": "Account Administrator", + "description": "Administrative access to the entire Account", + "permissions": { + "dns_records": { + "read": true, + "edit": true + }, + "lb": { + "read": true, + "edit": false + } + } + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/roles/3536bcfad5faccb999b47003c79917fb", testAccountID), handler) + _, err := client.GetAccountRole(context.Background(), AccountIdentifier(""), "3536bcfad5faccb999b47003c79917fb") + if assert.Error(t, err, "Expected error when no account ID is provided") { + assert.Equal(t, ErrMissingAccountID, err) + } + + actual, err := client.GetAccountRole(context.Background(), testAccountRC, "3536bcfad5faccb999b47003c79917fb") + + if assert.NoError(t, err) { + assert.Equal(t, expectedAccountRole, actual) + } +} diff --git a/pkg/cloudflare-go/accounts.go b/pkg/cloudflare-go/accounts.go new file mode 100644 index 000000000..aee3c6aef --- /dev/null +++ b/pkg/cloudflare-go/accounts.go @@ -0,0 +1,151 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AccountSettings outlines the available options for an account. +type AccountSettings struct { + EnforceTwoFactor bool `json:"enforce_twofactor"` +} + +// Account represents the root object that owns resources. +type Account struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` + Settings *AccountSettings `json:"settings,omitempty"` +} + +// AccountResponse represents the response from the accounts endpoint for a +// single account ID. +type AccountResponse struct { + Result Account `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccountListResponse represents the response from the list accounts endpoint. +type AccountListResponse struct { + Result []Account `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccountDetailResponse is the API response, containing a single Account. +type AccountDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result Account `json:"result"` +} + +// AccountsListParams holds the filterable options for Accounts. +type AccountsListParams struct { + Name string `url:"name,omitempty"` + + PaginationOptions +} + +// Accounts returns all accounts the logged in user has access to. +// +// API reference: https://api.cloudflare.com/#accounts-list-accounts +func (api *API) Accounts(ctx context.Context, params AccountsListParams) ([]Account, ResultInfo, error) { + res, err := api.makeRequestContext(ctx, http.MethodGet, buildURI("/accounts", params), nil) + if err != nil { + return []Account{}, ResultInfo{}, err + } + + var accListResponse AccountListResponse + err = json.Unmarshal(res, &accListResponse) + if err != nil { + return []Account{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return accListResponse.Result, accListResponse.ResultInfo, nil +} + +// Account returns a single account based on the ID. +// +// API reference: https://api.cloudflare.com/#accounts-account-details +func (api *API) Account(ctx context.Context, accountID string) (Account, ResultInfo, error) { + uri := fmt.Sprintf("/accounts/%s", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Account{}, ResultInfo{}, err + } + + var accResponse AccountResponse + err = json.Unmarshal(res, &accResponse) + if err != nil { + return Account{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return accResponse.Result, accResponse.ResultInfo, nil +} + +// UpdateAccount allows management of an account using the account ID. +// +// API reference: https://api.cloudflare.com/#accounts-update-account +func (api *API) UpdateAccount(ctx context.Context, accountID string, account Account) (Account, error) { + uri := fmt.Sprintf("/accounts/%s", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, account) + if err != nil { + return Account{}, err + } + + var a AccountDetailResponse + err = json.Unmarshal(res, &a) + if err != nil { + return Account{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return a.Result, nil +} + +// CreateAccount creates a new account. Note: This requires the Tenant +// entitlement. +// +// API reference: https://developers.cloudflare.com/tenant/tutorial/provisioning-resources#creating-an-account +func (api *API) CreateAccount(ctx context.Context, account Account) (Account, error) { + uri := "/accounts" + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, account) + if err != nil { + return Account{}, err + } + + var a AccountDetailResponse + err = json.Unmarshal(res, &a) + if err != nil { + return Account{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return a.Result, nil +} + +// DeleteAccount removes an account. Note: This requires the Tenant +// entitlement. +// +// API reference: https://developers.cloudflare.com/tenant/tutorial/provisioning-resources#optional-deleting-accounts +func (api *API) DeleteAccount(ctx context.Context, accountID string) error { + if accountID == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s", accountID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/accounts_test.go b/pkg/cloudflare-go/accounts_test.go new file mode 100644 index 000000000..0f0fecc9c --- /dev/null +++ b/pkg/cloudflare-go/accounts_test.go @@ -0,0 +1,227 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + accountCreatedOn, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + expectedAccountStruct = Account{ + ID: "01a7362d577a6c3019a474fd6f485823", + Name: "Cloudflare Demo", + CreatedOn: accountCreatedOn, + Settings: &AccountSettings{ + EnforceTwoFactor: false, + }, + } +) + +func TestAccounts(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Cloudflare Demo", + "created_on": "2014-01-01T05:20:00.12345Z", + "settings": { + "enforce_twofactor": false + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts", handler) + want := []Account{expectedAccountStruct} + + actual, _, err := client.Accounts(context.Background(), AccountsListParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccount(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Cloudflare Demo", + "created_on": "2014-01-01T05:20:00.12345Z", + "settings": { + "enforce_twofactor": false + } + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823", handler) + want := expectedAccountStruct + + actual, _, err := client.Account(context.Background(), "01a7362d577a6c3019a474fd6f485823") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAccount(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "id":"01a7362d577a6c3019a474fd6f485823", + "name":"Cloudflare Demo - New", + "created_on": "2014-01-01T05:20:00.12345Z", + "settings":{ + "enforce_twofactor":false + } + }`, string(b), "JSON payload not equal") + } + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Cloudflare Demo - New", + "created_on": "2014-01-01T05:20:00.12345Z", + "settings": { + "enforce_twofactor": false + } + } + }`) + }) + + oldAccountDetails := Account{ + ID: "01a7362d577a6c3019a474fd6f485823", + Name: "Cloudflare Demo - Old", + CreatedOn: accountCreatedOn, + Settings: &AccountSettings{ + EnforceTwoFactor: false, + }, + } + + newAccountDetails := Account{ + ID: "01a7362d577a6c3019a474fd6f485823", + Name: "Cloudflare Demo - New", + CreatedOn: accountCreatedOn, + Settings: &AccountSettings{ + EnforceTwoFactor: false, + }, + } + + account, err := client.UpdateAccount(context.Background(), newAccountDetails.ID, newAccountDetails) + if assert.NoError(t, err) { + assert.NotEqual(t, oldAccountDetails.Name, account.Name) + assert.Equal(t, account.Name, "Cloudflare Demo - New") + } +} + +func TestCreateAccount(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "Cloudflare Demo", + "type": "standard" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts", handler) + newAccount := Account{ + Name: "Cloudflare Demo", + Type: "standard", + } + + actual, err := client.CreateAccount(context.Background(), newAccount) + + if assert.NoError(t, err) { + assert.Equal(t, newAccount, actual) + } +} + +func TestDeleteAccount(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "1b16db169c9cb7853009857198fae1b9" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID, handler) + err := client.DeleteAccount(context.Background(), testAccountID) + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/addressing_address_map.go b/pkg/cloudflare-go/addressing_address_map.go new file mode 100644 index 000000000..0109e79bb --- /dev/null +++ b/pkg/cloudflare-go/addressing_address_map.go @@ -0,0 +1,285 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AddressMap contains information about an address map. +type AddressMap struct { + ID string `json:"id"` + Description *string `json:"description,omitempty"` + DefaultSNI *string `json:"default_sni"` + Enabled *bool `json:"enabled"` + Deletable *bool `json:"can_delete"` + CanModifyIPs *bool `json:"can_modify_ips"` + Memberships []AddressMapMembership `json:"memberships"` + IPs []AddressMapIP `json:"ips"` + CreatedAt time.Time `json:"created_at"` + ModifiedAt time.Time `json:"modified_at"` +} + +type AddressMapIP struct { + IP string `json:"ip"` + CreatedAt time.Time `json:"created_at"` +} + +type AddressMapMembershipContainer struct { + Identifier string `json:"identifier"` + Kind AddressMapMembershipKind `json:"kind"` +} + +type AddressMapMembership struct { + Identifier string `json:"identifier"` + Kind AddressMapMembershipKind `json:"kind"` + Deletable *bool `json:"can_delete"` + CreatedAt time.Time `json:"created_at"` +} + +func (ammb *AddressMapMembershipContainer) URLFragment() string { + switch ammb.Kind { + case AddressMapMembershipAccount: + return fmt.Sprintf("accounts/%s", ammb.Identifier) + case AddressMapMembershipZone: + return fmt.Sprintf("zones/%s", ammb.Identifier) + default: + return fmt.Sprintf("%s/%s", ammb.Kind, ammb.Identifier) + } +} + +type AddressMapMembershipKind string + +const ( + AddressMapMembershipZone AddressMapMembershipKind = "zone" + AddressMapMembershipAccount AddressMapMembershipKind = "account" +) + +// ListAddressMapResponse contains a slice of address maps. +type ListAddressMapResponse struct { + Response + Result []AddressMap `json:"result"` +} + +// GetAddressMapResponse contains a specific address map's API Response. +type GetAddressMapResponse struct { + Response + Result AddressMap `json:"result"` +} + +// CreateAddressMapParams contains information about an address map to be created. +type CreateAddressMapParams struct { + Description *string `json:"description"` + Enabled *bool `json:"enabled"` + IPs []string `json:"ips"` + Memberships []AddressMapMembershipContainer `json:"memberships"` +} + +// UpdateAddressMapParams contains information about an address map to be updated. +type UpdateAddressMapParams struct { + ID string `json:"-"` + Description *string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + DefaultSNI *string `json:"default_sni,omitempty"` +} + +// AddressMapFilter contains filter parameters for finding a list of address maps. +type ListAddressMapsParams struct { + IP *string `url:"ip,omitempty"` + CIDR *string `url:"cidr,omitempty"` +} + +// CreateIPAddressToAddressMapParams contains request parameters to add/remove IP address to/from an address map. +type CreateIPAddressToAddressMapParams struct { + // ID represents the target address map for adding the IP address. + ID string + // The IP address. + IP string +} + +// CreateMembershipToAddressMapParams contains request parameters to add/remove membership from an address map. +type CreateMembershipToAddressMapParams struct { + // ID represents the target address map for adding the membershp. + ID string + Membership AddressMapMembershipContainer +} + +type DeleteMembershipFromAddressMapParams struct { + // ID represents the target address map for removing the IP address. + ID string + Membership AddressMapMembershipContainer +} + +type DeleteIPAddressFromAddressMapParams struct { + // ID represents the target address map for adding the membershp. + ID string + IP string +} + +// ListAddressMaps lists all address maps owned by the account. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-list-address-maps +func (api *API) ListAddressMaps(ctx context.Context, rc *ResourceContainer, params ListAddressMapsParams) ([]AddressMap, error) { + if rc.Level != AccountRouteLevel { + return []AddressMap{}, ErrRequiredAccountLevelResourceContainer + } + + uri := buildURI(fmt.Sprintf("/%s/addressing/address_maps", rc.URLFragment()), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []AddressMap{}, err + } + + result := ListAddressMapResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// CreateAddressMap creates a new address map under the account. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-create-address-map +func (api *API) CreateAddressMap(ctx context.Context, rc *ResourceContainer, params CreateAddressMapParams) (AddressMap, error) { + if rc.Level != AccountRouteLevel { + return AddressMap{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/addressing/address_maps", rc.URLFragment()) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return AddressMap{}, err + } + + result := GetAddressMapResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetAddressMap returns a specific address map. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-address-map-details +func (api *API) GetAddressMap(ctx context.Context, rc *ResourceContainer, id string) (AddressMap, error) { + if rc.Level != AccountRouteLevel { + return AddressMap{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s", rc.URLFragment(), id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AddressMap{}, err + } + + result := GetAddressMapResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateAddressMap modifies properties of an address map owned by the account. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-update-address-map +func (api *API) UpdateAddressMap(ctx context.Context, rc *ResourceContainer, params UpdateAddressMapParams) (AddressMap, error) { + if rc.Level != AccountRouteLevel { + return AddressMap{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s", rc.URLFragment(), params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return AddressMap{}, err + } + + result := GetAddressMapResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return AddressMap{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteAddressMap deletes a particular address map owned by the account. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-delete-address-map +func (api *API) DeleteAddressMap(ctx context.Context, rc *ResourceContainer, id string) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s", rc.URLFragment(), id) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + return err +} + +// CreateIPAddressToAddressMap adds an IP address from a prefix owned by the account to a particular address map. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-add-an-ip-to-an-address-map +func (api *API) CreateIPAddressToAddressMap(ctx context.Context, rc *ResourceContainer, params CreateIPAddressToAddressMapParams) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s/ips/%s", rc.URLFragment(), params.ID, params.IP) + _, err := api.makeRequestContext(ctx, http.MethodPut, uri, nil) + return err +} + +// DeleteIPAddressFromAddressMap removes an IP address from a particular address map. +// +// API reference: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-remove-an-ip-from-an-address-map +func (api *API) DeleteIPAddressFromAddressMap(ctx context.Context, rc *ResourceContainer, params DeleteIPAddressFromAddressMapParams) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s/ips/%s", rc.URLFragment(), params.ID, params.IP) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + return err +} + +// CreateMembershipToAddressMap adds a zone/account as a member of a particular address map. +// +// API reference: +// - account: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-add-an-account-membership-to-an-address-map +// - zone: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-add-a-zone-membership-to-an-address-map +func (api *API) CreateMembershipToAddressMap(ctx context.Context, rc *ResourceContainer, params CreateMembershipToAddressMapParams) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + if params.Membership.Kind != AddressMapMembershipZone && params.Membership.Kind != AddressMapMembershipAccount { + return fmt.Errorf("requested membershp kind (%q) is not supported", params.Membership.Kind) + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s/%s", rc.URLFragment(), params.ID, params.Membership.URLFragment()) + _, err := api.makeRequestContext(ctx, http.MethodPut, uri, nil) + return err +} + +// DeleteMembershipFromAddressMap removes a zone/account as a member of a particular address map. +// +// API reference: +// - account: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-remove-an-account-membership-from-an-address-map +// - zone: https://developers.cloudflare.com/api/operations/ip-address-management-address-maps-remove-a-zone-membership-from-an-address-map +func (api *API) DeleteMembershipFromAddressMap(ctx context.Context, rc *ResourceContainer, params DeleteMembershipFromAddressMapParams) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + if params.Membership.Kind != AddressMapMembershipZone && params.Membership.Kind != AddressMapMembershipAccount { + return fmt.Errorf("requested membershp kind (%q) is not supported", params.Membership.Kind) + } + + uri := fmt.Sprintf("/%s/addressing/address_maps/%s/%s", rc.URLFragment(), params.ID, params.Membership.URLFragment()) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + return err +} diff --git a/pkg/cloudflare-go/addressing_address_map_test.go b/pkg/cloudflare-go/addressing_address_map_test.go new file mode 100644 index 000000000..0bbb3dd60 --- /dev/null +++ b/pkg/cloudflare-go/addressing_address_map_test.go @@ -0,0 +1,384 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + addressMapDesc = "My Ecommerce zones" + addressMapDefaultSNI = "*.example.com" +) + +func TestListAddressMap(t *testing.T) { + setup() + defer teardown() + + expectedIP := "127.0.0.1" + expectedCIDR := "127.0.0.1/24" + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + + actualIP := r.URL.Query().Get("ip") + assert.Equal(t, expectedIP, actualIP, "Expected ip %q, got %q", expectedIP, actualIP) + + actualCIDR := r.URL.Query().Get("cidr") + assert.Equal(t, expectedCIDR, actualCIDR, "Expected cidr %q, got %q", expectedCIDR, actualCIDR) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [{ + "id": "9a7806061c88ada191ed06f989cc3dac", + "description": "My Ecommerce zones", + "can_delete": true, + "can_modify_ips": true, + "default_sni": "*.example.com", + "created_at": "2023-01-01T05:20:00.12345Z", + "modified_at": "2023-01-05T05:20:00.12345Z", + "enabled": true, + "ips": [ + { + "ip": "192.0.2.1", + "created_at": "2023-01-02T05:20:00.12345Z" + } + ], + "memberships": [ + { + "kind": "zone", + "identifier": "01a7362d577a6c3019a474fd6f485823", + "can_delete": true, + "created_at": "2023-01-03T05:20:00.12345Z" + } + ] + }] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00.12345Z") + modifiedAt, _ := time.Parse(time.RFC3339, "2023-01-05T05:20:00.12345Z") + ipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-02T05:20:00.12345Z") + membershipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-03T05:20:00.12345Z") + + want := []AddressMap{ + { + ID: "9a7806061c88ada191ed06f989cc3dac", + CreatedAt: createdAt, + ModifiedAt: modifiedAt, + Description: &addressMapDesc, + Deletable: BoolPtr(true), + CanModifyIPs: BoolPtr(true), + DefaultSNI: &addressMapDefaultSNI, + Enabled: BoolPtr(true), + IPs: []AddressMapIP{{"192.0.2.1", ipCreatedAt}}, + Memberships: []AddressMapMembership{{ + Identifier: "01a7362d577a6c3019a474fd6f485823", + Kind: AddressMapMembershipZone, + Deletable: BoolPtr(true), + CreatedAt: membershipCreatedAt, + }}, + }, + } + + actual, err := client.ListAddressMaps(context.Background(), AccountIdentifier(testAccountID), ListAddressMapsParams{&expectedIP, &expectedCIDR}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetAddressMap(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac", + "description": "My Ecommerce zones", + "can_delete": true, + "can_modify_ips": true, + "default_sni": "*.example.com", + "created_at": "2023-01-01T05:20:00.12345Z", + "modified_at": "2023-01-05T05:20:00.12345Z", + "enabled": true, + "ips": [ + { + "ip": "192.0.2.1", + "created_at": "2023-01-02T05:20:00.12345Z" + } + ], + "memberships": [ + { + "kind": "zone", + "identifier": "01a7362d577a6c3019a474fd6f485823", + "can_delete": true, + "created_at": "2023-01-03T05:20:00.12345Z" + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00.12345Z") + modifiedAt, _ := time.Parse(time.RFC3339, "2023-01-05T05:20:00.12345Z") + ipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-02T05:20:00.12345Z") + membershipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-03T05:20:00.12345Z") + + want := AddressMap{ + ID: "9a7806061c88ada191ed06f989cc3dac", + CreatedAt: createdAt, + ModifiedAt: modifiedAt, + Description: &addressMapDesc, + Deletable: BoolPtr(true), + CanModifyIPs: BoolPtr(true), + DefaultSNI: &addressMapDefaultSNI, + Enabled: BoolPtr(true), + IPs: []AddressMapIP{{"192.0.2.1", ipCreatedAt}}, + Memberships: []AddressMapMembership{{ + Identifier: "01a7362d577a6c3019a474fd6f485823", + Kind: AddressMapMembershipZone, + Deletable: BoolPtr(true), + CreatedAt: membershipCreatedAt, + }}, + } + + actual, err := client.GetAddressMap(context.Background(), AccountIdentifier(testAccountID), "9a7806061c88ada191ed06f989cc3dac") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAddressMap(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac", + "description": "My Ecommerce zones", + "can_delete": true, + "can_modify_ips": true, + "default_sni": "*.example.com", + "created_at": "2023-01-01T05:20:00.12345Z", + "modified_at": "2023-01-05T05:20:00.12345Z", + "enabled": true, + "ips": [ + { + "ip": "192.0.2.1", + "created_at": "2023-01-02T05:20:00.12345Z" + } + ], + "memberships": [ + { + "kind": "zone", + "identifier": "01a7362d577a6c3019a474fd6f485823", + "can_delete": true, + "created_at": "2023-01-03T05:20:00.12345Z" + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00.12345Z") + modifiedAt, _ := time.Parse(time.RFC3339, "2023-01-05T05:20:00.12345Z") + ipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-02T05:20:00.12345Z") + membershipCreatedAt, _ := time.Parse(time.RFC3339, "2023-01-03T05:20:00.12345Z") + + want := AddressMap{ + ID: "9a7806061c88ada191ed06f989cc3dac", + CreatedAt: createdAt, + ModifiedAt: modifiedAt, + Description: &addressMapDesc, + Deletable: BoolPtr(true), + CanModifyIPs: BoolPtr(true), + DefaultSNI: &addressMapDefaultSNI, + Enabled: BoolPtr(true), + IPs: []AddressMapIP{{"192.0.2.1", ipCreatedAt}}, + Memberships: []AddressMapMembership{{ + Identifier: "01a7362d577a6c3019a474fd6f485823", + Kind: AddressMapMembershipZone, + Deletable: BoolPtr(true), + CreatedAt: membershipCreatedAt, + }}, + } + + actual, err := client.UpdateAddressMap(context.Background(), AccountIdentifier(testAccountID), UpdateAddressMapParams{ID: "9a7806061c88ada191ed06f989cc3dac"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteAddressMap(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac", handler) + + err := client.DeleteAddressMap(context.Background(), AccountIdentifier(testAccountID), "9a7806061c88ada191ed06f989cc3dac") + assert.NoError(t, err) +} + +func TestAddIPAddressToAddressMap(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/ips/192.0.2.1", handler) + + err := client.CreateIPAddressToAddressMap(context.Background(), AccountIdentifier(testAccountID), CreateIPAddressToAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", "192.0.2.1"}) + assert.NoError(t, err) +} + +func TestRemoveIPAddressFromAddressMap(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/ips/192.0.2.1", handler) + + err := client.DeleteIPAddressFromAddressMap(context.Background(), AccountIdentifier(testAccountID), DeleteIPAddressFromAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", "192.0.2.1"}) + assert.NoError(t, err) +} + +func TestAddZoneToAddressMap(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/zones/01a7362d577a6c3019a474fd6f485823", handler) + + err := client.CreateMembershipToAddressMap(context.Background(), AccountIdentifier(testAccountID), CreateMembershipToAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipZone}}) + assert.NoError(t, err) +} + +func TestRemoveZoneFromAddressMap(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/zones/01a7362d577a6c3019a474fd6f485823", handler) + + err := client.DeleteMembershipFromAddressMap(context.Background(), AccountIdentifier(testAccountID), DeleteMembershipFromAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipZone}}) + assert.NoError(t, err) +} + +func TestAddAccountToAddressMap(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/accounts/01a7362d577a6c3019a474fd6f485823", handler) + + err := client.CreateMembershipToAddressMap(context.Background(), AccountIdentifier(testAccountID), CreateMembershipToAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipAccount}}) + assert.NoError(t, err) +} + +func TestRemoveAccountFromAddressMap(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/address_maps/9a7806061c88ada191ed06f989cc3dac/accounts/01a7362d577a6c3019a474fd6f485823", handler) + + err := client.DeleteMembershipFromAddressMap(context.Background(), AccountIdentifier(testAccountID), DeleteMembershipFromAddressMapParams{"9a7806061c88ada191ed06f989cc3dac", AddressMapMembershipContainer{"01a7362d577a6c3019a474fd6f485823", AddressMapMembershipAccount}}) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/addressing_ip_prefix.go b/pkg/cloudflare-go/addressing_ip_prefix.go new file mode 100644 index 000000000..a2e686c3b --- /dev/null +++ b/pkg/cloudflare-go/addressing_ip_prefix.go @@ -0,0 +1,149 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// IPPrefix contains information about an IP prefix. +type IPPrefix struct { + ID string `json:"id"` + CreatedAt *time.Time `json:"created_at"` + ModifiedAt *time.Time `json:"modified_at"` + CIDR string `json:"cidr"` + AccountID string `json:"account_id"` + Description string `json:"description"` + Approved string `json:"approved"` + OnDemandEnabled bool `json:"on_demand_enabled"` + OnDemandLocked bool `json:"on_demand_locked"` + Advertised bool `json:"advertised"` + AdvertisedModifiedAt *time.Time `json:"advertised_modified_at"` +} + +// AdvertisementStatus contains information about the BGP status of an IP prefix. +type AdvertisementStatus struct { + Advertised bool `json:"advertised"` + AdvertisedModifiedAt *time.Time `json:"advertised_modified_at"` +} + +// ListIPPrefixResponse contains a slice of IP prefixes. +type ListIPPrefixResponse struct { + Response + Result []IPPrefix `json:"result"` +} + +// GetIPPrefixResponse contains a specific IP prefix's API Response. +type GetIPPrefixResponse struct { + Response + Result IPPrefix `json:"result"` +} + +// GetAdvertisementStatusResponse contains an API Response for the BGP status of the IP Prefix. +type GetAdvertisementStatusResponse struct { + Response + Result AdvertisementStatus `json:"result"` +} + +// IPPrefixUpdateRequest contains information about prefix updates. +type IPPrefixUpdateRequest struct { + Description string `json:"description"` +} + +// AdvertisementStatusUpdateRequest contains information about bgp status updates. +type AdvertisementStatusUpdateRequest struct { + Advertised bool `json:"advertised"` +} + +// ListPrefixes lists all IP prefixes for a given account +// +// API reference: https://api.cloudflare.com/#ip-address-management-prefixes-list-prefixes +func (api *API) ListPrefixes(ctx context.Context, accountID string) ([]IPPrefix, error) { + uri := fmt.Sprintf("/accounts/%s/addressing/prefixes", accountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []IPPrefix{}, err + } + + result := ListIPPrefixResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []IPPrefix{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetPrefix returns a specific IP prefix +// +// API reference: https://api.cloudflare.com/#ip-address-management-prefixes-prefix-details +func (api *API) GetPrefix(ctx context.Context, accountID, ID string) (IPPrefix, error) { + uri := fmt.Sprintf("/accounts/%s/addressing/prefixes/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return IPPrefix{}, err + } + + result := GetIPPrefixResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPPrefix{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdatePrefixDescription edits the description of the IP prefix +// +// API reference: https://api.cloudflare.com/#ip-address-management-prefixes-update-prefix-description +func (api *API) UpdatePrefixDescription(ctx context.Context, accountID, ID string, description string) (IPPrefix, error) { + uri := fmt.Sprintf("/accounts/%s/addressing/prefixes/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, IPPrefixUpdateRequest{Description: description}) + if err != nil { + return IPPrefix{}, err + } + + result := GetIPPrefixResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPPrefix{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetAdvertisementStatus returns the BGP status of the IP prefix +// +// API reference: https://api.cloudflare.com/#ip-address-management-prefixes-update-prefix-description +func (api *API) GetAdvertisementStatus(ctx context.Context, accountID, ID string) (AdvertisementStatus, error) { + uri := fmt.Sprintf("/accounts/%s/addressing/prefixes/%s/bgp/status", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AdvertisementStatus{}, err + } + + result := GetAdvertisementStatusResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return AdvertisementStatus{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateAdvertisementStatus changes the BGP status of an IP prefix +// +// API reference: https://api.cloudflare.com/#ip-address-management-prefixes-update-prefix-description +func (api *API) UpdateAdvertisementStatus(ctx context.Context, accountID, ID string, advertised bool) (AdvertisementStatus, error) { + uri := fmt.Sprintf("/accounts/%s/addressing/prefixes/%s/bgp/status", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, AdvertisementStatusUpdateRequest{Advertised: advertised}) + if err != nil { + return AdvertisementStatus{}, err + } + + result := GetAdvertisementStatusResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return AdvertisementStatus{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} diff --git a/pkg/cloudflare-go/addressing_ip_prefix_test.go b/pkg/cloudflare-go/addressing_ip_prefix_test.go new file mode 100644 index 000000000..0b7bd8cab --- /dev/null +++ b/pkg/cloudflare-go/addressing_ip_prefix_test.go @@ -0,0 +1,237 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListIPPrefix(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "created_at": "2020-04-24T21:25:55.643771Z", + "modified_at": "2020-04-24T21:25:55.643771Z", + "cidr": "10.1.2.3/24", + "account_id": "foo", + "description": "Sample Prefix", + "approved": "V", + "on_demand_enabled": true, + "on_demand_locked": false, + "advertised": true, + "advertised_modified_at": "2020-04-24T21:25:55.643771Z" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/prefixes", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + modifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + advertisedModifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + + want := []IPPrefix{ + { + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedAt: &createdAt, + ModifiedAt: &modifiedAt, + CIDR: "10.1.2.3/24", + AccountID: "foo", + Description: "Sample Prefix", + Approved: "V", + OnDemandEnabled: true, + Advertised: true, + AdvertisedModifiedAt: &advertisedModifiedAt, + }, + } + + actual, err := client.ListPrefixes(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetIPPrefix(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "created_at": "2020-04-24T21:25:55.643771Z", + "modified_at": "2020-04-24T21:25:55.643771Z", + "cidr": "10.1.2.3/24", + "account_id": "foo", + "description": "Sample Prefix", + "approved": "V", + "on_demand_enabled": true, + "on_demand_locked": false, + "advertised": true, + "advertised_modified_at": "2020-04-24T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/prefixes/f68579455bd947efb65ffa1bcf33b52c", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + modifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + advertisedModifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + + want := IPPrefix{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedAt: &createdAt, + ModifiedAt: &modifiedAt, + CIDR: "10.1.2.3/24", + AccountID: "foo", + Description: "Sample Prefix", + Approved: "V", + OnDemandEnabled: true, + Advertised: true, + AdvertisedModifiedAt: &advertisedModifiedAt, + } + + actual, err := client.GetPrefix(context.Background(), testAccountID, "f68579455bd947efb65ffa1bcf33b52c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdatePrefixDescription(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "created_at": "2020-04-24T21:25:55.643771Z", + "modified_at": "2020-04-24T21:25:55.643771Z", + "cidr": "10.1.2.3/24", + "account_id": "foo", + "description": "My IP Prefix", + "approved": "V", + "on_demand_enabled": true, + "on_demand_locked": false, + "advertised": true, + "advertised_modified_at": "2020-04-24T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/prefixes/f68579455bd947efb65ffa1bcf33b52c", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + modifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + advertisedModifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + + want := IPPrefix{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedAt: &createdAt, + ModifiedAt: &modifiedAt, + CIDR: "10.1.2.3/24", + AccountID: "foo", + Description: "My IP Prefix", + Approved: "V", + OnDemandEnabled: true, + Advertised: true, + AdvertisedModifiedAt: &advertisedModifiedAt, + } + + actual, err := client.UpdatePrefixDescription(context.Background(), testAccountID, "f68579455bd947efb65ffa1bcf33b52c", "My IP Prefix") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetAdvertisementStatus(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "advertised": true, + "advertised_modified_at": "2020-04-24T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/prefixes/f68579455bd947efb65ffa1bcf33b52c/bgp/status", handler) + + advertisedModifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + + want := AdvertisementStatus{ + Advertised: true, + AdvertisedModifiedAt: &advertisedModifiedAt, + } + + actual, err := client.GetAdvertisementStatus(context.Background(), testAccountID, "f68579455bd947efb65ffa1bcf33b52c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAdvertisementStatus(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "advertised": false, + "advertised_modified_at": "2020-04-24T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/prefixes/f68579455bd947efb65ffa1bcf33b52c/bgp/status", handler) + + advertisedModifiedAt, _ := time.Parse(time.RFC3339, "2020-04-24T21:25:55.643771Z") + + want := AdvertisementStatus{ + Advertised: false, + AdvertisedModifiedAt: &advertisedModifiedAt, + } + + actual, err := client.UpdateAdvertisementStatus(context.Background(), testAccountID, "f68579455bd947efb65ffa1bcf33b52c", false) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/api_shield.go b/pkg/cloudflare-go/api_shield.go new file mode 100644 index 000000000..622631c26 --- /dev/null +++ b/pkg/cloudflare-go/api_shield.go @@ -0,0 +1,71 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// AuthIdCharacteristics a single option from +// configuration?properties=auth_id_characteristics. +type AuthIdCharacteristics struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// APIShield is all the available options under +// configuration?properties=auth_id_characteristics. +type APIShield struct { + AuthIdCharacteristics []AuthIdCharacteristics `json:"auth_id_characteristics"` +} + +// APIShieldResponse represents the response from the api_gateway/configuration endpoint. +type APIShieldResponse struct { + Result APIShield `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type UpdateAPIShieldParams struct { + AuthIdCharacteristics []AuthIdCharacteristics `json:"auth_id_characteristics"` +} + +// GetAPIShieldConfiguration gets a zone API shield configuration. +// +// API documentation: https://api.cloudflare.com/#api-shield-settings-retrieve-information-about-specific-configuration-properties +func (api *API) GetAPIShieldConfiguration(ctx context.Context, rc *ResourceContainer) (APIShield, ResultInfo, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/configuration?properties=auth_id_characteristics", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return APIShield{}, ResultInfo{}, err + } + var asResponse APIShieldResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return APIShield{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, asResponse.ResultInfo, nil +} + +// UpdateAPIShieldConfiguration sets a zone API shield configuration. +// +// API documentation: https://api.cloudflare.com/#api-shield-settings-set-configuration-properties +func (api *API) UpdateAPIShieldConfiguration(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldParams) (Response, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/configuration", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return Response{}, err + } + var asResponse Response + err = json.Unmarshal(res, &asResponse) + if err != nil { + return Response{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse, nil +} diff --git a/pkg/cloudflare-go/api_shield_api_discovery.go b/pkg/cloudflare-go/api_shield_api_discovery.go new file mode 100644 index 000000000..69b3ad243 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_api_discovery.go @@ -0,0 +1,190 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// APIShieldDiscoveryOrigin is an enumeration on what discovery engine an operation was discovered by. +type APIShieldDiscoveryOrigin string + +const ( + + // APIShieldDiscoveryOriginML discovered operations that were sourced using ML API Discovery. + APIShieldDiscoveryOriginML APIShieldDiscoveryOrigin = "ML" + // APIShieldDiscoveryOriginSessionIdentifier discovered operations that were sourced using Session Identifier + // API Discovery. + APIShieldDiscoveryOriginSessionIdentifier APIShieldDiscoveryOrigin = "SessionIdentifier" +) + +// APIShieldDiscoveryState is an enumeration on states a discovery operation can be in. +type APIShieldDiscoveryState string + +const ( + // APIShieldDiscoveryStateReview discovered operations that are not saved into API Shield Endpoint Management. + APIShieldDiscoveryStateReview APIShieldDiscoveryState = "review" + // APIShieldDiscoveryStateSaved discovered operations that are already saved into API Shield Endpoint Management. + APIShieldDiscoveryStateSaved APIShieldDiscoveryState = "saved" + // APIShieldDiscoveryStateIgnored discovered operations that have been marked as ignored. + APIShieldDiscoveryStateIgnored APIShieldDiscoveryState = "ignored" +) + +// APIShieldDiscoveryOperation is an operation that was discovered by API Discovery. +type APIShieldDiscoveryOperation struct { + // ID represents the ID of the operation, formatted as UUID + ID string `json:"id"` + // Origin represents the API discovery engine(s) that discovered this operation + Origin []APIShieldDiscoveryOrigin `json:"origin"` + // State represents the state of operation in API Discovery + State APIShieldDiscoveryState `json:"state"` + // LastUpdated timestamp of when this operation was last updated + LastUpdated *time.Time `json:"last_updated"` + // Features are additional data about the operation + Features map[string]any `json:"features,omitempty"` + + Method string `json:"method"` + Host string `json:"host"` + Endpoint string `json:"endpoint"` +} + +// ListAPIShieldDiscoveryOperationsParams represents the parameters to pass when retrieving discovered operations. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-api-discovery-retrieve-discovered-operations-on-a-zone +type ListAPIShieldDiscoveryOperationsParams struct { + // Direction to order results. + Direction string `url:"direction,omitempty"` + // OrderBy when requesting a feature, the feature keys are available for ordering as well, e.g., thresholds.suggested_threshold. + OrderBy string `url:"order,omitempty"` + // Hosts filters results to only include the specified hosts. + Hosts []string `url:"host,omitempty"` + // Methods filters results to only include the specified methods. + Methods []string `url:"method,omitempty"` + // Endpoint filters results to only include endpoints containing this pattern. + Endpoint string `url:"endpoint,omitempty"` + // Diff when true, only return API Discovery results that are not saved into API Shield Endpoint Management + Diff bool `url:"diff,omitempty"` + // Origin filters results to only include discovery results sourced from a particular discovery engine + // See APIShieldDiscoveryOrigin for valid values. + Origin APIShieldDiscoveryOrigin `url:"origin,omitempty"` + // State filters results to only include discovery results in a particular state + // See APIShieldDiscoveryState for valid values. + State APIShieldDiscoveryState `url:"state,omitempty"` + + // Pagination options to apply to the request. + PaginationOptions +} + +// UpdateAPIShieldDiscoveryOperationParams represents the parameters to pass to patch a discovery operation +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-api-patch-discovered-operation +type UpdateAPIShieldDiscoveryOperationParams struct { + // OperationID is the ID, formatted as UUID, of the operation to be updated + OperationID string `json:"-" url:"-"` + State APIShieldDiscoveryState `json:"state" url:"-"` +} + +// UpdateAPIShieldDiscoveryOperationsParams maps discovery operation IDs to PatchAPIShieldDiscoveryOperation structs +// +// Example: +// +// UpdateAPIShieldDiscoveryOperations{ +// "99522293-a505-45e5-bbad-bbc339f5dc40": PatchAPIShieldDiscoveryOperation{ State: "review" }, +// } +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-api-patch-discovered-operations +type UpdateAPIShieldDiscoveryOperationsParams map[string]UpdateAPIShieldDiscoveryOperation + +// UpdateAPIShieldDiscoveryOperation represents the state to set on a discovery operation. +type UpdateAPIShieldDiscoveryOperation struct { + // State is the state to set on the operation + State APIShieldDiscoveryState `json:"state" url:"-"` +} + +// APIShieldListDiscoveryOperationsResponse represents the response from the api_gateway/discovery/operations endpoint. +type APIShieldListDiscoveryOperationsResponse struct { + Result []APIShieldDiscoveryOperation `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// APIShieldPatchDiscoveryOperationResponse represents the response from the PATCH api_gateway/discovery/operations/{id} endpoint. +type APIShieldPatchDiscoveryOperationResponse struct { + Result UpdateAPIShieldDiscoveryOperation `json:"result"` + Response +} + +// APIShieldPatchDiscoveryOperationsResponse represents the response from the PATCH api_gateway/discovery/operations endpoint. +type APIShieldPatchDiscoveryOperationsResponse struct { + Result UpdateAPIShieldDiscoveryOperationsParams `json:"result"` + Response +} + +// ListAPIShieldDiscoveryOperations retrieve the most up to date view of discovered operations. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-api-discovery-retrieve-discovered-operations-on-a-zone +func (api *API) ListAPIShieldDiscoveryOperations(ctx context.Context, rc *ResourceContainer, params ListAPIShieldDiscoveryOperationsParams) ([]APIShieldDiscoveryOperation, ResultInfo, error) { + uri := buildURI(fmt.Sprintf("/zones/%s/api_gateway/discovery/operations", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var asResponse APIShieldListDiscoveryOperationsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, asResponse.ResultInfo, nil +} + +// UpdateAPIShieldDiscoveryOperation updates certain fields on a discovered operation. +// +// API Documentation: https://developers.cloudflare.com/api/operations/api-shield-api-patch-discovered-operation +func (api *API) UpdateAPIShieldDiscoveryOperation(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldDiscoveryOperationParams) (*UpdateAPIShieldDiscoveryOperation, error) { + if params.OperationID == "" { + return nil, fmt.Errorf("operation ID must be provided") + } + + uri := fmt.Sprintf("/zones/%s/api_gateway/discovery/operations/%s", rc.Identifier, params.OperationID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return nil, err + } + + // Result should be the updated schema that was patched + var asResponse APIShieldPatchDiscoveryOperationResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// UpdateAPIShieldDiscoveryOperations bulk updates certain fields on multiple discovered operations +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-api-patch-discovered-operations +func (api *API) UpdateAPIShieldDiscoveryOperations(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldDiscoveryOperationsParams) (*UpdateAPIShieldDiscoveryOperationsParams, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/discovery/operations", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return nil, err + } + + // Result should be the updated schema that was patched + var asResponse APIShieldPatchDiscoveryOperationsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} diff --git a/pkg/cloudflare-go/api_shield_api_discovery_test.go b/pkg/cloudflare-go/api_shield_api_discovery_test.go new file mode 100644 index 000000000..3344c4934 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_api_discovery_test.go @@ -0,0 +1,383 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAPIShieldDiscoveryOperationID = "d3f614c0-7d73-4e4f-8d17-4215e7d78b77" + +func TestListAPIShieldDiscoveryOperations(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/discovery/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z", + "origin": ["ML"], + "state": "review" + } + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, actualResultInfo, err := client.ListAPIShieldDiscoveryOperations( + context.Background(), + ZoneIdentifier(testZoneID), + ListAPIShieldDiscoveryOperationsParams{}, + ) + + lastUpdated := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expectedOps := []APIShieldDiscoveryOperation{ + { + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: &lastUpdated, + Origin: []APIShieldDiscoveryOrigin{APIShieldDiscoveryOriginML}, + State: APIShieldDiscoveryStateReview, + }, + } + + expectedResultInfo := ResultInfo{ + Page: 3, + PerPage: 20, + Count: 1, + Total: 2000, + } + + if assert.NoError(t, err) { + assert.Equal(t, expectedOps, actual) + assert.Equal(t, expectedResultInfo, actualResultInfo) + } +} + +func TestListAPIShieldDiscoveryOperationsWithParams(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/discovery/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z", + "origin": ["SessionIdentifier"], + "state": "saved", + "features": { + "traffic_stats": {} + } + } + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + tests := []struct { + name string + params ListAPIShieldDiscoveryOperationsParams + expectedParams url.Values + }{ + { + name: "all params", + params: ListAPIShieldDiscoveryOperationsParams{ + Direction: "desc", + OrderBy: "host", + Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, + Methods: []string{"GET", "PUT"}, + Endpoint: "/client", + Origin: APIShieldDiscoveryOriginSessionIdentifier, + State: APIShieldDiscoveryStateSaved, + Diff: true, + PaginationOptions: PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "direction": []string{"desc"}, + "order": []string{"host"}, + "host": []string{"api.cloudflare.com", "developers.cloudflare.com"}, + "method": []string{"GET", "PUT"}, + "endpoint": []string{"/client"}, + "origin": []string{"SessionIdentifier"}, + "state": []string{"saved"}, + "diff": []string{"true"}, + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + { + name: "direction only", + params: ListAPIShieldDiscoveryOperationsParams{ + Direction: "desc", + }, + expectedParams: url.Values{ + "direction": []string{"desc"}, + }, + }, + { + name: "order only", + params: ListAPIShieldDiscoveryOperationsParams{ + OrderBy: "host", + }, + expectedParams: url.Values{ + "order": []string{"host"}, + }, + }, + { + name: "hosts only", + params: ListAPIShieldDiscoveryOperationsParams{ + Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, + }, + expectedParams: url.Values{ + "host": []string{"api.cloudflare.com", "developers.cloudflare.com"}, + }, + }, + { + name: "methods only", + params: ListAPIShieldDiscoveryOperationsParams{ + Methods: []string{"GET", "PUT"}, + }, + expectedParams: url.Values{ + "method": []string{"GET", "PUT"}, + }, + }, + { + name: "endpoint only", + params: ListAPIShieldDiscoveryOperationsParams{ + Endpoint: "/client", + }, + expectedParams: url.Values{ + "endpoint": []string{"/client"}, + }, + }, + { + name: "origin only", + params: ListAPIShieldDiscoveryOperationsParams{ + Origin: APIShieldDiscoveryOriginSessionIdentifier, + }, + expectedParams: url.Values{ + "origin": []string{"SessionIdentifier"}, + }, + }, + { + name: "state only", + params: ListAPIShieldDiscoveryOperationsParams{ + State: APIShieldDiscoveryStateSaved, + }, + expectedParams: url.Values{ + "state": []string{"saved"}, + }, + }, + { + name: "diff only", + params: ListAPIShieldDiscoveryOperationsParams{ + Diff: true, + }, + expectedParams: url.Values{ + "diff": []string{"true"}, + }, + }, + { + name: "pagination only", + params: ListAPIShieldDiscoveryOperationsParams{ + PaginationOptions: PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, _, err := client.ListAPIShieldDiscoveryOperations( + context.Background(), + ZoneIdentifier(testZoneID), + test.params, + ) + + lastUpdated := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := []APIShieldDiscoveryOperation{ + { + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: &lastUpdated, + Origin: []APIShieldDiscoveryOrigin{APIShieldDiscoveryOriginSessionIdentifier}, + State: APIShieldDiscoveryStateSaved, + Features: map[string]any{ + "traffic_stats": map[string]any{}, + }, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + }) + } +} + +func TestUpdateAPIShieldDiscoveryOperation(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/discovery/operations/%s", testZoneID, testAPIShieldDiscoveryOperationID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "state": "ignored" + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + require.Empty(t, r.URL.Query()) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, `{"state":"ignored"}`, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.UpdateAPIShieldDiscoveryOperation( + context.Background(), + ZoneIdentifier(testZoneID), + UpdateAPIShieldDiscoveryOperationParams{ + OperationID: testAPIShieldDiscoveryOperationID, + State: APIShieldDiscoveryStateIgnored, + }, + ) + + // patch result is a cut down representation of the schema + // so metadata like created date is not populated + expected := &UpdateAPIShieldDiscoveryOperation{ + State: APIShieldDiscoveryStateIgnored, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + + assert.NoError(t, err) +} + +func TestUpdateAPIShieldDiscoveryOperations(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/discovery/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "9b16ce22-d1bf-425d-869f-a11f8240fafb": { "state": "ignored" }, + "c51c2ea1-a690-48fd-8e3f-7fc79b269947": { "state": "review" } + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + require.Empty(t, r.URL.Query()) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, `{"9b16ce22-d1bf-425d-869f-a11f8240fafb":{"state":"ignored"},"c51c2ea1-a690-48fd-8e3f-7fc79b269947":{"state":"review"}}`, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.UpdateAPIShieldDiscoveryOperations( + context.Background(), + ZoneIdentifier(testZoneID), + UpdateAPIShieldDiscoveryOperationsParams{ + "9b16ce22-d1bf-425d-869f-a11f8240fafb": UpdateAPIShieldDiscoveryOperation{State: APIShieldDiscoveryStateIgnored}, + "c51c2ea1-a690-48fd-8e3f-7fc79b269947": UpdateAPIShieldDiscoveryOperation{State: APIShieldDiscoveryStateReview}, + }, + ) + + expected := &UpdateAPIShieldDiscoveryOperationsParams{ + "9b16ce22-d1bf-425d-869f-a11f8240fafb": UpdateAPIShieldDiscoveryOperation{State: APIShieldDiscoveryStateIgnored}, + "c51c2ea1-a690-48fd-8e3f-7fc79b269947": UpdateAPIShieldDiscoveryOperation{State: APIShieldDiscoveryStateReview}, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + + assert.NoError(t, err) +} + +func TestMustProvideDiscoveryOperationID(t *testing.T) { + setup() + t.Cleanup(teardown) + + _, err := client.UpdateAPIShieldDiscoveryOperation(context.Background(), ZoneIdentifier(testZoneID), UpdateAPIShieldDiscoveryOperationParams{}) + require.ErrorContains(t, err, "operation ID must be provided") +} diff --git a/pkg/cloudflare-go/api_shield_operations.go b/pkg/cloudflare-go/api_shield_operations.go new file mode 100644 index 000000000..1d89a1754 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_operations.go @@ -0,0 +1,185 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// APIShieldOperation represents an operation stored in API Shield Endpoint Management. +type APIShieldOperation struct { + APIShieldBasicOperation + ID string `json:"operation_id"` + LastUpdated *time.Time `json:"last_updated"` + Features map[string]any `json:"features,omitempty"` +} + +// GetAPIShieldOperationParams represents the parameters to pass when retrieving an operation. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation +type GetAPIShieldOperationParams struct { + // The Operation ID to retrieve + OperationID string `url:"-"` + // Features represents a set of features to return in `features` object when + // performing making read requests against an Operation or listing operations. + Features []string `url:"feature,omitempty"` +} + +// CreateAPIShieldOperationsParams represents the parameters to pass when adding one or more operations. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone +type CreateAPIShieldOperationsParams struct { + // Operations are a slice of operations to be created in API Shield Endpoint Management + Operations []APIShieldBasicOperation `url:"-"` +} + +// APIShieldBasicOperation should be used when creating an operation in API Shield Endpoint Management. +type APIShieldBasicOperation struct { + Method string `json:"method"` + Host string `json:"host"` + Endpoint string `json:"endpoint"` +} + +// DeleteAPIShieldOperationParams represents the parameters to pass to delete an operation. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation +type DeleteAPIShieldOperationParams struct { + // OperationID is the operation to be deleted + OperationID string `url:"-"` +} + +// ListAPIShieldOperationsParams represents the parameters to pass when retrieving operations +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +type ListAPIShieldOperationsParams struct { + // Features represents a set of features to return in `features` object when + // performing making read requests against an Operation or listing operations. + Features []string `url:"feature,omitempty"` + // Direction to order results. + Direction string `url:"direction,omitempty"` + // OrderBy when requesting a feature, the feature keys are available for ordering as well, e.g., thresholds.suggested_threshold. + OrderBy string `url:"order,omitempty"` + // Filters to only return operations that match filtering criteria, see APIShieldGetOperationsFilters + APIShieldListOperationsFilters + // Pagination options to apply to the request. + PaginationOptions +} + +// APIShieldListOperationsFilters represents the filtering query parameters to set when retrieving operations +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +type APIShieldListOperationsFilters struct { + // Hosts filters results to only include the specified hosts. + Hosts []string `url:"host,omitempty"` + // Methods filters results to only include the specified methods. + Methods []string `url:"method,omitempty"` + // Endpoint filter results to only include endpoints containing this pattern. + Endpoint string `url:"endpoint,omitempty"` +} + +// APIShieldGetOperationResponse represents the response from the api_gateway/operations/{id} endpoint. +type APIShieldGetOperationResponse struct { + Result APIShieldOperation `json:"result"` + Response +} + +// APIShieldGetOperationsResponse represents the response from the api_gateway/operations endpoint. +type APIShieldGetOperationsResponse struct { + Result []APIShieldOperation `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// APIShieldDeleteOperationResponse represents the response from the api_gateway/operations/{id} endpoint (DELETE). +type APIShieldDeleteOperationResponse struct { + Result interface{} `json:"result"` + Response +} + +// GetAPIShieldOperation returns information about an operation +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-an-operation +func (api *API) GetAPIShieldOperation(ctx context.Context, rc *ResourceContainer, params GetAPIShieldOperationParams) (*APIShieldOperation, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, params.OperationID) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var asResponse APIShieldGetOperationResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// ListAPIShieldOperations retrieve information about all operations on a zone +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-retrieve-information-about-all-operations-on-a-zone +func (api *API) ListAPIShieldOperations(ctx context.Context, rc *ResourceContainer, params ListAPIShieldOperationsParams) ([]APIShieldOperation, ResultInfo, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var asResponse APIShieldGetOperationsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, asResponse.ResultInfo, nil +} + +// CreateAPIShieldOperations add one or more operations to a zone. +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-add-operations-to-a-zone +func (api *API) CreateAPIShieldOperations(ctx context.Context, rc *ResourceContainer, params CreateAPIShieldOperationsParams) ([]APIShieldOperation, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Operations) + if err != nil { + return nil, err + } + + // Result should be all the operations added to the zone, similar to doing GetAPIShieldOperations + var asResponse APIShieldGetOperationsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, nil +} + +// DeleteAPIShieldOperation deletes a single operation +// +// API documentation https://developers.cloudflare.com/api/operations/api-shield-endpoint-management-delete-an-operation +func (api *API) DeleteAPIShieldOperation(ctx context.Context, rc *ResourceContainer, params DeleteAPIShieldOperationParams) error { + uri := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", rc.Identifier, params.OperationID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var asResponse APIShieldDeleteOperationResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/api_shield_operations_test.go b/pkg/cloudflare-go/api_shield_operations_test.go new file mode 100644 index 000000000..fdcc25cc8 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_operations_test.go @@ -0,0 +1,491 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAPIShieldOperationId = "9def2cb0-3ed0-4737-92ca-f09efa4718fd" + +func TestGetAPIShieldOperation(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z" + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldOperation( + context.Background(), + ZoneIdentifier(testZoneID), + GetAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + }, + ) + + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := &APIShieldOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: testAPIShieldOperationId, + LastUpdated: &time, + Features: nil, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestGetAPIShieldOperationWithParams(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z", + "features":{ + "thresholds":{}, + "parameter_schemas":{} + } + } + }` + + tests := []struct { + name string + getParams GetAPIShieldOperationParams + expectedParams url.Values + }{ + { + name: "one feature", + getParams: GetAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + Features: []string{"thresholds"}, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds"}, + }, + }, + { + name: "more than one feature", + getParams: GetAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + Features: []string{"thresholds", "parameter_schemas"}, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds", "parameter_schemas"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldOperation( + context.Background(), + ZoneIdentifier(testZoneID), + test.getParams, + ) + + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := &APIShieldOperation{ + APIShieldBasicOperation: APIShieldBasicOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: &time, + Features: map[string]any{ + "thresholds": map[string]any{}, + "parameter_schemas": map[string]any{}, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + }) + } +} + +func TestListAPIShieldOperations(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z" + } + + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, actualResultInfo, err := client.ListAPIShieldOperations( + context.Background(), + ZoneIdentifier(testZoneID), + ListAPIShieldOperationsParams{}, + ) + + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expectedOps := []APIShieldOperation{ + { + APIShieldBasicOperation: APIShieldBasicOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: &time, + Features: nil, + }, + } + + expectedResultInfo := ResultInfo{ + Page: 3, + PerPage: 20, + Count: 1, + Total: 2000, + } + + if assert.NoError(t, err) { + assert.Equal(t, expectedOps, actual) + assert.Equal(t, expectedResultInfo, actualResultInfo) + } +} + +func TestListAPIShieldOperationsWithParams(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z", + "features": { + "thresholds": {} + } + } + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + tests := []struct { + name string + params ListAPIShieldOperationsParams + expectedParams url.Values + }{ + { + name: "all params", + params: ListAPIShieldOperationsParams{ + Features: []string{"thresholds", "parameter_schemas"}, + Direction: "desc", + OrderBy: "host", + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ + Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, + Methods: []string{"GET", "PUT"}, + Endpoint: "/client", + }, + PaginationOptions: PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds", "parameter_schemas"}, + "direction": []string{"desc"}, + "order": []string{"host"}, + "host": []string{"api.cloudflare.com", "developers.cloudflare.com"}, + "method": []string{"GET", "PUT"}, + "endpoint": []string{"/client"}, + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + { + name: "features only", + params: ListAPIShieldOperationsParams{ + Features: []string{"thresholds", "parameter_schemas"}, + }, + expectedParams: url.Values{ + "feature": []string{"thresholds", "parameter_schemas"}, + }, + }, + { + name: "direction only", + params: ListAPIShieldOperationsParams{ + Direction: "desc", + }, + expectedParams: url.Values{ + "direction": []string{"desc"}, + }, + }, + { + name: "order only", + params: ListAPIShieldOperationsParams{ + OrderBy: "host", + }, + expectedParams: url.Values{ + "order": []string{"host"}, + }, + }, + { + name: "hosts only", + params: ListAPIShieldOperationsParams{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ + Hosts: []string{"api.cloudflare.com", "developers.cloudflare.com"}, + }, + }, + expectedParams: url.Values{ + "host": []string{"api.cloudflare.com", "developers.cloudflare.com"}, + }, + }, + { + name: "methods only", + params: ListAPIShieldOperationsParams{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ + Methods: []string{"GET", "PUT"}, + }, + }, + expectedParams: url.Values{ + "method": []string{"GET", "PUT"}, + }, + }, + { + name: "endpoint only", + params: ListAPIShieldOperationsParams{ + APIShieldListOperationsFilters: APIShieldListOperationsFilters{ + Endpoint: "/client", + }, + }, + expectedParams: url.Values{ + "endpoint": []string{"/client"}, + }, + }, + { + name: "pagination only", + params: ListAPIShieldOperationsParams{ + PaginationOptions: PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, _, err := client.ListAPIShieldOperations( + context.Background(), + ZoneIdentifier(testZoneID), + test.params, + ) + + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := []APIShieldOperation{ + { + APIShieldBasicOperation: APIShieldBasicOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: &time, + Features: map[string]any{ + "thresholds": map[string]any{}, + }, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + }) + } +} + +func TestCreateAPIShieldOperations(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "operation_id": "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + "method": "POST", + "host": "api.cloudflare.com", + "endpoint": "/client/v4/zones", + "last_updated": "2023-03-02T15:46:06.000000Z" + } + ] + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, []byte(`[{"method":"POST","host":"api.cloudflare.com","endpoint":"/client/v4/zones"}]`), body) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.CreateAPIShieldOperations( + context.Background(), + ZoneIdentifier(testZoneID), + CreateAPIShieldOperationsParams{ + Operations: []APIShieldBasicOperation{ + { + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + }, + }, + ) + + time := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := []APIShieldOperation{ + { + APIShieldBasicOperation: APIShieldBasicOperation{ + Method: "POST", + Host: "api.cloudflare.com", + Endpoint: "/client/v4/zones", + }, + ID: "9def2cb0-3ed0-4737-92ca-f09efa4718fd", + LastUpdated: &time, + Features: nil, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestDeleteAPIShieldOperation(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s", testZoneID, testAPIShieldOperationId) + response := `{"result":{},"success":true,"errors":[],"messages":[]}` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + err := client.DeleteAPIShieldOperation( + context.Background(), + ZoneIdentifier(testZoneID), + DeleteAPIShieldOperationParams{ + OperationID: testAPIShieldOperationId, + }, + ) + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/api_shield_schemas.go b/pkg/cloudflare-go/api_shield_schemas.go new file mode 100644 index 000000000..804181d86 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_schemas.go @@ -0,0 +1,505 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "strconv" + "time" + + "github.com/goccy/go-json" +) + +// APIShieldSchema represents a schema stored in API Shield Schema Validation 2.0. +type APIShieldSchema struct { + // ID represents the ID of the schema + ID string `json:"schema_id"` + // Name represents the name of the schema + Name string `json:"name"` + // Kind of the schema + Kind string `json:"kind"` + // Source is the contents of the schema + Source string `json:"source,omitempty"` + // CreatedAt is the time the schema was created + CreatedAt *time.Time `json:"created_at,omitempty"` + // ValidationEnabled controls if schema is used for validation + ValidationEnabled bool `json:"validation_enabled,omitempty"` +} + +// CreateAPIShieldSchemaParams represents the parameters to pass when creating a schema in Schema Validation 2.0. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-post-schema +type CreateAPIShieldSchemaParams struct { + // Source is a io.Reader containing the contents of the schema + Source io.Reader + // Name represents the name of the schema. + Name string + // Kind of the schema. This is always set to openapi_v3. + Kind string + // ValidationEnabled controls if schema is used for validation + ValidationEnabled *bool +} + +// GetAPIShieldSchemaParams represents the parameters to pass when retrieving a schema with a given schema ID. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-specific-schema +type GetAPIShieldSchemaParams struct { + // SchemaID is the ID of the schema to retrieve + SchemaID string `url:"-"` + + // OmitSource specifies whether the contents of the schema should be returned in the "Source" field. + OmitSource *bool `url:"omit_source,omitempty"` +} + +// ListAPIShieldSchemasParams represents the parameters to pass when retrieving all schemas. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-all-schemas +type ListAPIShieldSchemasParams struct { + // OmitSource specifies whether the contents of the schema should be returned in the "Source" field. + OmitSource *bool `url:"omit_source,omitempty"` + + // ValidationEnabled specifies whether to return only schemas that have validation enabled. + ValidationEnabled *bool `url:"validation_enabled,omitempty"` + + // PaginationOptions to apply to the request. + PaginationOptions +} + +// DeleteAPIShieldSchemaParams represents the parameters to pass to delete a schema. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-delete-a-schema +type DeleteAPIShieldSchemaParams struct { + // SchemaID is the schema to be deleted + SchemaID string `url:"-"` +} + +// UpdateAPIShieldSchemaParams represents the parameters to pass to patch certain fields on an existing schema +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-enable-validation-for-a-schema +type UpdateAPIShieldSchemaParams struct { + // SchemaID is the schema to be patched + SchemaID string `json:"-" url:"-"` + + // ValidationEnabled controls if schema is used for validation + ValidationEnabled *bool `json:"validation_enabled" url:"-"` +} + +// APIShieldGetSchemaResponse represents the response from the GET api_gateway/user_schemas/{id} endpoint. +type APIShieldGetSchemaResponse struct { + Result APIShieldSchema `json:"result"` + Response +} + +// APIShieldListSchemasResponse represents the response from the GET api_gateway/user_schemas endpoint. +type APIShieldListSchemasResponse struct { + Result []APIShieldSchema `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// APIShieldCreateSchemaResponse represents the response from the POST api_gateway/user_schemas endpoint. +type APIShieldCreateSchemaResponse struct { + Result APIShieldCreateSchemaResult `json:"result"` + Response +} + +// APIShieldDeleteSchemaResponse represents the response from the DELETE api_gateway/user_schemas/{id} endpoint. +type APIShieldDeleteSchemaResponse struct { + Result interface{} `json:"result"` + Response +} + +// APIShieldPatchSchemaResponse represents the response from the PATCH api_gateway/user_schemas/{id} endpoint. +type APIShieldPatchSchemaResponse struct { + Result APIShieldSchema `json:"result"` + Response +} + +// APIShieldCreateSchemaResult represents the successful result of creating a schema in Schema Validation 2.0. +type APIShieldCreateSchemaResult struct { + // APIShieldSchema is the schema that was created + Schema APIShieldSchema `json:"schema"` + // APIShieldCreateSchemaEvents are non-critical event logs that occurred during processing. + Events APIShieldCreateSchemaEvents `json:"upload_details"` +} + +// APIShieldCreateSchemaEvents are event logs that occurred during processing. +// +// The logs are separated into levels of severity. +type APIShieldCreateSchemaEvents struct { + Critical *APIShieldCreateSchemaEventWithLocation `json:"critical,omitempty"` + Errors []APIShieldCreateSchemaEventWithLocations `json:"errors,omitempty"` + Warnings []APIShieldCreateSchemaEventWithLocations `json:"warnings,omitempty"` +} + +// APIShieldCreateSchemaEvent is an event log that occurred during processing. +type APIShieldCreateSchemaEvent struct { + // Code identifies the event that occurred + Code uint `json:"code"` + // Message describes the event that occurred + Message string `json:"message"` +} + +// APIShieldCreateSchemaEventWithLocation is an event log that occurred during processing, with the location +// in the schema where the event occurred. +type APIShieldCreateSchemaEventWithLocation struct { + APIShieldCreateSchemaEvent + + // Location is where the event occurred + // See https://goessner.net/articles/JsonPath/ for JSONPath specification. + Location string `json:"location,omitempty"` +} + +// APIShieldCreateSchemaEventWithLocations is an event log that occurred during processing, with the location(s) +// in the schema where the event occurred. +type APIShieldCreateSchemaEventWithLocations struct { + APIShieldCreateSchemaEvent + + // Locations lists JSONPath locations where the event occurred + // See https://goessner.net/articles/JsonPath/ for JSONPath specification + Locations []string `json:"locations"` +} + +func (cse APIShieldCreateSchemaEventWithLocations) String() string { + var s string + s += cse.Message + + if len(cse.Locations) == 0 || (len(cse.Locations) == 1 && cse.Locations[0] == "") { + // append nothing + } else if len(cse.Locations) == 1 { + s += fmt.Sprintf(" (%s)", cse.Locations[0]) + } else { + s += " (multiple locations)" + } + + return s +} + +// GetAPIShieldSchema retrieves information about a specific schema on a zone +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-specific-schema +func (api *API) GetAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params GetAPIShieldSchemaParams) (*APIShieldSchema, error) { + if params.SchemaID == "" { + return nil, fmt.Errorf("schema ID must be provided") + } + + path := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", rc.Identifier, params.SchemaID) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var asResponse APIShieldGetSchemaResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// ListAPIShieldSchemas retrieves all schemas for a zone +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-information-about-all-schemas +func (api *API) ListAPIShieldSchemas(ctx context.Context, rc *ResourceContainer, params ListAPIShieldSchemasParams) ([]APIShieldSchema, ResultInfo, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", rc.Identifier) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var asResponse APIShieldListSchemasResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return asResponse.Result, asResponse.ResultInfo, nil +} + +// CreateAPIShieldSchema uploads a schema to a zone +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-post-schema +func (api *API) CreateAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params CreateAPIShieldSchemaParams) (*APIShieldCreateSchemaResult, error) { + uri := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", rc.Identifier) + + if params.Name == "" { + return nil, fmt.Errorf("name must not be empty") + } + + if params.Source == nil { + return nil, fmt.Errorf("source must not be nil") + } + + // Prepare the form to be submitted + var b bytes.Buffer + w := multipart.NewWriter(&b) + // write fields + if err := w.WriteField("name", params.Name); err != nil { + return nil, fmt.Errorf("error during multi-part form construction: %w", err) + } + if err := w.WriteField("kind", params.Kind); err != nil { + return nil, fmt.Errorf("error during multi-part form construction: %w", err) + } + + if params.ValidationEnabled != nil { + if err := w.WriteField("validation_enabled", strconv.FormatBool(*params.ValidationEnabled)); err != nil { + return nil, fmt.Errorf("error during multi-part form construction: %w", err) + } + } + + // write schema contents + part, err := w.CreateFormFile("file", params.Name) + if err != nil { + return nil, fmt.Errorf("error during multi-part form construction: %w", err) + } + if _, err := io.Copy(part, params.Source); err != nil { + return nil, fmt.Errorf("error during multi-part form construction: %w", err) + } + if err := w.Close(); err != nil { + return nil, fmt.Errorf("error during multi-part form construction: %w", err) + } + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, &b, http.Header{ + "Content-Type": []string{w.FormDataContentType()}, + }) + if err != nil { + return nil, err + } + + var asResponse APIShieldCreateSchemaResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// DeleteAPIShieldSchema deletes a single schema +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-delete-a-schema +func (api *API) DeleteAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params DeleteAPIShieldSchemaParams) error { + if params.SchemaID == "" { + return fmt.Errorf("schema ID must be provided") + } + + uri := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", rc.Identifier, params.SchemaID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var asResponse APIShieldDeleteSchemaResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// UpdateAPIShieldSchema updates certain fields on an existing schema. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-enable-validation-for-a-schema +func (api *API) UpdateAPIShieldSchema(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldSchemaParams) (*APIShieldSchema, error) { + if params.SchemaID == "" { + return nil, fmt.Errorf("schema ID must be provided") + } + + uri := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", rc.Identifier, params.SchemaID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return nil, err + } + + // Result should be the updated schema that was patched + var asResponse APIShieldPatchSchemaResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// Schema Validation Settings + +// APIShieldSchemaValidationSettings represents zone level schema validation settings for +// API Shield Schema Validation 2.0. +type APIShieldSchemaValidationSettings struct { + // DefaultMitigationAction is the mitigation to apply when there is no operation-level + // mitigation action defined + DefaultMitigationAction string `json:"validation_default_mitigation_action" url:"-"` + // OverrideMitigationAction when set, will apply to all requests regardless of + // zone-level/operation-level setting + OverrideMitigationAction *string `json:"validation_override_mitigation_action" url:"-"` +} + +// UpdateAPIShieldSchemaValidationSettingsParams represents the parameters to pass to update certain fields +// on schema validation settings on the zone +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-patch-zone-level-settings +type UpdateAPIShieldSchemaValidationSettingsParams struct { + // DefaultMitigationAction is the mitigation to apply when there is no operation-level + // mitigation action defined + // + // passing a `nil` value will have no effect on this setting + DefaultMitigationAction *string `json:"validation_default_mitigation_action" url:"-"` + + // OverrideMitigationAction when set, will apply to all requests regardless of + // zone-level/operation-level setting + // + // passing a `nil` value will have no effect on this setting + OverrideMitigationAction *string `json:"validation_override_mitigation_action" url:"-"` +} + +// APIShieldSchemaValidationSettingsResponse represents the response from the GET api_gateway/settings/schema_validation endpoint. +type APIShieldSchemaValidationSettingsResponse struct { + Result APIShieldSchemaValidationSettings `json:"result"` + Response +} + +// GetAPIShieldSchemaValidationSettings retrieves zone level schema validation settings +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-zone-level-settings +func (api *API) GetAPIShieldSchemaValidationSettings(ctx context.Context, rc *ResourceContainer) (*APIShieldSchemaValidationSettings, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/settings/schema_validation", rc.Identifier) + + uri := buildURI(path, nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var asResponse APIShieldSchemaValidationSettingsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// UpdateAPIShieldSchemaValidationSettings updates certain fields on zone level schema validation settings +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-patch-zone-level-settings +func (api *API) UpdateAPIShieldSchemaValidationSettings(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldSchemaValidationSettingsParams) (*APIShieldSchemaValidationSettings, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/settings/schema_validation", rc.Identifier) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return nil, err + } + + var asResponse APIShieldSchemaValidationSettingsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// APIShieldOperationSchemaValidationSettings represents operation level schema validation settings for +// API Shield Schema Validation 2.0. +type APIShieldOperationSchemaValidationSettings struct { + // MitigationAction is the mitigation to apply to the operation + MitigationAction *string `json:"mitigation_action" url:"-"` +} + +// GetAPIShieldOperationSchemaValidationSettingsParams represents the parameters to pass to retrieve +// the schema validation settings set on the operation. +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-operation-level-settings +type GetAPIShieldOperationSchemaValidationSettingsParams struct { + // The Operation ID to apply the mitigation action to + OperationID string `url:"-"` +} + +// UpdateAPIShieldOperationSchemaValidationSettings maps operation IDs to APIShieldOperationSchemaValidationSettings +// +// # This can be used to bulk update operations in one call +// +// Example: +// +// UpdateAPIShieldOperationSchemaValidationSettings{ +// "99522293-a505-45e5-bbad-bbc339f5dc40": APIShieldOperationSchemaValidationSettings{ MitigationAction: nil }, +// } +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-update-multiple-operation-level-settings +type UpdateAPIShieldOperationSchemaValidationSettings map[string]APIShieldOperationSchemaValidationSettings + +// APIShieldOperationSchemaValidationSettingsResponse represents the response from the GET api_gateway/operation/{operationID}/schema_validation endpoint. +type APIShieldOperationSchemaValidationSettingsResponse struct { + Result APIShieldOperationSchemaValidationSettings `json:"result"` + Response +} + +// UpdateAPIShieldOperationSchemaValidationSettingsResponse represents the response from the PATCH api_gateway/operations/schema_validation endpoint. +type UpdateAPIShieldOperationSchemaValidationSettingsResponse struct { + Result UpdateAPIShieldOperationSchemaValidationSettings `json:"result"` + Response +} + +// GetAPIShieldOperationSchemaValidationSettings retrieves operation level schema validation settings +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-retrieve-operation-level-settings +func (api *API) GetAPIShieldOperationSchemaValidationSettings(ctx context.Context, rc *ResourceContainer, params GetAPIShieldOperationSchemaValidationSettingsParams) (*APIShieldOperationSchemaValidationSettings, error) { + if params.OperationID == "" { + return nil, fmt.Errorf("operation ID must be provided") + } + + path := fmt.Sprintf("/zones/%s/api_gateway/operations/%s/schema_validation", rc.Identifier, params.OperationID) + + uri := buildURI(path, nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, params) + if err != nil { + return nil, err + } + + var asResponse APIShieldOperationSchemaValidationSettingsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} + +// UpdateAPIShieldOperationSchemaValidationSettings update multiple operation level schema validation settings +// +// API documentation: https://developers.cloudflare.com/api/operations/api-shield-schema-validation-update-multiple-operation-level-settings +func (api *API) UpdateAPIShieldOperationSchemaValidationSettings(ctx context.Context, rc *ResourceContainer, params UpdateAPIShieldOperationSchemaValidationSettings) (*UpdateAPIShieldOperationSchemaValidationSettings, error) { + path := fmt.Sprintf("/zones/%s/api_gateway/operations/schema_validation", rc.Identifier) + + uri := buildURI(path, nil) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return nil, err + } + + var asResponse UpdateAPIShieldOperationSchemaValidationSettingsResponse + err = json.Unmarshal(res, &asResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &asResponse.Result, nil +} diff --git a/pkg/cloudflare-go/api_shield_schemas_test.go b/pkg/cloudflare-go/api_shield_schemas_test.go new file mode 100644 index 000000000..00514eaa6 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_schemas_test.go @@ -0,0 +1,669 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAPIShieldSchemaId = "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e" + +func TestGetAPIShieldSchema(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", testZoneID, testAPIShieldSchemaId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e", + "name": "petstore.json", + "kind": "openapi_v3", + "created_at": "2023-03-02T15:46:06.000000Z", + "validation_enabled": true, + "source": "{}" + } + }` + + tests := []struct { + name string + getParams GetAPIShieldSchemaParams + expectedParams url.Values + }{ + { + name: "without omit source", + getParams: GetAPIShieldSchemaParams{ + SchemaID: testAPIShieldSchemaId, + }, + expectedParams: url.Values{}, + }, + { + name: "with omit source=true", + getParams: GetAPIShieldSchemaParams{ + SchemaID: testAPIShieldSchemaId, + OmitSource: BoolPtr(true), + }, + expectedParams: url.Values{ + "omit_source": []string{"true"}, + }, + }, + { + name: "with omit source=false", + getParams: GetAPIShieldSchemaParams{ + SchemaID: testAPIShieldSchemaId, + OmitSource: BoolPtr(false), + }, + expectedParams: url.Values{ + "omit_source": []string{"false"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldSchema( + context.Background(), + ZoneIdentifier(testZoneID), + test.getParams, + ) + + createdAt := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := &APIShieldSchema{ + ID: testAPIShieldSchemaId, + Name: "petstore.json", + Kind: "openapi_v3", + Source: "{}", + CreatedAt: &createdAt, + ValidationEnabled: true, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + }) + } +} + +func TestListAPIShieldSchemas(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": [ + { + "schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e", + "name": "petstore.json", + "kind": "openapi_v3", + "created_at": "2023-03-02T15:46:06.000000Z", + "validation_enabled": true, + "source": "{}" + } + ], + "result_info": { + "page": 3, + "per_page": 20, + "count": 1, + "total_count": 2000 + } + }` + + tests := []struct { + name string + getParams ListAPIShieldSchemasParams + expectedParams url.Values + }{ + { + name: "with empty params", + getParams: ListAPIShieldSchemasParams{}, + expectedParams: url.Values{}, + }, + { + name: "all params", + getParams: ListAPIShieldSchemasParams{ + OmitSource: BoolPtr(true), + ValidationEnabled: BoolPtr(false), + PaginationOptions: PaginationOptions{ + Page: 1, + PerPage: 25, + }, + }, + expectedParams: url.Values{ + "omit_source": []string{"true"}, + "validation_enabled": []string{"false"}, + "page": []string{"1"}, + "per_page": []string{"25"}, + }, + }, + { + name: "with omit source", + getParams: ListAPIShieldSchemasParams{ + OmitSource: BoolPtr(true), + }, + expectedParams: url.Values{ + "omit_source": []string{"true"}, + }, + }, + { + name: "with validation enabled", + getParams: ListAPIShieldSchemasParams{ + ValidationEnabled: BoolPtr(true), + }, + expectedParams: url.Values{ + "validation_enabled": []string{"true"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Equal(t, test.expectedParams, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, resultInfo, err := client.ListAPIShieldSchemas( + context.Background(), + ZoneIdentifier(testZoneID), + test.getParams, + ) + + createdAt := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := []APIShieldSchema{ + { + ID: testAPIShieldSchemaId, + Name: "petstore.json", + Kind: "openapi_v3", + Source: "{}", + CreatedAt: &createdAt, + ValidationEnabled: true, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + + expectedResultInfo := ResultInfo{ + Page: 3, + PerPage: 20, + Count: 1, + Total: 2000, + } + assert.Equal(t, expectedResultInfo, resultInfo) + } + }) + } +} + +func TestDeleteAPIShieldSchema(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", testZoneID, testAPIShieldSchemaId) + response := `{"result":{},"success":true,"errors":[],"messages":[]}` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + err := client.DeleteAPIShieldSchema( + context.Background(), + ZoneIdentifier(testZoneID), + DeleteAPIShieldSchemaParams{ + SchemaID: testAPIShieldSchemaId, + }, + ) + + assert.NoError(t, err) +} + +func TestUpdateAPIShieldSchema(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas/%s", testZoneID, testAPIShieldSchemaId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e", + "name": "petstore.json", + "kind": "openapi_v3", + "validation_enabled": true + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + require.Empty(t, r.URL.Query()) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, `{"validation_enabled":true}`, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.UpdateAPIShieldSchema( + context.Background(), + ZoneIdentifier(testZoneID), + UpdateAPIShieldSchemaParams{ + SchemaID: testAPIShieldSchemaId, + ValidationEnabled: BoolPtr(true), + }, + ) + + // patch result is a cut down representation of the schema + // so metadata like created date is not populated + expected := &APIShieldSchema{ + ID: testAPIShieldSchemaId, + Name: "petstore.json", + Kind: "openapi_v3", + ValidationEnabled: true, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } + + assert.NoError(t, err) +} + +func TestCreateAPIShieldSchema(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "schema": { + "schema_id": "0f05e4fc-1c36-4bd2-a5e7-b29d32d3dc8e", + "name": "petstore.json", + "kind": "openapi_v3", + "created_at": "2023-03-02T15:46:06.000000Z", + "validation_enabled": true, + "source": "{}" + }, + "upload_details": { + "warnings": [ + { + "code": 28, + "message": "unsupported media type: application/octet-stream", + "locations": [ + ".paths[\"/user/{username}\"].put" + ] + } + ] + } + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + // Check that form data has been sent correctly. + mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + require.NoError(t, err) + require.True(t, strings.HasPrefix(mediaType, "multipart/form-data")) + + expectedParts := []struct { + partKeys map[string]string + data []byte + }{ + { + partKeys: map[string]string{"name": "name"}, + data: []byte("petstore.json"), + }, + { + partKeys: map[string]string{"name": "kind"}, + data: []byte("openapi_v3"), + }, + { + partKeys: map[string]string{"name": "validation_enabled"}, + data: []byte("true"), + }, + { + partKeys: map[string]string{"filename": "petstore.json", "name": "file"}, + data: []byte("{}"), + }, + } + + multiPartReader := multipart.NewReader(r.Body, params["boundary"]) + partNum := 0 + for { + part, err := multiPartReader.NextPart() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + + // more parts found - this is unexpected + require.Less(t, partNum, len(expectedParts)) + + value, err := io.ReadAll(part) + require.NoError(t, err) + + _, dParams, err := mime.ParseMediaType(part.Header.Get("Content-Disposition")) + require.NoError(t, err) + + require.Equal(t, expectedParts[partNum].partKeys, dParams) + require.Equal(t, expectedParts[partNum].data, value) + partNum++ + } + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.CreateAPIShieldSchema( + context.Background(), + ZoneIdentifier(testZoneID), + CreateAPIShieldSchemaParams{ + Name: "petstore.json", + Kind: "openapi_v3", + Source: strings.NewReader("{}"), + ValidationEnabled: BoolPtr(true), + }, + ) + + createdAt := time.Date(2023, time.March, 2, 15, 46, 6, 0, time.UTC) + expected := &APIShieldCreateSchemaResult{ + Schema: APIShieldSchema{ + ID: testAPIShieldSchemaId, + Name: "petstore.json", + Kind: "openapi_v3", + Source: "{}", + CreatedAt: &createdAt, + ValidationEnabled: true, + }, + Events: APIShieldCreateSchemaEvents{ + Warnings: []APIShieldCreateSchemaEventWithLocations{ + { + APIShieldCreateSchemaEvent: APIShieldCreateSchemaEvent{ + Code: 28, + Message: "unsupported media type: application/octet-stream", + }, + Locations: []string{ + ".paths[\"/user/{username}\"].put", + }, + }, + }, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestCreateAPIShieldSchemaError(t *testing.T) { + setup() + t.Cleanup(teardown) + + endpoint := fmt.Sprintf("/zones/%s/api_gateway/user_schemas", testZoneID) + response := `{ + "success" : false, + "errors": [ + { + "code": 21400, + "message": "only default parameter styles are supported: path, form (.paths[\"/pet/{petId}\"].get.parameters[0])" + } + ], + "messages": [], + "result": { + "upload_details": { + "errors": [ + { + "code": 22, + "message": "only default parameter styles are supported: path, form", + "locations": [ + ".paths[\"/pet/{petId}\"].get.parameters[0]" + ] + } + ] + } + } + }` + + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + w.WriteHeader(400) + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + _, err := client.CreateAPIShieldSchema( + context.Background(), + ZoneIdentifier(testZoneID), + CreateAPIShieldSchemaParams{ + Name: "petstore.json", + Kind: "openapi_v3", + Source: strings.NewReader("{}"), + ValidationEnabled: BoolPtr(true), + }, + ) + + require.ErrorContains(t, err, "only default parameter styles are supported: path, form (.paths[\"/pet/{petId}\"].get.parameters[0]) (21400)") +} + +func TestMustProvideSchemaID(t *testing.T) { + setup() + t.Cleanup(teardown) + + _, err := client.GetAPIShieldSchema(context.Background(), ZoneIdentifier(testZoneID), GetAPIShieldSchemaParams{}) + require.ErrorContains(t, err, "schema ID must be provided") + + _, err = client.UpdateAPIShieldSchema(context.Background(), ZoneIdentifier(testZoneID), UpdateAPIShieldSchemaParams{}) + require.ErrorContains(t, err, "schema ID must be provided") + + err = client.DeleteAPIShieldSchema(context.Background(), ZoneIdentifier(testZoneID), DeleteAPIShieldSchemaParams{}) + require.ErrorContains(t, err, "schema ID must be provided") +} + +func TestGetAPIShieldSchemaValidationSettings(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/settings/schema_validation", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "validation_default_mitigation_action": "log", + "validation_override_mitigation_action": "none" + } + }` + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldSchemaValidationSettings( + context.Background(), + ZoneIdentifier(testZoneID), + ) + + none := "none" + expected := &APIShieldSchemaValidationSettings{ + DefaultMitigationAction: "log", + OverrideMitigationAction: &none, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestUpdateAPIShieldSchemaValidationSettings(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/settings/schema_validation", testZoneID) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "validation_default_mitigation_action": "log", + "validation_override_mitigation_action": null + } + }` + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.UpdateAPIShieldSchemaValidationSettings( + context.Background(), + ZoneIdentifier(testZoneID), + UpdateAPIShieldSchemaValidationSettingsParams{}, // specifying nil is ok for fields + ) + + expected := &APIShieldSchemaValidationSettings{ + DefaultMitigationAction: "log", + OverrideMitigationAction: nil, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestGetAPIShieldOperationSchemaValidationSettings(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/%s/schema_validation", testZoneID, testAPIShieldOperationId) + response := `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "mitigation_action": "log" + } + }` + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + require.Empty(t, r.URL.Query()) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.GetAPIShieldOperationSchemaValidationSettings( + context.Background(), + ZoneIdentifier(testZoneID), + GetAPIShieldOperationSchemaValidationSettingsParams{OperationID: testAPIShieldOperationId}, + ) + + log := "log" + expected := &APIShieldOperationSchemaValidationSettings{ + MitigationAction: &log, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} + +func TestUpdateAPIShieldOperationSchemaValidationSettings(t *testing.T) { + endpoint := fmt.Sprintf("/zones/%s/api_gateway/operations/schema_validation", testZoneID) + response := fmt.Sprintf(`{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "%s": null + } + }`, testAPIShieldOperationId) + + setup() + t.Cleanup(teardown) + handler := func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + require.Empty(t, r.URL.Query()) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + expected := fmt.Sprintf(`{"%s":{"mitigation_action":null}}`, testAPIShieldOperationId) + require.Equal(t, expected, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + + mux.HandleFunc(endpoint, handler) + + actual, err := client.UpdateAPIShieldOperationSchemaValidationSettings( + context.Background(), + ZoneIdentifier(testZoneID), + UpdateAPIShieldOperationSchemaValidationSettings{ + testAPIShieldOperationId: APIShieldOperationSchemaValidationSettings{ + MitigationAction: nil, + }, + }, + ) + + expected := &UpdateAPIShieldOperationSchemaValidationSettings{ + testAPIShieldOperationId: APIShieldOperationSchemaValidationSettings{ + MitigationAction: nil, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, expected, actual) + } +} diff --git a/pkg/cloudflare-go/api_shield_test.go b/pkg/cloudflare-go/api_shield_test.go new file mode 100644 index 000000000..9644ad7b0 --- /dev/null +++ b/pkg/cloudflare-go/api_shield_test.go @@ -0,0 +1,84 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetAPIShield(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "auth_id_characteristics": [ + { + "type": "header", + "name": "test-header" + }, + { + "type": "cookie", + "name": "test-cookie" + } + ] + } +} + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/api_gateway/configuration", handler) + + var authChars []AuthIdCharacteristics + authChars = append(authChars, AuthIdCharacteristics{Type: "header", Name: "test-header"}) + authChars = append(authChars, AuthIdCharacteristics{Type: "cookie", Name: "test-cookie"}) + + want := APIShield{ + AuthIdCharacteristics: authChars, + } + + actual, _, err := client.GetAPIShieldConfiguration(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestPutAPIShield(t *testing.T) { + setup() + defer teardown() + + // now lets do a PUT + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success" : true, + "errors": [], + "messages": [], + "result": [] +} + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/api_gateway/configuration", handler) + + apiShieldData := UpdateAPIShieldParams{AuthIdCharacteristics: []AuthIdCharacteristics{{Type: "header", Name: "different-header"}, {Type: "cookie", Name: "different-cookie"}}} + + want := Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}} + + actual, err := client.UpdateAPIShieldConfiguration(context.Background(), ZoneIdentifier(testZoneID), apiShieldData) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/api_token.go b/pkg/cloudflare-go/api_token.go new file mode 100644 index 000000000..92554774d --- /dev/null +++ b/pkg/cloudflare-go/api_token.go @@ -0,0 +1,238 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// APIToken is the full API token. +type APIToken struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + IssuedOn *time.Time `json:"issued_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + NotBefore *time.Time `json:"not_before,omitempty"` + ExpiresOn *time.Time `json:"expires_on,omitempty"` + Policies []APITokenPolicies `json:"policies,omitempty"` + Condition *APITokenCondition `json:"condition,omitempty"` + Value string `json:"value,omitempty"` +} + +// APITokenPermissionGroups is the permission groups associated with API tokens. +type APITokenPermissionGroups struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// APITokenPolicies are policies attached to an API token. +type APITokenPolicies struct { + ID string `json:"id,omitempty"` + Effect string `json:"effect"` + Resources map[string]interface{} `json:"resources"` + PermissionGroups []APITokenPermissionGroups `json:"permission_groups"` +} + +// APITokenRequestIPCondition is the struct for adding an IP restriction to an +// API token. +type APITokenRequestIPCondition struct { + In []string `json:"in,omitempty"` + NotIn []string `json:"not_in,omitempty"` +} + +// APITokenCondition is the outer structure for request conditions (currently +// only IPs). +type APITokenCondition struct { + RequestIP *APITokenRequestIPCondition `json:"request.ip,omitempty"` +} + +// APITokenResponse is the API response for a single API token. +type APITokenResponse struct { + Response + Result APIToken `json:"result"` +} + +// APITokenListResponse is the API response for multiple API tokens. +type APITokenListResponse struct { + Response + Result []APIToken `json:"result"` +} + +// APITokenRollResponse is the API response when rolling the token. +type APITokenRollResponse struct { + Response + Result string `json:"result"` +} + +// APITokenVerifyResponse is the API response for verifying a token. +type APITokenVerifyResponse struct { + Response + Result APITokenVerifyBody `json:"result"` +} + +// APITokenPermissionGroupsResponse is the API response for the available +// permission groups. +type APITokenPermissionGroupsResponse struct { + Response + Result []APITokenPermissionGroups `json:"result"` +} + +// APITokenVerifyBody is the API body for verifying a token. +type APITokenVerifyBody struct { + ID string `json:"id"` + Status string `json:"status"` + NotBefore time.Time `json:"not_before"` + ExpiresOn time.Time `json:"expires_on"` +} + +// GetAPIToken returns a single API token based on the ID. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-token-details +func (api *API) GetAPIToken(ctx context.Context, tokenID string) (APIToken, error) { + uri := fmt.Sprintf("/user/tokens/%s", tokenID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return APIToken{}, err + } + + var apiTokenResponse APITokenResponse + err = json.Unmarshal(res, &apiTokenResponse) + if err != nil { + return APIToken{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return apiTokenResponse.Result, nil +} + +// APITokens returns all available API tokens. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-list-tokens +func (api *API) APITokens(ctx context.Context) ([]APIToken, error) { + res, err := api.makeRequestContext(ctx, http.MethodGet, "/user/tokens", nil) + if err != nil { + return []APIToken{}, err + } + + var apiTokenListResponse APITokenListResponse + err = json.Unmarshal(res, &apiTokenListResponse) + if err != nil { + return []APIToken{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return apiTokenListResponse.Result, nil +} + +// CreateAPIToken creates a new token. Returns the API token that has been +// generated. +// +// The token value itself is only shown once (post create) and will present as +// `Value` from this method. If you fail to capture it at this point, you will +// need to roll the token in order to get a new value. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-create-token +func (api *API) CreateAPIToken(ctx context.Context, token APIToken) (APIToken, error) { + res, err := api.makeRequestContext(ctx, http.MethodPost, "/user/tokens", token) + if err != nil { + return APIToken{}, err + } + + var createTokenAPIResponse APITokenResponse + err = json.Unmarshal(res, &createTokenAPIResponse) + if err != nil { + return APIToken{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return createTokenAPIResponse.Result, nil +} + +// UpdateAPIToken updates an existing API token. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-update-token +func (api *API) UpdateAPIToken(ctx context.Context, tokenID string, token APIToken) (APIToken, error) { + res, err := api.makeRequestContext(ctx, http.MethodPut, "/user/tokens/"+tokenID, token) + if err != nil { + return APIToken{}, err + } + + var updatedTokenResponse APITokenResponse + err = json.Unmarshal(res, &updatedTokenResponse) + if err != nil { + return APIToken{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return updatedTokenResponse.Result, nil +} + +// RollAPIToken rolls the credential associated with the token. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-roll-token +func (api *API) RollAPIToken(ctx context.Context, tokenID string) (string, error) { + uri := fmt.Sprintf("/user/tokens/%s/value", tokenID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, nil) + if err != nil { + return "", err + } + + var apiTokenRollResponse APITokenRollResponse + err = json.Unmarshal(res, &apiTokenRollResponse) + if err != nil { + return "", fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return apiTokenRollResponse.Result, nil +} + +// VerifyAPIToken tests the validity of the token. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-verify-token +func (api *API) VerifyAPIToken(ctx context.Context) (APITokenVerifyBody, error) { + res, err := api.makeRequestContext(ctx, http.MethodGet, "/user/tokens/verify", nil) + if err != nil { + return APITokenVerifyBody{}, err + } + + var apiTokenVerifyResponse APITokenVerifyResponse + err = json.Unmarshal(res, &apiTokenVerifyResponse) + if err != nil { + return APITokenVerifyBody{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return apiTokenVerifyResponse.Result, nil +} + +// DeleteAPIToken deletes a single API token. +// +// API reference: https://api.cloudflare.com/#user-api-tokens-delete-token +func (api *API) DeleteAPIToken(ctx context.Context, tokenID string) error { + _, err := api.makeRequestContext(ctx, http.MethodDelete, "/user/tokens/"+tokenID, nil) + if err != nil { + return err + } + + return nil +} + +// ListAPITokensPermissionGroups returns all available API token permission groups. +// +// API reference: https://api.cloudflare.com/#permission-groups-list-permission-groups +func (api *API) ListAPITokensPermissionGroups(ctx context.Context) ([]APITokenPermissionGroups, error) { + var r APITokenPermissionGroupsResponse + res, err := api.makeRequestContext(ctx, http.MethodGet, "/user/tokens/permission_groups", nil) + if err != nil { + return []APITokenPermissionGroups{}, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []APITokenPermissionGroups{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} diff --git a/pkg/cloudflare-go/api_token_test.go b/pkg/cloudflare-go/api_token_test.go new file mode 100644 index 000000000..a48759832 --- /dev/null +++ b/pkg/cloudflare-go/api_token_test.go @@ -0,0 +1,522 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAPITokens(t *testing.T) { + setup() + defer teardown() + + issuedOn, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-07-02T05:20:00Z") + notBefore, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2020-01-01T00:00:00Z") + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "ed17574386854bf78a67040be0a770b0", + "name": "readonly token", + "status": "active", + "issued_on": "2018-07-01T05:20:00Z", + "modified_on": "2018-07-02T05:20:00Z", + "not_before": "2018-07-01T05:20:00Z", + "expires_on": "2020-01-01T00:00:00Z", + "policies": [ + { + "id": "f267e341f3dd4697bd3b9f71dd96247f", + "effect": "allow", + "resources": { + "com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4": "*", + "com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43": "*" + }, + "permission_groups": [ + { + "id": "c8fed203ed3043cba015a93ad1616f1f", + "name": "Zone Read" + }, + { + "id": "82e64a83756745bbbb1c9c2701bf816b", + "name": "DNS Read" + } + ] + } + ], + "condition": { + "request.ip": { + "in": [ + "192.0.2.0/24", + "2001:db8::/48" + ], + "not_in": [ + "198.51.100.0/24", + "2001:db8:1::/48" + ] + } + } + } + ] + }`) + } + + mux.HandleFunc("/user/tokens", handler) + + resources := make(map[string]interface{}) + resources["com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4"] = "*" + resources["com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43"] = "*" + + expectedAPIToken := APIToken{ + ID: "ed17574386854bf78a67040be0a770b0", + Name: "readonly token", + Status: "active", + IssuedOn: &issuedOn, + ModifiedOn: &modifiedOn, + NotBefore: ¬Before, + ExpiresOn: &expiresOn, + Policies: []APITokenPolicies{{ + ID: "f267e341f3dd4697bd3b9f71dd96247f", + Effect: "allow", + Resources: resources, + PermissionGroups: []APITokenPermissionGroups{ + { + ID: "c8fed203ed3043cba015a93ad1616f1f", + Name: "Zone Read", + }, + { + ID: "82e64a83756745bbbb1c9c2701bf816b", + Name: "DNS Read", + }, + }, + }}, + Condition: &APITokenCondition{ + RequestIP: &APITokenRequestIPCondition{ + In: []string{"192.0.2.0/24", "2001:db8::/48"}, + NotIn: []string{"198.51.100.0/24", "2001:db8:1::/48"}, + }, + }, + } + + actual, err := client.APITokens(context.Background()) + + if assert.NoError(t, err) { + assert.Equal(t, []APIToken{expectedAPIToken}, actual) + } +} + +func TestGetAPIToken(t *testing.T) { + setup() + defer teardown() + + issuedOn, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-07-02T05:20:00Z") + notBefore, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2020-01-01T00:00:00Z") + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ed17574386854bf78a67040be0a770b0", + "name": "readonly token", + "status": "active", + "issued_on": "2018-07-01T05:20:00Z", + "modified_on": "2018-07-02T05:20:00Z", + "not_before": "2018-07-01T05:20:00Z", + "expires_on": "2020-01-01T00:00:00Z", + "policies": [ + { + "id": "f267e341f3dd4697bd3b9f71dd96247f", + "effect": "allow", + "resources": { + "com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4": "*", + "com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43": "*" + }, + "permission_groups": [ + { + "id": "c8fed203ed3043cba015a93ad1616f1f", + "name": "Zone Read" + }, + { + "id": "82e64a83756745bbbb1c9c2701bf816b", + "name": "DNS Read" + } + ] + } + ], + "condition": { + "request.ip": { + "in": [ + "192.0.2.0/24", + "2001:db8::/48" + ], + "not_in": [ + "198.51.100.0/24", + "2001:db8:1::/48" + ] + } + } + } + }`) + } + + mux.HandleFunc("/user/tokens/ed17574386854bf78a67040be0a770b0", handler) + + resources := make(map[string]interface{}) + resources["com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4"] = "*" + resources["com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43"] = "*" + + expectedAPIToken := APIToken{ + ID: "ed17574386854bf78a67040be0a770b0", + Name: "readonly token", + Status: "active", + IssuedOn: &issuedOn, + ModifiedOn: &modifiedOn, + NotBefore: ¬Before, + ExpiresOn: &expiresOn, + Policies: []APITokenPolicies{{ + ID: "f267e341f3dd4697bd3b9f71dd96247f", + Effect: "allow", + Resources: resources, + PermissionGroups: []APITokenPermissionGroups{ + { + ID: "c8fed203ed3043cba015a93ad1616f1f", + Name: "Zone Read", + }, + { + ID: "82e64a83756745bbbb1c9c2701bf816b", + Name: "DNS Read", + }, + }, + }}, + Condition: &APITokenCondition{ + RequestIP: &APITokenRequestIPCondition{ + In: []string{"192.0.2.0/24", "2001:db8::/48"}, + NotIn: []string{"198.51.100.0/24", "2001:db8:1::/48"}, + }, + }, + } + + actual, err := client.GetAPIToken(context.Background(), "ed17574386854bf78a67040be0a770b0") + + if assert.NoError(t, err) { + assert.Equal(t, expectedAPIToken, actual) + } +} + +func TestCreateAPIToken(t *testing.T) { + setup() + defer teardown() + + // issuedOn, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + // modifiedOn, _ := time.Parse(time.RFC3339, "2018-07-02T05:20:00Z") + notBefore, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2020-01-01T00:00:00Z") + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"ed17574386854bf78a67040be0a770b0", + "name":"readonly token", + "status":"active", + "issued_on":"2018-07-01T05:20:00Z", + "modified_on":"2018-07-02T05:20:00Z", + "not_before":"2018-07-01T05:20:00Z", + "expires_on":"2020-01-01T00:00:00Z", + "policies":[ + { + "id":"f267e341f3dd4697bd3b9f71dd96247f", + "effect":"allow", + "resources":{ + "com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4":"*", + "com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43":"*" + }, + "permission_groups":[ + { + "id":"c8fed203ed3043cba015a93ad1616f1f", + "name":"Zone Read" + }, + { + "id":"82e64a83756745bbbb1c9c2701bf816b", + "name":"DNS Read" + } + ] + } + ] + } + }`) + } + + mux.HandleFunc("/user/tokens", handler) + + resources := make(map[string]interface{}) + resources["com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4"] = "*" + resources["com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43"] = "*" + + tokenToCreate := APIToken{ + Name: "readonly token", + NotBefore: ¬Before, + ExpiresOn: &expiresOn, + Policies: []APITokenPolicies{{ + ID: "f267e341f3dd4697bd3b9f71dd96247f", + Effect: "allow", + Resources: resources, + PermissionGroups: []APITokenPermissionGroups{ + { + ID: "c8fed203ed3043cba015a93ad1616f1f", + Name: "Zone Read", + }, + { + ID: "82e64a83756745bbbb1c9c2701bf816b", + Name: "DNS Read", + }, + }, + }}, + } + + actual, err := client.CreateAPIToken(context.Background(), tokenToCreate) + + if assert.NoError(t, err) { + assert.Equal(t, tokenToCreate.Name, actual.Name) + assert.Equal(t, tokenToCreate.Policies, actual.Policies) + } +} + +func TestUpdateAPIToken(t *testing.T) { + setup() + defer teardown() + + issuedOn, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-07-02T05:20:00Z") + notBefore, _ := time.Parse(time.RFC3339, "2018-07-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2020-01-01T00:00:00Z") + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ed17574386854bf78a67040be0a770b0", + "name": "readonly token", + "status": "active", + "issued_on": "2018-07-01T05:20:00Z", + "modified_on": "2018-07-02T05:20:00Z", + "not_before": "2018-07-01T05:20:00Z", + "expires_on": "2020-01-01T00:00:00Z", + "policies": [ + { + "id": "f267e341f3dd4697bd3b9f71dd96247f", + "effect": "allow", + "resources": { + "com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4": "*", + "com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43": "*" + }, + "permission_groups": [ + { + "id": "c8fed203ed3043cba015a93ad1616f1f", + "name": "Zone Read" + }, + { + "id": "82e64a83756745bbbb1c9c2701bf816b", + "name": "DNS Read" + } + ] + } + ], + "condition": { + "request.ip": { + "in": [ + "198.51.100.0/24", + "2400:cb00::/32" + ], + "not_in": [ + "198.51.100.0/24", + "2400:cb00::/32" + ] + } + } + } +}`) + } + + mux.HandleFunc("/user/tokens/ed17574386854bf78a67040be0a770b0", handler) + + resources := make(map[string]interface{}) + resources["com.cloudflare.api.account.zone.eb78d65290b24279ba6f44721b3ea3c4"] = "*" + resources["com.cloudflare.api.account.zone.22b1de5f1c0e4b3ea97bb1e963b06a43"] = "*" + + expectedAPIToken := APIToken{ + ID: "ed17574386854bf78a67040be0a770b0", + Name: "readonly token", + Status: "active", + IssuedOn: &issuedOn, + ModifiedOn: &modifiedOn, + NotBefore: ¬Before, + ExpiresOn: &expiresOn, + Policies: []APITokenPolicies{{ + ID: "f267e341f3dd4697bd3b9f71dd96247f", + Effect: "allow", + Resources: resources, + PermissionGroups: []APITokenPermissionGroups{ + { + ID: "c8fed203ed3043cba015a93ad1616f1f", + Name: "Zone Read", + }, + { + ID: "82e64a83756745bbbb1c9c2701bf816b", + Name: "DNS Read", + }, + }, + }, + }, + Condition: &APITokenCondition{ + RequestIP: &APITokenRequestIPCondition{ + In: []string{"198.51.100.0/24", "2400:cb00::/32"}, + NotIn: []string{"198.51.100.0/24", "2400:cb00::/32"}, + }, + }, + } + + actual, err := client.UpdateAPIToken(context.Background(), "ed17574386854bf78a67040be0a770b0", APIToken{}) + + if assert.NoError(t, err) { + assert.Equal(t, expectedAPIToken, actual) + } +} + +func TestRollAPIToken(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": "8M7wS6hCpXVc-DoRnPPY_UCWPgy8aea4Wy6kCe5T" + } + `) + } + + mux.HandleFunc("/user/tokens/ed17574386854bf78a67040be0a770b0/value", handler) + + actual, err := client.RollAPIToken(context.Background(), "ed17574386854bf78a67040be0a770b0") + + if assert.NoError(t, err) { + assert.Equal(t, "8M7wS6hCpXVc-DoRnPPY_UCWPgy8aea4Wy6kCe5T", actual) + } +} + +func TestVerifyAPIToken(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ed17574386854bf78a67040be0a770b0", + "status": "active", + "not_before": "2018-07-01T05:20:00Z", + "expires_on": "2020-01-01T00:00:00Z" + } + }`) + } + + mux.HandleFunc("/user/tokens/verify", handler) + + actual, err := client.VerifyAPIToken(context.Background()) + + if assert.NoError(t, err) { + assert.Equal(t, "active", actual.Status) + } +} + +func TestDeleteAPIToken(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac" + } + }`) + } + + mux.HandleFunc("/user/tokens/ed17574386854bf78a67040be0a770b0", handler) + + err := client.DeleteAPIToken(context.Background(), "ed17574386854bf78a67040be0a770b0") + assert.NoError(t, err) +} + +func TestListAPITokensPermissionGroups(t *testing.T) { + setup() + defer teardown() + + var pgID = "47aa30b6eb97ecae0518b750d6b142b6" + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": %q, + "name": "DNS Read", + "scopes": ["com.cloudflare.api.account.zone"] + } + ] + } + `, pgID) + } + + mux.HandleFunc("/user/tokens/permission_groups", handler) + want := []APITokenPermissionGroups{{ + ID: pgID, + Name: "DNS Read", + Scopes: []string{"com.cloudflare.api.account.zone"}, + }} + actual, err := client.ListAPITokensPermissionGroups(context.Background()) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/argo.go b/pkg/cloudflare-go/argo.go new file mode 100644 index 000000000..d789257a4 --- /dev/null +++ b/pkg/cloudflare-go/argo.go @@ -0,0 +1,121 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var validSettingValues = []string{"on", "off"} + +// ArgoFeatureSetting is the structure of the API object for the +// argo smart routing and tiered caching settings. +type ArgoFeatureSetting struct { + Editable bool `json:"editable,omitempty"` + ID string `json:"id,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + Value string `json:"value"` +} + +// ArgoDetailsResponse is the API response for the argo smart routing +// and tiered caching response. +type ArgoDetailsResponse struct { + Result ArgoFeatureSetting `json:"result"` + Response +} + +// ArgoSmartRouting returns the current settings for smart routing. +// +// API reference: https://api.cloudflare.com/#argo-smart-routing-get-argo-smart-routing-setting +func (api *API) ArgoSmartRouting(ctx context.Context, zoneID string) (ArgoFeatureSetting, error) { + uri := fmt.Sprintf("/zones/%s/argo/smart_routing", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ArgoFeatureSetting{}, err + } + + var argoDetailsResponse ArgoDetailsResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return ArgoFeatureSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// UpdateArgoSmartRouting updates the setting for smart routing. +// +// API reference: https://api.cloudflare.com/#argo-smart-routing-patch-argo-smart-routing-setting +func (api *API) UpdateArgoSmartRouting(ctx context.Context, zoneID, settingValue string) (ArgoFeatureSetting, error) { + if !contains(validSettingValues, settingValue) { + return ArgoFeatureSetting{}, fmt.Errorf("invalid setting value '%s'. must be 'on' or 'off'", settingValue) + } + + uri := fmt.Sprintf("/zones/%s/argo/smart_routing", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, ArgoFeatureSetting{Value: settingValue}) + if err != nil { + return ArgoFeatureSetting{}, err + } + + var argoDetailsResponse ArgoDetailsResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return ArgoFeatureSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// ArgoTieredCaching returns the current settings for tiered caching. +// +// API reference: TBA. +func (api *API) ArgoTieredCaching(ctx context.Context, zoneID string) (ArgoFeatureSetting, error) { + uri := fmt.Sprintf("/zones/%s/argo/tiered_caching", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ArgoFeatureSetting{}, err + } + + var argoDetailsResponse ArgoDetailsResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return ArgoFeatureSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// UpdateArgoTieredCaching updates the setting for tiered caching. +// +// API reference: TBA. +func (api *API) UpdateArgoTieredCaching(ctx context.Context, zoneID, settingValue string) (ArgoFeatureSetting, error) { + if !contains(validSettingValues, settingValue) { + return ArgoFeatureSetting{}, fmt.Errorf("invalid setting value '%s'. must be 'on' or 'off'", settingValue) + } + + uri := fmt.Sprintf("/zones/%s/argo/tiered_caching", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, ArgoFeatureSetting{Value: settingValue}) + if err != nil { + return ArgoFeatureSetting{}, err + } + + var argoDetailsResponse ArgoDetailsResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return ArgoFeatureSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/pkg/cloudflare-go/argo_example_test.go b/pkg/cloudflare-go/argo_example_test.go new file mode 100644 index 000000000..720b6de47 --- /dev/null +++ b/pkg/cloudflare-go/argo_example_test.go @@ -0,0 +1,65 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ArgoSmartRouting() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + smartRoutingSettings, err := api.ArgoSmartRouting(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("smart routing is %s", smartRoutingSettings.Value) +} + +func ExampleAPI_ArgoTieredCaching() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + tieredCachingSettings, err := api.ArgoTieredCaching(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("tiered caching is %s", tieredCachingSettings.Value) +} + +func ExampleAPI_UpdateArgoSmartRouting() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + smartRoutingSettings, err := api.UpdateArgoSmartRouting(context.Background(), "01a7362d577a6c3019a474fd6f485823", "on") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("smart routing is %s", smartRoutingSettings.Value) +} + +func ExampleAPI_UpdateArgoTieredCaching() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + tieredCachingSettings, err := api.UpdateArgoTieredCaching(context.Background(), "01a7362d577a6c3019a474fd6f485823", "on") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("tiered caching is %s", tieredCachingSettings.Value) +} diff --git a/pkg/cloudflare-go/argo_test.go b/pkg/cloudflare-go/argo_test.go new file mode 100644 index 000000000..8f5f28507 --- /dev/null +++ b/pkg/cloudflare-go/argo_test.go @@ -0,0 +1,179 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var argoTimestamp, _ = time.Parse(time.RFC3339Nano, "2019-02-20T22:37:07.107449Z") + +func TestArgoSmartRouting(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "smart_routing", + "value": "on", + "editable": true, + "modified_on": "2019-02-20T22:37:07.107449Z" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/argo/smart_routing", handler) + want := ArgoFeatureSetting{ + ID: "smart_routing", + Value: "on", + Editable: true, + ModifiedOn: argoTimestamp, + } + + actual, err := client.ArgoSmartRouting(context.Background(), "01a7362d577a6c3019a474fd6f485823") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateArgoSmartRouting(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "smart_routing", + "value": "off", + "editable": true, + "modified_on": "2019-02-20T22:37:07.107449Z" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/argo/smart_routing", handler) + want := ArgoFeatureSetting{ + ID: "smart_routing", + Value: "off", + Editable: true, + ModifiedOn: argoTimestamp, + } + + actual, err := client.UpdateArgoSmartRouting(context.Background(), "01a7362d577a6c3019a474fd6f485823", "off") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateArgoSmartRoutingWithInvalidValue(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateArgoSmartRouting(context.Background(), "01a7362d577a6c3019a474fd6f485823", "notreal") + + if assert.Error(t, err) { + assert.Equal(t, "invalid setting value 'notreal'. must be 'on' or 'off'", err.Error()) + } +} + +func TestArgoTieredCaching(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "tiered_caching", + "value": "on", + "editable": true, + "modified_on": "2019-02-20T22:37:07.107449Z" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/argo/tiered_caching", handler) + want := ArgoFeatureSetting{ + ID: "tiered_caching", + Value: "on", + Editable: true, + ModifiedOn: argoTimestamp, + } + + actual, err := client.ArgoTieredCaching(context.Background(), "01a7362d577a6c3019a474fd6f485823") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateArgoTieredCaching(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "tiered_caching", + "value": "off", + "editable": true, + "modified_on": "2019-02-20T22:37:07.107449Z" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/argo/tiered_caching", handler) + want := ArgoFeatureSetting{ + ID: "tiered_caching", + Value: "off", + Editable: true, + ModifiedOn: argoTimestamp, + } + + actual, err := client.UpdateArgoTieredCaching(context.Background(), "01a7362d577a6c3019a474fd6f485823", "off") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateArgoTieredCachingWithInvalidValue(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateArgoTieredCaching(context.Background(), "01a7362d577a6c3019a474fd6f485823", "notreal") + + if assert.Error(t, err) { + assert.Equal(t, "invalid setting value 'notreal'. must be 'on' or 'off'", err.Error()) + } +} diff --git a/pkg/cloudflare-go/argo_tunnel.go b/pkg/cloudflare-go/argo_tunnel.go new file mode 100644 index 000000000..fa444f0ea --- /dev/null +++ b/pkg/cloudflare-go/argo_tunnel.go @@ -0,0 +1,162 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// ArgoTunnel is the struct definition of a tunnel. +type ArgoTunnel struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Secret string `json:"tunnel_secret,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + Connections []ArgoTunnelConnection `json:"connections,omitempty"` +} + +// ArgoTunnelConnection represents the connections associated with a tunnel. +type ArgoTunnelConnection struct { + ColoName string `json:"colo_name"` + UUID string `json:"uuid"` + IsPendingReconnect bool `json:"is_pending_reconnect"` +} + +// ArgoTunnelsDetailResponse is used for representing the API response payload for +// multiple tunnels. +type ArgoTunnelsDetailResponse struct { + Result []ArgoTunnel `json:"result"` + Response +} + +// ArgoTunnelDetailResponse is used for representing the API response payload for +// a single tunnel. +type ArgoTunnelDetailResponse struct { + Result ArgoTunnel `json:"result"` + Response +} + +// ArgoTunnels lists all tunnels. +// +// API reference: https://api.cloudflare.com/#argo-tunnel-list-argo-tunnels +// +// Deprecated: Use `Tunnels` instead. +func (api *API) ArgoTunnels(ctx context.Context, accountID string) ([]ArgoTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", accountID) + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, argoV1Header()) + if err != nil { + return []ArgoTunnel{}, err + } + + var argoDetailsResponse ArgoTunnelsDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return []ArgoTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// ArgoTunnel returns a single Argo tunnel. +// +// API reference: https://api.cloudflare.com/#argo-tunnel-get-argo-tunnel +// +// Deprecated: Use `Tunnel` instead. +func (api *API) ArgoTunnel(ctx context.Context, accountID, tunnelUUID string) (ArgoTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", accountID, tunnelUUID) + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, argoV1Header()) + if err != nil { + return ArgoTunnel{}, err + } + + var argoDetailsResponse ArgoTunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return ArgoTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// CreateArgoTunnel creates a new tunnel for the account. +// +// API reference: https://api.cloudflare.com/#argo-tunnel-create-argo-tunnel +// +// Deprecated: Use `CreateTunnel` instead. +func (api *API) CreateArgoTunnel(ctx context.Context, accountID, name, secret string) (ArgoTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", accountID) + + tunnel := ArgoTunnel{Name: name, Secret: secret} + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, tunnel, argoV1Header()) + if err != nil { + return ArgoTunnel{}, err + } + + var argoDetailsResponse ArgoTunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return ArgoTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return argoDetailsResponse.Result, nil +} + +// DeleteArgoTunnel removes a single Argo tunnel. +// +// API reference: https://api.cloudflare.com/#argo-tunnel-delete-argo-tunnel +// +// Deprecated: Use `DeleteTunnel` instead. +func (api *API) DeleteArgoTunnel(ctx context.Context, accountID, tunnelUUID string) error { + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", accountID, tunnelUUID) + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodDelete, uri, nil, argoV1Header()) + if err != nil { + return err + } + + var argoDetailsResponse ArgoTunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// CleanupArgoTunnelConnections deletes any inactive connections on a tunnel. +// +// API reference: https://api.cloudflare.com/#argo-tunnel-clean-up-argo-tunnel-connections +// +// Deprecated: Use `CleanupTunnelConnections` instead. +func (api *API) CleanupArgoTunnelConnections(ctx context.Context, accountID, tunnelUUID string) error { + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", accountID, tunnelUUID) + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodDelete, uri, nil, argoV1Header()) + if err != nil { + return err + } + + var argoDetailsResponse ArgoTunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// The early implementation of Argo Tunnel endpoints didn't conform to the V4 +// API standard response structure. This has been remedied going forward however +// to support older clients this isn't yet the default. An explicit `Accept` +// header is used to get the V4 compatible version. +func argoV1Header() http.Header { + header := make(http.Header) + header.Set("Accept", "application/json;version=1") + + return header +} diff --git a/pkg/cloudflare-go/argo_tunnel_test.go b/pkg/cloudflare-go/argo_tunnel_test.go new file mode 100644 index 000000000..29f29e100 --- /dev/null +++ b/pkg/cloudflare-go/argo_tunnel_test.go @@ -0,0 +1,221 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestArgoTunnels(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "blog", + "created_at": "2009-11-10T23:00:00Z", + "deleted_at": "2009-11-10T23:00:00Z", + "connections": [ + { + "colo_name": "DFW", + "uuid": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect": false + } + ] + } + ] + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := []ArgoTunnel{{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []ArgoTunnelConnection{{ + ColoName: "DFW", + UUID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + IsPendingReconnect: false, + }}, + }} + + actual, err := client.ArgoTunnels(context.Background(), "01a7362d577a6c3019a474fd6f485823") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestArgoTunnel(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name":"blog", + "created_at":"2009-11-10T23:00:00Z", + "deleted_at":"2009-11-10T23:00:00Z", + "connections":[ + { + "colo_name":"DFW", + "uuid":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect":false + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := ArgoTunnel{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []ArgoTunnelConnection{{ + ColoName: "DFW", + UUID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + IsPendingReconnect: false, + }}, + } + + actual, err := client.ArgoTunnel(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateArgoTunnel(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name":"blog", + "created_at":"2009-11-10T23:00:00Z", + "deleted_at":"2009-11-10T23:00:00Z", + "connections":[ + { + "colo_name":"DFW", + "uuid":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect":false + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := ArgoTunnel{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []ArgoTunnelConnection{{ + ColoName: "DFW", + UUID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + IsPendingReconnect: false, + }}, + } + + actual, err := client.CreateArgoTunnel(context.Background(), "01a7362d577a6c3019a474fd6f485823", "blog", "notarealsecret") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteArgoTunnel(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name":"blog", + "created_at":"2009-11-10T23:00:00Z", + "deleted_at":"2009-11-10T23:00:00Z", + "connections":[ + { + "colo_name":"DFW", + "uuid":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect":false + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler) + + err := client.DeleteArgoTunnel(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + assert.NoError(t, err) +} + +func TestCleanupArgoTunnelConnections(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [] + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/connections", handler) + + err := client.CleanupArgoTunnelConnections(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f174e90a-fafe-4643-bbbc-4a0ed4fc8415") + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/auditlogs.go b/pkg/cloudflare-go/auditlogs.go new file mode 100644 index 000000000..c21398a26 --- /dev/null +++ b/pkg/cloudflare-go/auditlogs.go @@ -0,0 +1,158 @@ +package cloudflare + +import ( + "context" + "net/http" + "net/url" + "path" + "strconv" + "time" + + "github.com/goccy/go-json" +) + +// AuditLogAction is a member of AuditLog, the action that was taken. +type AuditLogAction struct { + Result bool `json:"result"` + Type string `json:"type"` +} + +// AuditLogActor is a member of AuditLog, who performed the action. +type AuditLogActor struct { + Email string `json:"email"` + ID string `json:"id"` + IP string `json:"ip"` + Type string `json:"type"` +} + +// AuditLogOwner is a member of AuditLog, who owns this audit log. +type AuditLogOwner struct { + ID string `json:"id"` +} + +// AuditLogResource is a member of AuditLog, what was the action performed on. +type AuditLogResource struct { + ID string `json:"id"` + Type string `json:"type"` +} + +// AuditLog is an resource that represents an update in the cloudflare dash. +type AuditLog struct { + Action AuditLogAction `json:"action"` + Actor AuditLogActor `json:"actor"` + ID string `json:"id"` + Metadata map[string]interface{} `json:"metadata"` + NewValue string `json:"newValue"` + NewValueJSON map[string]interface{} `json:"newValueJson"` + OldValue string `json:"oldValue"` + OldValueJSON map[string]interface{} `json:"oldValueJson"` + Owner AuditLogOwner `json:"owner"` + Resource AuditLogResource `json:"resource"` + When time.Time `json:"when"` +} + +// AuditLogResponse is the response returned from the cloudflare v4 api. +type AuditLogResponse struct { + Response Response + Result []AuditLog `json:"result"` + ResultInfo `json:"result_info"` +} + +// AuditLogFilter is an object for filtering the audit log response from the api. +type AuditLogFilter struct { + ID string + ActorIP string + ActorEmail string + HideUserLogs bool + Direction string + ZoneName string + Since string + Before string + PerPage int + Page int +} + +// ToQuery turns an audit log filter in to an HTTP Query Param +// list, suitable for use in a url.URL.RawQuery. It will not include empty +// members of the struct in the query parameters. +func (a AuditLogFilter) ToQuery() url.Values { + v := url.Values{} + + if a.ID != "" { + v.Add("id", a.ID) + } + if a.ActorIP != "" { + v.Add("actor.ip", a.ActorIP) + } + if a.ActorEmail != "" { + v.Add("actor.email", a.ActorEmail) + } + if a.HideUserLogs { + v.Add("hide_user_logs", "true") + } + if a.ZoneName != "" { + v.Add("zone.name", a.ZoneName) + } + if a.Direction != "" { + v.Add("direction", a.Direction) + } + if a.Since != "" { + v.Add("since", a.Since) + } + if a.Before != "" { + v.Add("before", a.Before) + } + if a.PerPage > 0 { + v.Add("per_page", strconv.Itoa(a.PerPage)) + } + if a.Page > 0 { + v.Add("page", strconv.Itoa(a.Page)) + } + + return v +} + +// GetOrganizationAuditLogs will return the audit logs of a specific +// organization, based on the ID passed in. The audit logs can be +// filtered based on any argument in the AuditLogFilter. +// +// API Reference: https://api.cloudflare.com/#audit-logs-list-organization-audit-logs +func (api *API) GetOrganizationAuditLogs(ctx context.Context, organizationID string, a AuditLogFilter) (AuditLogResponse, error) { + uri := url.URL{ + Path: path.Join("/accounts", organizationID, "audit_logs"), + ForceQuery: true, + RawQuery: a.ToQuery().Encode(), + } + res, err := api.makeRequestContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return AuditLogResponse{}, err + } + return unmarshalReturn(res) +} + +// unmarshalReturn will unmarshal bytes and return an auditlogresponse. +func unmarshalReturn(res []byte) (AuditLogResponse, error) { + var auditResponse AuditLogResponse + err := json.Unmarshal(res, &auditResponse) + if err != nil { + return auditResponse, err + } + return auditResponse, nil +} + +// GetUserAuditLogs will return your user's audit logs. The audit logs can be +// filtered based on any argument in the AuditLogFilter. +// +// API Reference: https://api.cloudflare.com/#audit-logs-list-user-audit-logs +func (api *API) GetUserAuditLogs(ctx context.Context, a AuditLogFilter) (AuditLogResponse, error) { + uri := url.URL{ + Path: path.Join("/user", "audit_logs"), + ForceQuery: true, + RawQuery: a.ToQuery().Encode(), + } + res, err := api.makeRequestContext(ctx, http.MethodGet, uri.String(), nil) + if err != nil { + return AuditLogResponse{}, err + } + return unmarshalReturn(res) +} diff --git a/pkg/cloudflare-go/auditlogs_test.go b/pkg/cloudflare-go/auditlogs_test.go new file mode 100644 index 000000000..a7f7fc207 --- /dev/null +++ b/pkg/cloudflare-go/auditlogs_test.go @@ -0,0 +1,60 @@ +package cloudflare + +import ( + "strings" + "testing" +) + +func TestAuditLogFilterToQuery(t *testing.T) { + filter := AuditLogFilter{ + ID: "aaaa", + } + if !strings.Contains(filter.ToQuery().Encode(), "id=aaaa") { + t.Fatalf("Did not properly stringify the id field: %s", filter.ToQuery().Encode()) + } + + filter.ActorEmail = "admin@example.com" + if !strings.Contains(filter.ToQuery().Encode(), "actor.email=admin%40example.com") { + t.Fatalf("Did not properly stringify the actor.email field: %s", filter.ToQuery().Encode()) + } + + filter.HideUserLogs = true + if !strings.Contains(filter.ToQuery().Encode(), "hide_user_logs=true") { + t.Fatalf("Did not properly stringify the hide_user_logs field: %s", filter.ToQuery().Encode()) + } + + filter.ActorIP = "192.0.2.0" + if !strings.Contains(filter.ToQuery().Encode(), "&actor.ip=192.0.2.0") { + t.Fatalf("Did not properly stringify the actorip field: %s", filter.ToQuery().Encode()) + } + + filter.ZoneName = "example.com" + if !strings.Contains(filter.ToQuery().Encode(), "&zone.name=example.com") { + t.Fatalf("Did not properly stringify the zone.name field: %s", filter.ToQuery().Encode()) + } + + filter.Direction = "direction" + if !strings.Contains(filter.ToQuery().Encode(), "&direction=direction") { + t.Fatalf("Did not properly stringify the direction field: %s", filter.ToQuery().Encode()) + } + + filter.Since = "10-2-2018" + if !strings.Contains(filter.ToQuery().Encode(), "&since=10-2-2018") { + t.Fatalf("Did not properly stringify the since field: %s", filter.ToQuery().Encode()) + } + + filter.Before = "10-2-2018" + if !strings.Contains(filter.ToQuery().Encode(), "&before=10-2-2018") { + t.Fatalf("Did not properly stringify the before field: %s", filter.ToQuery().Encode()) + } + + filter.PerPage = 10000 + if !strings.Contains(filter.ToQuery().Encode(), "&per_page=10000") { + t.Fatalf("Did not properly stringify the per_page field: %s", filter.ToQuery().Encode()) + } + + filter.Page = 3 + if !strings.Contains(filter.ToQuery().Encode(), "&page=3") { + t.Fatalf("Did not properly stringify the page field: %s", filter.ToQuery().Encode()) + } +} diff --git a/pkg/cloudflare-go/authenticated_origin_pulls.go b/pkg/cloudflare-go/authenticated_origin_pulls.go new file mode 100644 index 000000000..ff46549ce --- /dev/null +++ b/pkg/cloudflare-go/authenticated_origin_pulls.go @@ -0,0 +1,67 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// AuthenticatedOriginPulls represents global AuthenticatedOriginPulls (tls_client_auth) metadata. +type AuthenticatedOriginPulls struct { + ID string `json:"id"` + Value string `json:"value"` + Editable bool `json:"editable"` + ModifiedOn time.Time `json:"modified_on"` +} + +// AuthenticatedOriginPullsResponse represents the response from the global AuthenticatedOriginPulls (tls_client_auth) details endpoint. +type AuthenticatedOriginPullsResponse struct { + Response + Result AuthenticatedOriginPulls `json:"result"` +} + +// GetAuthenticatedOriginPullsStatus returns the configuration details for global AuthenticatedOriginPulls (tls_client_auth). +// +// API reference: https://api.cloudflare.com/#zone-settings-get-tls-client-auth-setting +func (api *API) GetAuthenticatedOriginPullsStatus(ctx context.Context, zoneID string) (AuthenticatedOriginPulls, error) { + uri := fmt.Sprintf("/zones/%s/settings/tls_client_auth", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AuthenticatedOriginPulls{}, err + } + var r AuthenticatedOriginPullsResponse + if err := json.Unmarshal(res, &r); err != nil { + return AuthenticatedOriginPulls{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// SetAuthenticatedOriginPullsStatus toggles whether global AuthenticatedOriginPulls is enabled for the zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-change-tls-client-auth-setting +func (api *API) SetAuthenticatedOriginPullsStatus(ctx context.Context, zoneID string, enable bool) (AuthenticatedOriginPulls, error) { + uri := fmt.Sprintf("/zones/%s/settings/tls_client_auth", zoneID) + var val string + if enable { + val = "on" + } else { + val = "off" + } + params := struct { + Value string `json:"value"` + }{ + Value: val, + } + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return AuthenticatedOriginPulls{}, err + } + var r AuthenticatedOriginPullsResponse + if err := json.Unmarshal(res, &r); err != nil { + return AuthenticatedOriginPulls{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/authenticated_origin_pulls_per_hostname.go b/pkg/cloudflare-go/authenticated_origin_pulls_per_hostname.go new file mode 100644 index 000000000..c8ea3eccb --- /dev/null +++ b/pkg/cloudflare-go/authenticated_origin_pulls_per_hostname.go @@ -0,0 +1,175 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// PerHostnameAuthenticatedOriginPullsCertificateDetails represents the metadata for a Per Hostname AuthenticatedOriginPulls certificate. +type PerHostnameAuthenticatedOriginPullsCertificateDetails struct { + ID string `json:"id"` + Certificate string `json:"certificate"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + SerialNumber string `json:"serial_number"` + ExpiresOn time.Time `json:"expires_on"` + Status string `json:"status"` + UploadedOn time.Time `json:"uploaded_on"` +} + +// PerHostnameAuthenticatedOriginPullsCertificateResponse represents the response from endpoints relating to creating and deleting a Per Hostname AuthenticatedOriginPulls certificate. +type PerHostnameAuthenticatedOriginPullsCertificateResponse struct { + Response + Result PerHostnameAuthenticatedOriginPullsCertificateDetails `json:"result"` +} + +// PerHostnameAuthenticatedOriginPullsDetails contains metadata about the Per Hostname AuthenticatedOriginPulls configuration on a hostname. +type PerHostnameAuthenticatedOriginPullsDetails struct { + Hostname string `json:"hostname"` + CertID string `json:"cert_id"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CertStatus string `json:"cert_status"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + SerialNumber string `json:"serial_number"` + Certificate string `json:"certificate"` + CertUploadedOn time.Time `json:"cert_uploaded_on"` + CertUpdatedAt time.Time `json:"cert_updated_at"` + ExpiresOn time.Time `json:"expires_on"` +} + +// PerHostnameAuthenticatedOriginPullsDetailsResponse represents Per Hostname AuthenticatedOriginPulls configuration metadata for a single hostname. +type PerHostnameAuthenticatedOriginPullsDetailsResponse struct { + Response + Result PerHostnameAuthenticatedOriginPullsDetails `json:"result"` +} + +// PerHostnamesAuthenticatedOriginPullsDetailsResponse represents Per Hostname AuthenticatedOriginPulls configuration metadata for multiple hostnames. +type PerHostnamesAuthenticatedOriginPullsDetailsResponse struct { + Response + Result []PerHostnameAuthenticatedOriginPullsDetails `json:"result"` +} + +// PerHostnameAuthenticatedOriginPullsCertificateParams represents the required data related to the client certificate being uploaded to be used in Per Hostname AuthenticatedOriginPulls. +type PerHostnameAuthenticatedOriginPullsCertificateParams struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` +} + +// PerHostnameAuthenticatedOriginPullsConfig represents the config state for Per Hostname AuthenticatedOriginPulls applied on a hostname. +type PerHostnameAuthenticatedOriginPullsConfig struct { + Hostname string `json:"hostname"` + CertID string `json:"cert_id"` + Enabled bool `json:"enabled"` +} + +// PerHostnameAuthenticatedOriginPullsConfigParams represents the expected config param format for Per Hostname AuthenticatedOriginPulls applied on a hostname. +type PerHostnameAuthenticatedOriginPullsConfigParams struct { + Config []PerHostnameAuthenticatedOriginPullsConfig `json:"config"` +} + +// ListPerHostnameAuthenticatedOriginPullsCertificates will get all certificate under Per Hostname AuthenticatedOriginPulls zone. +// +// API reference: https://api.cloudflare.com/#per-hostname-authenticated-origin-pull-list-certificates +func (api *API) ListPerHostnameAuthenticatedOriginPullsCertificates(ctx context.Context, zoneID string) ([]PerHostnameAuthenticatedOriginPullsDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/hostnames/certificates", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PerHostnameAuthenticatedOriginPullsDetails{}, err + } + var r PerHostnamesAuthenticatedOriginPullsDetailsResponse + if err := json.Unmarshal(res, &r); err != nil { + return []PerHostnameAuthenticatedOriginPullsDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UploadPerHostnameAuthenticatedOriginPullsCertificate will upload the provided certificate and private key to the edge under Per Hostname AuthenticatedOriginPulls. +// +// API reference: https://api.cloudflare.com/#per-hostname-authenticated-origin-pull-upload-a-hostname-client-certificate +func (api *API) UploadPerHostnameAuthenticatedOriginPullsCertificate(ctx context.Context, zoneID string, params PerHostnameAuthenticatedOriginPullsCertificateParams) (PerHostnameAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/hostnames/certificates", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return PerHostnameAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerHostnameAuthenticatedOriginPullsCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerHostnameAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetPerHostnameAuthenticatedOriginPullsCertificate retrieves certificate metadata about the requested Per Hostname certificate. +// +// API reference: https://api.cloudflare.com/#per-hostname-authenticated-origin-pull-get-the-hostname-client-certificate +func (api *API) GetPerHostnameAuthenticatedOriginPullsCertificate(ctx context.Context, zoneID, certificateID string) (PerHostnameAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/hostnames/certificates/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PerHostnameAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerHostnameAuthenticatedOriginPullsCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerHostnameAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeletePerHostnameAuthenticatedOriginPullsCertificate will remove the requested Per Hostname certificate from the edge. +// +// API reference: https://api.cloudflare.com/#per-hostname-authenticated-origin-pull-delete-hostname-client-certificate +func (api *API) DeletePerHostnameAuthenticatedOriginPullsCertificate(ctx context.Context, zoneID, certificateID string) (PerHostnameAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/hostnames/certificates/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return PerHostnameAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerHostnameAuthenticatedOriginPullsCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerHostnameAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// EditPerHostnameAuthenticatedOriginPullsConfig applies the supplied Per Hostname AuthenticatedOriginPulls config onto a hostname(s) in the edge. +// +// API reference: https://api.cloudflare.com/#per-hostname-authenticated-origin-pull-enable-or-disable-a-hostname-for-client-authentication +func (api *API) EditPerHostnameAuthenticatedOriginPullsConfig(ctx context.Context, zoneID string, config []PerHostnameAuthenticatedOriginPullsConfig) ([]PerHostnameAuthenticatedOriginPullsDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/hostnames", zoneID) + conf := PerHostnameAuthenticatedOriginPullsConfigParams{ + Config: config, + } + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, conf) + if err != nil { + return []PerHostnameAuthenticatedOriginPullsDetails{}, err + } + var r PerHostnamesAuthenticatedOriginPullsDetailsResponse + if err := json.Unmarshal(res, &r); err != nil { + return []PerHostnameAuthenticatedOriginPullsDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetPerHostnameAuthenticatedOriginPullsConfig returns the config state of Per Hostname AuthenticatedOriginPulls of the provided hostname within a zone. +// +// API reference: https://api.cloudflare.com/#per-hostname-authenticated-origin-pull-get-the-hostname-status-for-client-authentication +func (api *API) GetPerHostnameAuthenticatedOriginPullsConfig(ctx context.Context, zoneID, hostname string) (PerHostnameAuthenticatedOriginPullsDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/hostnames/%s", zoneID, hostname) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PerHostnameAuthenticatedOriginPullsDetails{}, err + } + var r PerHostnameAuthenticatedOriginPullsDetailsResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerHostnameAuthenticatedOriginPullsDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/authenticated_origin_pulls_per_hostname_test.go b/pkg/cloudflare-go/authenticated_origin_pulls_per_hostname_test.go new file mode 100644 index 000000000..635ceb101 --- /dev/null +++ b/pkg/cloudflare-go/authenticated_origin_pulls_per_hostname_test.go @@ -0,0 +1,360 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListPerHostnameAuthenticatedOriginPullsCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "hostname": "app.example.com", + "cert_id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "enabled": true, + "status": "active", + "created_at": "2019-10-28T18:11:23.37411Z", + "updated_at": "2019-10-28T18:11:23.37411Z", + "cert_status": "active", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354901", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "cert_uploaded_on": "2019-10-28T18:11:23.37411Z", + "cert_updated_at": "2019-10-28T18:11:23.37411Z", + "expires_on": "2100-01-01T05:20:00Z" + }, + { + "hostname": "anotherapp.example.com", + "cert_id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff61", + "enabled": true, + "status": "active", + "created_at": "2019-10-28T18:11:23.37411Z", + "updated_at": "2019-10-28T18:11:23.37411Z", + "cert_status": "active", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354921", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "cert_uploaded_on": "2019-10-28T18:11:23.37411Z", + "cert_updated_at": "2019-10-28T18:11:23.37411Z", + "expires_on": "2100-01-01T05:20:00Z" + } + ] + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/hostnames/certificates", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + createdAt, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + updatedAt, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + + want := []PerHostnameAuthenticatedOriginPullsDetails{ + { + Hostname: "app.example.com", + Enabled: true, + CertStatus: "active", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + CertID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354901", + ExpiresOn: expiresOn, + Status: "active", + CertUploadedOn: updatedAt, + CertUpdatedAt: updatedAt, + }, { + Hostname: "anotherapp.example.com", + Enabled: true, + CertStatus: "active", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + CertID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff61", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354921", + ExpiresOn: expiresOn, + Status: "active", + CertUploadedOn: updatedAt, + CertUpdatedAt: updatedAt, + }, + } + actual, err := client.ListPerHostnameAuthenticatedOriginPullsCertificates(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUploadPerHostnameAuthenticatedOriginPullsCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354901", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/hostnames/certificates", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + want := PerHostnameAuthenticatedOriginPullsCertificateDetails{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354901", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + } + + clientCert := PerHostnameAuthenticatedOriginPullsCertificateParams{ + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmG\ndtcGbg/1CGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKn\nabIRuGvBKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpid\ntnKX/a+50GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+py\nFxIXjbEIdZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pE\newooaeO2izNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABAoIBACbhTYXBZYKmYPCb\nHBR1IBlCQA2nLGf0qRuJNJZg5iEzXows/6tc8YymZkQE7nolapWsQ+upk2y5Xdp/\naxiuprIs9JzkYK8Ox0r+dlwCG1kSW+UAbX0bQ/qUqlsTvU6muVuMP8vZYHxJ3wmb\n+ufRBKztPTQ/rYWaYQcgC0RWI20HTFBMxlTAyNxYNWzX7RKFkGVVyB9RsAtmcc8g\n+j4OdosbfNoJPS0HeIfNpAznDfHKdxDk2Yc1tV6RHBrC1ynyLE9+TaflIAdo2MVv\nKLMLq51GqYKtgJFIlBRPQqKoyXdz3fGvXrTkf/WY9QNq0J1Vk5ERePZ54mN8iZB7\n9lwy/AkCgYEA6FXzosxswaJ2wQLeoYc7ceaweX/SwTvxHgXzRyJIIT0eJWgx13Wo\n/WA3Iziimsjf6qE+SI/8laxPp2A86VMaIt3Z3mJN/CqSVGw8LK2AQst+OwdPyDMu\niacE8lj/IFGC8mwNUAb9CzGU3JpU4PxxGFjS/eMtGeRXCWkK4NE+G08CgYEA1Kp9\nN2JrVlqUz+gAX+LPmE9OEMAS9WQSQsfCHGogIFDGGcNf7+uwBM7GAaSJIP01zcoe\nVAgWdzXCv3FLhsaZoJ6RyLOLay5phbu1iaTr4UNYm5WtYTzMzqh8l1+MFFDl9xDB\nvULuCIIrglM5MeS/qnSg1uMoH2oVPj9TVst/ir8CgYEAxrI7Ws9Zc4Bt70N1As+U\nlySjaEVZCMkqvHJ6TCuVZFfQoE0r0whdLdRLU2PsLFP+q7qaeZQqgBaNSKeVcDYR\n9B+nY/jOmQoPewPVsp/vQTCnE/R81spu0mp0YI6cIheT1Z9zAy322svcc43JaWB7\nmEbeqyLOP4Z4qSOcmghZBSECgYACvR9Xs0DGn+wCsW4vze/2ei77MD4OQvepPIFX\ndFZtlBy5ADcgE9z0cuVB6CiL8DbdK5kwY9pGNr8HUCI03iHkW6Zs+0L0YmihfEVe\nPG19PSzK9CaDdhD9KFZSbLyVFmWfxOt50H7YRTTiPMgjyFpfi5j2q348yVT0tEQS\nfhRqaQKBgAcWPokmJ7EbYQGeMbS7HC8eWO/RyamlnSffdCdSc7ue3zdVJxpAkQ8W\nqu80pEIF6raIQfAf8MXiiZ7auFOSnHQTXUbhCpvDLKi0Mwq3G8Pl07l+2s6dQG6T\nlv6XTQaMyf6n1yjzL+fzDrH3qXMxHMO/b13EePXpDMpY7HQpoLDi\n-----END RSA PRIVATE KEY-----\n", + } + actual, err := client.UploadPerHostnameAuthenticatedOriginPullsCertificate(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", clientCert) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetPerHostnameAuthenticatedOriginPullsCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354901", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/hostnames/certificates/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + + want := PerHostnameAuthenticatedOriginPullsCertificateDetails{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354901", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + } + actual, err := client.GetPerHostnameAuthenticatedOriginPullsCertificate(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} +func TestDeletePerHostnameAuthenticatedOriginPullsCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354901", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/hostnames/certificates/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + + want := PerHostnameAuthenticatedOriginPullsCertificateDetails{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354901", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + } + actual, err := client.DeletePerHostnameAuthenticatedOriginPullsCertificate(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestEditPerHostnameAuthenticatedOriginPullsConfig(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "hostname": "app.example.com", + "cert_id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "enabled": true, + "status": "active", + "created_at": "2100-01-01T05:20:00Z", + "updated_at": "2100-01-01T05:20:00Z", + "cert_status": "active", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354901", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "cert_uploaded_on": "2019-10-28T18:11:23.37411Z", + "cert_updated_at": "2100-01-01T05:20:00Z", + "expires_on": "2100-01-01T05:20:00Z" + } + ] + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/hostnames", handler) + createdAt, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + updatedAt, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + certUploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + certUpdatedAt, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + + want := []PerHostnameAuthenticatedOriginPullsDetails{ + { + Hostname: "app.example.com", + CertID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Enabled: true, + Status: "active", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + CertStatus: "active", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354901", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + CertUploadedOn: certUploadedOn, + CertUpdatedAt: certUpdatedAt, + ExpiresOn: expiresOn, + }, + } + + config := []PerHostnameAuthenticatedOriginPullsConfig{ + { + Hostname: "app.example.com", + CertID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Enabled: true, + }, + } + actual, err := client.EditPerHostnameAuthenticatedOriginPullsConfig(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", config) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetPerHostnameAuthenticatedOriginPullsConfig(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "hostname": "app.example.com", + "cert_id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "enabled": true, + "status": "active", + "created_at": "2100-01-01T05:20:00Z", + "updated_at": "2100-01-01T05:20:00Z", + "cert_status": "active", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "serial_number": "6743787633689793699141714808227354901", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "cert_uploaded_on": "2019-10-28T18:11:23.37411Z", + "cert_updated_at": "2100-01-01T05:20:00Z", + "expires_on": "2100-01-01T05:20:00Z" + } + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/hostnames/app.example.com", handler) + createdAt, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + updatedAt, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + certUploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + certUpdatedAt, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + + want := PerHostnameAuthenticatedOriginPullsDetails{ + Hostname: "app.example.com", + CertID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Enabled: true, + Status: "active", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + CertStatus: "active", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + SerialNumber: "6743787633689793699141714808227354901", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + CertUploadedOn: certUploadedOn, + CertUpdatedAt: certUpdatedAt, + ExpiresOn: expiresOn, + } + actual, err := client.GetPerHostnameAuthenticatedOriginPullsConfig(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "app.example.com") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/authenticated_origin_pulls_per_zone.go b/pkg/cloudflare-go/authenticated_origin_pulls_per_zone.go new file mode 100644 index 000000000..a72b34e0c --- /dev/null +++ b/pkg/cloudflare-go/authenticated_origin_pulls_per_zone.go @@ -0,0 +1,151 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// PerZoneAuthenticatedOriginPullsSettings represents the settings for Per Zone AuthenticatedOriginPulls. +type PerZoneAuthenticatedOriginPullsSettings struct { + Enabled bool `json:"enabled"` +} + +// PerZoneAuthenticatedOriginPullsSettingsResponse represents the response from the Per Zone AuthenticatedOriginPulls settings endpoint. +type PerZoneAuthenticatedOriginPullsSettingsResponse struct { + Response + Result PerZoneAuthenticatedOriginPullsSettings `json:"result"` +} + +// PerZoneAuthenticatedOriginPullsCertificateDetails represents the metadata for a Per Zone AuthenticatedOriginPulls client certificate. +type PerZoneAuthenticatedOriginPullsCertificateDetails struct { + ID string `json:"id"` + Certificate string `json:"certificate"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + ExpiresOn time.Time `json:"expires_on"` + Status string `json:"status"` + UploadedOn time.Time `json:"uploaded_on"` +} + +// PerZoneAuthenticatedOriginPullsCertificateResponse represents the response from endpoints relating to creating and deleting a per zone AuthenticatedOriginPulls certificate. +type PerZoneAuthenticatedOriginPullsCertificateResponse struct { + Response + Result PerZoneAuthenticatedOriginPullsCertificateDetails `json:"result"` +} + +// PerZoneAuthenticatedOriginPullsCertificatesResponse represents the response from the per zone AuthenticatedOriginPulls certificate list endpoint. +type PerZoneAuthenticatedOriginPullsCertificatesResponse struct { + Response + Result []PerZoneAuthenticatedOriginPullsCertificateDetails `json:"result"` +} + +// PerZoneAuthenticatedOriginPullsCertificateParams represents the required data related to the client certificate being uploaded to be used in Per Zone AuthenticatedOriginPulls. +type PerZoneAuthenticatedOriginPullsCertificateParams struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` +} + +// GetPerZoneAuthenticatedOriginPullsStatus returns whether per zone AuthenticatedOriginPulls is enabled or not. It is false by default. +// +// API reference: https://api.cloudflare.com/#zone-level-authenticated-origin-pulls-get-enablement-setting-for-zone +func (api *API) GetPerZoneAuthenticatedOriginPullsStatus(ctx context.Context, zoneID string) (PerZoneAuthenticatedOriginPullsSettings, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/settings", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PerZoneAuthenticatedOriginPullsSettings{}, err + } + var r PerZoneAuthenticatedOriginPullsSettingsResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerZoneAuthenticatedOriginPullsSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// SetPerZoneAuthenticatedOriginPullsStatus will update whether Per Zone AuthenticatedOriginPulls is enabled for the zone. +// +// API reference: https://api.cloudflare.com/#zone-level-authenticated-origin-pulls-set-enablement-for-zone +func (api *API) SetPerZoneAuthenticatedOriginPullsStatus(ctx context.Context, zoneID string, enable bool) (PerZoneAuthenticatedOriginPullsSettings, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/settings", zoneID) + params := struct { + Enabled bool `json:"enabled"` + }{ + Enabled: enable, + } + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return PerZoneAuthenticatedOriginPullsSettings{}, err + } + var r PerZoneAuthenticatedOriginPullsSettingsResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerZoneAuthenticatedOriginPullsSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UploadPerZoneAuthenticatedOriginPullsCertificate will upload a provided client certificate and enable it to be used in all AuthenticatedOriginPulls requests for the zone. +// +// API reference: https://api.cloudflare.com/#zone-level-authenticated-origin-pulls-upload-certificate +func (api *API) UploadPerZoneAuthenticatedOriginPullsCertificate(ctx context.Context, zoneID string, params PerZoneAuthenticatedOriginPullsCertificateParams) (PerZoneAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return PerZoneAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerZoneAuthenticatedOriginPullsCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerZoneAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListPerZoneAuthenticatedOriginPullsCertificates returns a list of all user uploaded client certificates to Per Zone AuthenticatedOriginPulls. +// +// API reference: https://api.cloudflare.com/#zone-level-authenticated-origin-pulls-list-certificates +func (api *API) ListPerZoneAuthenticatedOriginPullsCertificates(ctx context.Context, zoneID string) ([]PerZoneAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PerZoneAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerZoneAuthenticatedOriginPullsCertificatesResponse + if err := json.Unmarshal(res, &r); err != nil { + return []PerZoneAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetPerZoneAuthenticatedOriginPullsCertificateDetails returns the metadata associated with a user uploaded client certificate to Per Zone AuthenticatedOriginPulls. +// +// API reference: https://api.cloudflare.com/#zone-level-authenticated-origin-pulls-get-certificate-details +func (api *API) GetPerZoneAuthenticatedOriginPullsCertificateDetails(ctx context.Context, zoneID, certificateID string) (PerZoneAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PerZoneAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerZoneAuthenticatedOriginPullsCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerZoneAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeletePerZoneAuthenticatedOriginPullsCertificate removes the specified client certificate from the edge. +// +// API reference: https://api.cloudflare.com/#zone-level-authenticated-origin-pulls-delete-certificate +func (api *API) DeletePerZoneAuthenticatedOriginPullsCertificate(ctx context.Context, zoneID, certificateID string) (PerZoneAuthenticatedOriginPullsCertificateDetails, error) { + uri := fmt.Sprintf("/zones/%s/origin_tls_client_auth/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return PerZoneAuthenticatedOriginPullsCertificateDetails{}, err + } + var r PerZoneAuthenticatedOriginPullsCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return PerZoneAuthenticatedOriginPullsCertificateDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/authenticated_origin_pulls_per_zone_test.go b/pkg/cloudflare-go/authenticated_origin_pulls_per_zone_test.go new file mode 100644 index 000000000..26d256733 --- /dev/null +++ b/pkg/cloudflare-go/authenticated_origin_pulls_per_zone_test.go @@ -0,0 +1,230 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetPerZoneAuthenticatedOriginPullsStatus(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "enabled": true + } + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/settings", handler) + want := PerZoneAuthenticatedOriginPullsSettings{ + Enabled: true, + } + actual, err := client.GetPerZoneAuthenticatedOriginPullsStatus(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSetPerZoneAuthenticatedOriginPullsStatus(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "enabled": true + } + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/settings", handler) + want := PerZoneAuthenticatedOriginPullsSettings{ + Enabled: true, + } + actual, err := client.SetPerZoneAuthenticatedOriginPullsStatus(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", true) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUploadPerZoneAuthenticatedOriginPullsCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + want := PerZoneAuthenticatedOriginPullsCertificateDetails{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + } + + clientCert := PerZoneAuthenticatedOriginPullsCertificateParams{ + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmG\ndtcGbg/1CGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKn\nabIRuGvBKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpid\ntnKX/a+50GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+py\nFxIXjbEIdZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pE\newooaeO2izNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABAoIBACbhTYXBZYKmYPCb\nHBR1IBlCQA2nLGf0qRuJNJZg5iEzXows/6tc8YymZkQE7nolapWsQ+upk2y5Xdp/\naxiuprIs9JzkYK8Ox0r+dlwCG1kSW+UAbX0bQ/qUqlsTvU6muVuMP8vZYHxJ3wmb\n+ufRBKztPTQ/rYWaYQcgC0RWI20HTFBMxlTAyNxYNWzX7RKFkGVVyB9RsAtmcc8g\n+j4OdosbfNoJPS0HeIfNpAznDfHKdxDk2Yc1tV6RHBrC1ynyLE9+TaflIAdo2MVv\nKLMLq51GqYKtgJFIlBRPQqKoyXdz3fGvXrTkf/WY9QNq0J1Vk5ERePZ54mN8iZB7\n9lwy/AkCgYEA6FXzosxswaJ2wQLeoYc7ceaweX/SwTvxHgXzRyJIIT0eJWgx13Wo\n/WA3Iziimsjf6qE+SI/8laxPp2A86VMaIt3Z3mJN/CqSVGw8LK2AQst+OwdPyDMu\niacE8lj/IFGC8mwNUAb9CzGU3JpU4PxxGFjS/eMtGeRXCWkK4NE+G08CgYEA1Kp9\nN2JrVlqUz+gAX+LPmE9OEMAS9WQSQsfCHGogIFDGGcNf7+uwBM7GAaSJIP01zcoe\nVAgWdzXCv3FLhsaZoJ6RyLOLay5phbu1iaTr4UNYm5WtYTzMzqh8l1+MFFDl9xDB\nvULuCIIrglM5MeS/qnSg1uMoH2oVPj9TVst/ir8CgYEAxrI7Ws9Zc4Bt70N1As+U\nlySjaEVZCMkqvHJ6TCuVZFfQoE0r0whdLdRLU2PsLFP+q7qaeZQqgBaNSKeVcDYR\n9B+nY/jOmQoPewPVsp/vQTCnE/R81spu0mp0YI6cIheT1Z9zAy322svcc43JaWB7\nmEbeqyLOP4Z4qSOcmghZBSECgYACvR9Xs0DGn+wCsW4vze/2ei77MD4OQvepPIFX\ndFZtlBy5ADcgE9z0cuVB6CiL8DbdK5kwY9pGNr8HUCI03iHkW6Zs+0L0YmihfEVe\nPG19PSzK9CaDdhD9KFZSbLyVFmWfxOt50H7YRTTiPMgjyFpfi5j2q348yVT0tEQS\nfhRqaQKBgAcWPokmJ7EbYQGeMbS7HC8eWO/RyamlnSffdCdSc7ue3zdVJxpAkQ8W\nqu80pEIF6raIQfAf8MXiiZ7auFOSnHQTXUbhCpvDLKi0Mwq3G8Pl07l+2s6dQG6T\nlv6XTQaMyf6n1yjzL+fzDrH3qXMxHMO/b13EePXpDMpY7HQpoLDi\n-----END RSA PRIVATE KEY-----\n", + } + actual, err := client.UploadPerZoneAuthenticatedOriginPullsCertificate(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", clientCert) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListPerZoneAuthenticatedOriginPullsCertificates(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + ] + } + `) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + want := []PerZoneAuthenticatedOriginPullsCertificateDetails{ + { + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + }, + } + actual, err := client.ListPerZoneAuthenticatedOriginPullsCertificates(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetPerZoneAuthenticatedOriginPullsCertificateDetails(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + } + `) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + want := PerZoneAuthenticatedOriginPullsCertificateDetails{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + } + actual, err := client.GetPerZoneAuthenticatedOriginPullsCertificateDetails(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeletePerZoneAuthenticatedOriginPullsCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "expires_on": "2100-01-01T05:20:00Z", + "status": "active", + "uploaded_on": "2019-10-28T18:11:23.37411Z" + } + } + `) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/origin_tls_client_auth/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2100-01-01T05:20:00Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2019-10-28T18:11:23.37411Z") + want := PerZoneAuthenticatedOriginPullsCertificateDetails{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIDtTCCAp2gAwIBAgIJAMHAwfXZ5/PWMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX\naWRnaXRzIFB0eSBMdGQwHhcNMTYwODI0MTY0MzAxWhcNMTYxMTIyMTY0MzAxWjBF\nMQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50\nZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEAwQHoetcl9+5ikGzV6cMzWtWPJHqXT3wpbEkRU9Yz7lgvddmGdtcGbg/1\nCGZu0jJGkMoppoUo4c3dts3iwqRYmBikUP77wwY2QGmDZw2FvkJCJlKnabIRuGvB\nKwzESIXgKk2016aTP6/dAjEHyo6SeoK8lkIySUvK0fyOVlsiEsCmOpidtnKX/a+5\n0GjB79CJH4ER2lLVZnhePFR/zUOyPxZQQ4naHf7yu/b5jhO0f8fwt+pyFxIXjbEI\ndZliWRkRMtzrHOJIhrmJ2A1J7iOrirbbwillwjjNVUWPf3IJ3M12S9pEewooaeO2\nizNTERcG9HzAacbVRn2Y2SWIyT/18QIDAQABo4GnMIGkMB0GA1UdDgQWBBT/LbE4\n9rWf288N6sJA5BRb6FJIGDB1BgNVHSMEbjBsgBT/LbE49rWf288N6sJA5BRb6FJI\nGKFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV\nBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAMHAwfXZ5/PWMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAHHFwl0tH0quUYZYO0dZYt4R7SJ0pCm2\n2satiyzHl4OnXcHDpekAo7/a09c6Lz6AU83cKy/+x3/djYHXWba7HpEu0dR3ugQP\nMlr4zrhd9xKZ0KZKiYmtJH+ak4OM4L3FbT0owUZPyjLSlhMtJVcoRp5CJsjAMBUG\nSvD8RX+T01wzox/Qb+lnnNnOlaWpqu8eoOenybxKp1a9ULzIVvN/LAcc+14vioFq\n2swRWtmocBAs8QR9n4uvbpiYvS8eYueDCWMM4fvFfBhaDZ3N9IbtySh3SpFdQDhw\nYbjM2rxXiyLGxB4Bol7QTv4zHif7Zt89FReT/NBy4rzaskDJY5L6xmY=\n-----END CERTIFICATE-----\n", + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + ExpiresOn: expiresOn, + Status: "active", + UploadedOn: uploadedOn, + } + actual, err := client.DeletePerZoneAuthenticatedOriginPullsCertificate(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/authenticated_origin_pulls_test.go b/pkg/cloudflare-go/authenticated_origin_pulls_test.go new file mode 100644 index 000000000..1e3caa864 --- /dev/null +++ b/pkg/cloudflare-go/authenticated_origin_pulls_test.go @@ -0,0 +1,111 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAuthenticatedOriginPullsDetails(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "tls_client_auth", + "value": "on", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/settings/tls_client_auth", handler) + + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := AuthenticatedOriginPulls{ + ID: "tls_client_auth", + Value: "on", + Editable: true, + ModifiedOn: modifiedOn, + } + + actual, err := client.GetAuthenticatedOriginPullsStatus(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSetAuthenticatedOriginPullsStatusEnabled(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "tls_client_auth", + "value": "on", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/settings/tls_client_auth", handler) + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := AuthenticatedOriginPulls{ + ID: "tls_client_auth", + Value: "on", + Editable: true, + ModifiedOn: modifiedOn, + } + + actual, err := client.SetAuthenticatedOriginPullsStatus(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", true) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSetAuthenticatedOriginPullsStatusDisabled(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "tls_client_auth", + "value": "off", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/settings/tls_client_auth", handler) + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := AuthenticatedOriginPulls{ + ID: "tls_client_auth", + Value: "off", + Editable: true, + ModifiedOn: modifiedOn, + } + + actual, err := client.SetAuthenticatedOriginPullsStatus(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", false) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/bot_management.go b/pkg/cloudflare-go/bot_management.go new file mode 100644 index 000000000..f77968f45 --- /dev/null +++ b/pkg/cloudflare-go/bot_management.go @@ -0,0 +1,91 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// BotManagement represents the bots config for a zone. +type BotManagement struct { + EnableJS *bool `json:"enable_js,omitempty"` + FightMode *bool `json:"fight_mode,omitempty"` + SBFMDefinitelyAutomated *string `json:"sbfm_definitely_automated,omitempty"` + SBFMLikelyAutomated *string `json:"sbfm_likely_automated,omitempty"` + SBFMVerifiedBots *string `json:"sbfm_verified_bots,omitempty"` + SBFMStaticResourceProtection *bool `json:"sbfm_static_resource_protection,omitempty"` + OptimizeWordpress *bool `json:"optimize_wordpress,omitempty"` + SuppressSessionScore *bool `json:"suppress_session_score,omitempty"` + AutoUpdateModel *bool `json:"auto_update_model,omitempty"` + UsingLatestModel *bool `json:"using_latest_model,omitempty"` +} + +// BotManagementResponse represents the response from the bot_management endpoint. +type BotManagementResponse struct { + Result BotManagement `json:"result"` + Response +} + +type UpdateBotManagementParams struct { + EnableJS *bool `json:"enable_js,omitempty"` + FightMode *bool `json:"fight_mode,omitempty"` + SBFMDefinitelyAutomated *string `json:"sbfm_definitely_automated,omitempty"` + SBFMLikelyAutomated *string `json:"sbfm_likely_automated,omitempty"` + SBFMVerifiedBots *string `json:"sbfm_verified_bots,omitempty"` + SBFMStaticResourceProtection *bool `json:"sbfm_static_resource_protection,omitempty"` + OptimizeWordpress *bool `json:"optimize_wordpress,omitempty"` + SuppressSessionScore *bool `json:"suppress_session_score,omitempty"` + AutoUpdateModel *bool `json:"auto_update_model,omitempty"` +} + +// GetBotManagement gets a zone API shield configuration. +// +// API documentation: https://developers.cloudflare.com/api/operations/bot-management-for-a-zone-get-config +func (api *API) GetBotManagement(ctx context.Context, rc *ResourceContainer) (BotManagement, error) { + uri := fmt.Sprintf("/zones/%s/bot_management", rc.Identifier) + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, botV2Header()) + if err != nil { + return BotManagement{}, err + } + var bmResponse BotManagementResponse + err = json.Unmarshal(res, &bmResponse) + if err != nil { + return BotManagement{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return bmResponse.Result, nil +} + +// UpdateBotManagement sets a zone API shield configuration. +// +// API documentation: https://developers.cloudflare.com/api/operations/bot-management-for-a-zone-update-config +func (api *API) UpdateBotManagement(ctx context.Context, rc *ResourceContainer, params UpdateBotManagementParams) (BotManagement, error) { + uri := fmt.Sprintf("/zones/%s/bot_management", rc.Identifier) + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPut, uri, params, botV2Header()) + if err != nil { + return BotManagement{}, err + } + + var bmResponse BotManagementResponse + err = json.Unmarshal(res, &bmResponse) + if err != nil { + return BotManagement{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return bmResponse.Result, nil +} + +// We are currently undergoing the process of updating the bot management API. +// The older 1.0.0 version of the is still the default version, so we will need +// to explicitly set this special header on all requests. We will eventually +// make 2.0.0 the default version, and later we will remove the 1.0.0 entirely. +func botV2Header() http.Header { + header := make(http.Header) + header.Set("Cloudflare-Version", "2.0.0") + + return header +} diff --git a/pkg/cloudflare-go/bot_management_test.go b/pkg/cloudflare-go/bot_management_test.go new file mode 100644 index 000000000..e0837804e --- /dev/null +++ b/pkg/cloudflare-go/bot_management_test.go @@ -0,0 +1,87 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetBotManagement(t *testing.T) { + setup() + defer teardown() + + 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, "2.0.0", r.Header.Get("Cloudflare-Version")) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "enable_js": false, + "fight_mode": true, + "using_latest_model": true + } +} + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/bot_management", handler) + + want := BotManagement{ + EnableJS: BoolPtr(false), + FightMode: BoolPtr(true), + UsingLatestModel: BoolPtr(true), + } + + actual, err := client.GetBotManagement(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateBotManagement(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + assert.Equal(t, "2.0.0", r.Header.Get("Cloudflare-Version")) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "enable_js": false, + "fight_mode": true, + "using_latest_model": true + } +} + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/bot_management", handler) + + bmData := UpdateBotManagementParams{ + EnableJS: BoolPtr(false), + FightMode: BoolPtr(true), + } + + want := BotManagement{ + EnableJS: BoolPtr(false), + FightMode: BoolPtr(true), + UsingLatestModel: BoolPtr(true), + } + + actual, err := client.UpdateBotManagement(context.Background(), ZoneIdentifier(testZoneID), bmData) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/cache_reserve.go b/pkg/cloudflare-go/cache_reserve.go new file mode 100644 index 000000000..64e74b39a --- /dev/null +++ b/pkg/cloudflare-go/cache_reserve.go @@ -0,0 +1,83 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// CacheReserve is the structure of the API object for the cache reserve +// setting. +type CacheReserve struct { + ID string `json:"id,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + Value string `json:"value"` +} + +// CacheReserveDetailsResponse is the API response for the cache reserve +// setting. +type CacheReserveDetailsResponse struct { + Result CacheReserve `json:"result"` + Response +} + +type zoneCacheReserveSingleResponse struct { + Response + Result CacheReserve `json:"result"` +} + +type GetCacheReserveParams struct{} + +type UpdateCacheReserveParams struct { + Value string `json:"value"` +} + +// GetCacheReserve returns information about the current cache reserve settings. +// +// API reference: https://developers.cloudflare.com/api/operations/zone-cache-settings-get-cache-reserve-setting +func (api *API) GetCacheReserve(ctx context.Context, rc *ResourceContainer, params GetCacheReserveParams) (CacheReserve, error) { + if rc.Level != ZoneRouteLevel { + return CacheReserve{}, ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/cache/cache_reserve", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return CacheReserve{}, err + } + + var cacheReserveDetailsResponse CacheReserveDetailsResponse + err = json.Unmarshal(res, &cacheReserveDetailsResponse) + if err != nil { + return CacheReserve{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return cacheReserveDetailsResponse.Result, nil +} + +// UpdateCacheReserve updates the cache reserve setting for a zone +// +// API reference: https://developers.cloudflare.com/api/operations/zone-cache-settings-change-cache-reserve-setting +func (api *API) UpdateCacheReserve(ctx context.Context, rc *ResourceContainer, params UpdateCacheReserveParams) (CacheReserve, error) { + if rc.Level != ZoneRouteLevel { + return CacheReserve{}, ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/cache/cache_reserve", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return CacheReserve{}, err + } + + response := &zoneCacheReserveSingleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return CacheReserve{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} diff --git a/pkg/cloudflare-go/cache_reserve_test.go b/pkg/cloudflare-go/cache_reserve_test.go new file mode 100644 index 000000000..4434c9cf2 --- /dev/null +++ b/pkg/cloudflare-go/cache_reserve_test.go @@ -0,0 +1,82 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var cacheReserveTimestampString = "2019-02-20T22:37:07.107449Z" +var cacheReserveTimestamp, _ = time.Parse(time.RFC3339Nano, cacheReserveTimestampString) + +func TestCacheReserve(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "cache_reserve", + "value": "on", + "modified_on": "%s" + } + } + `, cacheReserveTimestampString) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/cache/cache_reserve", handler) + want := CacheReserve{ + ID: "cache_reserve", + Value: "on", + ModifiedOn: cacheReserveTimestamp, + } + + actual, err := client.GetCacheReserve(context.Background(), ZoneIdentifier("01a7362d577a6c3019a474fd6f485823"), GetCacheReserveParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateCacheReserve(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "cache_reserve", + "value": "off", + "modified_on": "%s" + } + } + `, cacheReserveTimestampString) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/cache/cache_reserve", handler) + want := CacheReserve{ + ID: "cache_reserve", + Value: "off", + ModifiedOn: cacheReserveTimestamp, + } + + actual, err := client.UpdateCacheReserve(context.Background(), ZoneIdentifier("01a7362d577a6c3019a474fd6f485823"), UpdateCacheReserveParams{Value: "off"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/catalog.json b/pkg/cloudflare-go/catalog.json new file mode 100644 index 000000000..97847246e --- /dev/null +++ b/pkg/cloudflare-go/catalog.json @@ -0,0 +1,67 @@ +{ + "nixpkgs-flox": { + "x86_64-darwin": { + "stable": { + "go_1_20": { + "latest": { + "cache": { + "out": { + "https://cache.nixos.org": {} + } + }, + "element": { + "attrPath": [ + "legacyPackages", + "x86_64-darwin", + "go_1_20" + ], + "originalUrl": "flake:nixpkgs", + "storePaths": [ + "/nix/store/0pp0svlrkdls28dixb6a7kqa567gs59v-go-1.20.1" + ], + "url": "github:flox/nixpkgs/d0d55259081f0b97c828f38559cad899d351cad1" + }, + "eval": { + "meta": { + "description": "The Go Programming language", + "outputsToInstall": [ + "out" + ], + "position": "/nix/store/32cp7y0cf36h3xp0fqwy2nf432fpcaci-source/pkgs/development/compilers/go/1.20.nix:176", + "unfree": false + }, + "name": "go-1.20.1", + "outputs": { + "out": "/nix/store/0pp0svlrkdls28dixb6a7kqa567gs59v-go-1.20.1" + }, + "pname": "go", + "system": "x86_64-darwin", + "version": "1.20.1" + }, + "publish_element": { + "attrPath": [ + "evalCatalog", + "x86_64-darwin", + "stable", + "go_1_20" + ], + "originalUrl": "flake:nixpkgs-flox", + "storePaths": [ + "/nix/store/0pp0svlrkdls28dixb6a7kqa567gs59v-go-1.20.1" + ], + "url": "flake:nixpkgs-flox/b7e7e40e2aa1ca2a44db440f5dc52213564af02f" + }, + "source": { + "locked": { + "lastModified": 1676973346, + "revCount": 456418 + } + }, + "type": "catalogRender", + "version": 1 + } + } + } + } + } +} diff --git a/pkg/cloudflare-go/certificate_packs.go b/pkg/cloudflare-go/certificate_packs.go new file mode 100644 index 000000000..2fee1d674 --- /dev/null +++ b/pkg/cloudflare-go/certificate_packs.go @@ -0,0 +1,163 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// CertificatePackGeoRestrictions is for the structure of the geographic +// restrictions for a TLS certificate. +type CertificatePackGeoRestrictions struct { + Label string `json:"label"` +} + +// CertificatePackCertificate is the base structure of a TLS certificate that is +// contained within a certificate pack. +type CertificatePackCertificate struct { + ID string `json:"id"` + Hosts []string `json:"hosts"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + Status string `json:"status"` + BundleMethod string `json:"bundle_method"` + GeoRestrictions CertificatePackGeoRestrictions `json:"geo_restrictions"` + ZoneID string `json:"zone_id"` + UploadedOn time.Time `json:"uploaded_on"` + ModifiedOn time.Time `json:"modified_on"` + ExpiresOn time.Time `json:"expires_on"` + Priority int `json:"priority"` +} + +// CertificatePack is the overarching structure of a certificate pack response. +type CertificatePack struct { + ID string `json:"id"` + Type string `json:"type"` + Hosts []string `json:"hosts"` + Certificates []CertificatePackCertificate `json:"certificates"` + PrimaryCertificate string `json:"primary_certificate"` + Status string `json:"status"` + ValidationRecords []SSLValidationRecord `json:"validation_records,omitempty"` + ValidationErrors []SSLValidationError `json:"validation_errors,omitempty"` + ValidationMethod string `json:"validation_method"` + ValidityDays int `json:"validity_days"` + CertificateAuthority string `json:"certificate_authority"` + CloudflareBranding bool `json:"cloudflare_branding"` +} + +// CertificatePackRequest is used for requesting a new certificate. +type CertificatePackRequest struct { + Type string `json:"type"` + Hosts []string `json:"hosts"` + ValidationMethod string `json:"validation_method"` + ValidityDays int `json:"validity_days"` + CertificateAuthority string `json:"certificate_authority"` + CloudflareBranding bool `json:"cloudflare_branding"` +} + +// CertificatePacksResponse is for responses where multiple certificates are +// expected. +type CertificatePacksResponse struct { + Response + Result []CertificatePack `json:"result"` +} + +// CertificatePacksDetailResponse contains a single certificate pack in the +// response. +type CertificatePacksDetailResponse struct { + Response + Result CertificatePack `json:"result"` +} + +// ListCertificatePacks returns all available TLS certificate packs for a zone. +// +// API Reference: https://api.cloudflare.com/#certificate-packs-list-certificate-packs +func (api *API) ListCertificatePacks(ctx context.Context, zoneID string) ([]CertificatePack, error) { + uri := fmt.Sprintf("/zones/%s/ssl/certificate_packs?status=all", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []CertificatePack{}, err + } + + var certificatePacksResponse CertificatePacksResponse + err = json.Unmarshal(res, &certificatePacksResponse) + if err != nil { + return []CertificatePack{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return certificatePacksResponse.Result, nil +} + +// CertificatePack returns a single TLS certificate pack on a zone. +// +// API Reference: https://api.cloudflare.com/#certificate-packs-get-certificate-pack +func (api *API) CertificatePack(ctx context.Context, zoneID, certificatePackID string) (CertificatePack, error) { + uri := fmt.Sprintf("/zones/%s/ssl/certificate_packs/%s", zoneID, certificatePackID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return CertificatePack{}, err + } + + var certificatePacksDetailResponse CertificatePacksDetailResponse + err = json.Unmarshal(res, &certificatePacksDetailResponse) + if err != nil { + return CertificatePack{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return certificatePacksDetailResponse.Result, nil +} + +// CreateCertificatePack creates a new certificate pack associated with a zone. +// +// API Reference: https://api.cloudflare.com/#certificate-packs-order-advanced-certificate-manager-certificate-pack +func (api *API) CreateCertificatePack(ctx context.Context, zoneID string, cert CertificatePackRequest) (CertificatePack, error) { + uri := fmt.Sprintf("/zones/%s/ssl/certificate_packs/order", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, cert) + if err != nil { + return CertificatePack{}, err + } + + var certificatePacksDetailResponse CertificatePacksDetailResponse + err = json.Unmarshal(res, &certificatePacksDetailResponse) + if err != nil { + return CertificatePack{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return certificatePacksDetailResponse.Result, nil +} + +// DeleteCertificatePack removes a certificate pack associated with a zone. +// +// API Reference: https://api.cloudflare.com/#certificate-packs-delete-advanced-certificate-manager-certificate-pack +func (api *API) DeleteCertificatePack(ctx context.Context, zoneID, certificateID string) error { + uri := fmt.Sprintf("/zones/%s/ssl/certificate_packs/%s", zoneID, certificateID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// RestartCertificateValidation kicks off the validation process for a +// pending certificate pack. +// +// API Reference: https://api.cloudflare.com/#certificate-packs-restart-validation-for-advanced-certificate-manager-certificate-pack +func (api *API) RestartCertificateValidation(ctx context.Context, zoneID, certificateID string) (CertificatePack, error) { + uri := fmt.Sprintf("/zones/%s/ssl/certificate_packs/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, nil) + if err != nil { + return CertificatePack{}, err + } + + var certificatePackResponse CertificatePacksDetailResponse + err = json.Unmarshal(res, &certificatePackResponse) + if err != nil { + return CertificatePack{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return certificatePackResponse.Result, nil +} diff --git a/pkg/cloudflare-go/certificate_packs_test.go b/pkg/cloudflare-go/certificate_packs_test.go new file mode 100644 index 000000000..6dc975824 --- /dev/null +++ b/pkg/cloudflare-go/certificate_packs_test.go @@ -0,0 +1,285 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + uploadedOn, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + expiresOn, _ = time.Parse(time.RFC3339, "2016-01-01T05:20:00Z") + + desiredCertificatePack = CertificatePack{ + ID: "3822ff90-ea29-44df-9e55-21300bb9419b", + Type: "advanced", + Hosts: []string{"example.com", "*.example.com", "www.example.com"}, + PrimaryCertificate: "b2cfa4183267af678ea06c7407d4d6d8", + ValidationMethod: "txt", + ValidityDays: 90, + CertificateAuthority: "lets_encrypt", + Certificates: []CertificatePackCertificate{{ + ID: "3822ff90-ea29-44df-9e55-21300bb9419b", + Hosts: []string{"example.com"}, + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + Status: "active", + BundleMethod: "ubiquitous", + GeoRestrictions: CertificatePackGeoRestrictions{Label: "us"}, + ZoneID: testZoneID, + UploadedOn: uploadedOn, + ModifiedOn: uploadedOn, + ExpiresOn: expiresOn, + Priority: 1, + }}, + } + + pendingCertificatePack = CertificatePack{ + ID: "3822ff90-ea29-44df-9e55-21300bb9419b", + Type: "advanced", + Hosts: []string{"example.com", "*.example.com", "www.example.com"}, + Status: "initializing", + ValidationMethod: "txt", + ValidityDays: 90, + CertificateAuthority: "lets_encrypt", + } +) + +func TestListCertificatePacks(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b", + "type": "advanced", + "hosts": [ + "example.com", + "*.example.com", + "www.example.com" + ], + "validity_days": 90, + "validation_method": "txt", + "certificate_authority": "lets_encrypt", + "certificates": [ + { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "geo_restrictions": { + "label": "us" + }, + "zone_id": "%[1]s", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + ], + "primary_certificate": "b2cfa4183267af678ea06c7407d4d6d8" + } + ] +} + `, testZoneID) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/certificate_packs", handler) + + want := []CertificatePack{desiredCertificatePack} + actual, err := client.ListCertificatePacks(context.Background(), testZoneID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListCertificatePack(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b", + "type": "advanced", + "validity_days": 90, + "validation_method": "txt", + "certificate_authority": "lets_encrypt", + "hosts": [ + "example.com", + "*.example.com", + "www.example.com" + ], + "certificates": [ + { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "geo_restrictions": { + "label": "us" + }, + "zone_id": "%[1]s", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + ], + "primary_certificate": "b2cfa4183267af678ea06c7407d4d6d8" + } +} + `, testZoneID) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/certificate_packs/3822ff90-ea29-44df-9e55-21300bb9419b", handler) + + actual, err := client.CertificatePack(context.Background(), testZoneID, "3822ff90-ea29-44df-9e55-21300bb9419b") + + if assert.NoError(t, err) { + assert.Equal(t, desiredCertificatePack, actual) + } +} + +func TestCreateCertificatePack(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b", + "type": "advanced", + "hosts": [ + "example.com", + "*.example.com", + "www.example.com" + ], + "status": "initializing", + "validation_method": "txt", + "validity_days": 90, + "certificate_authority": "lets_encrypt", + "cloudflare_branding": false + } + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/certificate_packs/order", handler) + + certificate := CertificatePackRequest{ + Type: "advanced", + Hosts: []string{"example.com", "*.example.com", "www.example.com"}, + ValidationMethod: "txt", + ValidityDays: 90, + CertificateAuthority: "lets_encrypt", + } + actual, err := client.CreateCertificatePack(context.Background(), testZoneID, certificate) + + if assert.NoError(t, err) { + assert.Equal(t, pendingCertificatePack, actual) + } +} + +func TestRestartAdvancedCertificateValidation(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b", + "type": "advanced", + "hosts": [ + "example.com", + "*.example.com", + "www.example.com" + ], + "status": "initializing", + "validation_method": "txt", + "validity_days": 365, + "certificate_authority": "lets_encrypt", + "cloudflare_branding": false + } +}`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/certificate_packs/3822ff90-ea29-44df-9e55-21300bb9419b", handler) + + certificate := CertificatePack{ + ID: "3822ff90-ea29-44df-9e55-21300bb9419b", + Type: "advanced", + Hosts: []string{"example.com", "*.example.com", "www.example.com"}, + Status: "initializing", + ValidityDays: 365, + ValidationMethod: "txt", + CertificateAuthority: "lets_encrypt", + CloudflareBranding: false, + } + + actual, err := client.RestartCertificateValidation(context.Background(), testZoneID, "3822ff90-ea29-44df-9e55-21300bb9419b") + + if assert.NoError(t, err) { + assert.Equal(t, certificate, actual) + } +} + +func TestDeleteCertificatePack(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "3822ff90-ea29-44df-9e55-21300bb9419b" + } +} + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/certificate_packs/3822ff90-ea29-44df-9e55-21300bb9419b", handler) + + err := client.DeleteCertificatePack(context.Background(), testZoneID, "3822ff90-ea29-44df-9e55-21300bb9419b") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/cloudflare.go b/pkg/cloudflare-go/cloudflare.go new file mode 100644 index 000000000..b22898101 --- /dev/null +++ b/pkg/cloudflare-go/cloudflare.go @@ -0,0 +1,593 @@ +// Package cloudflare implements the Cloudflare v4 API. +package cloudflare + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "math" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/goccy/go-json" + + "golang.org/x/time/rate" +) + +var ( + Version string = "v4" + + // Deprecated: Use `client.New` configuration instead. + apiURL = fmt.Sprintf("%s://%s%s", defaultScheme, defaultHostname, defaultBasePath) +) + +const ( + // AuthKeyEmail specifies that we should authenticate with API key and email address. + AuthKeyEmail = 1 << iota + // AuthUserService specifies that we should authenticate with a User-Service key. + AuthUserService + // AuthToken specifies that we should authenticate with an API Token. + AuthToken +) + +// API holds the configuration for the current API client. A client should not +// be modified concurrently. +type API struct { + APIKey string + APIEmail string + APIUserServiceKey string + APIToken string + BaseURL string + UserAgent string + headers http.Header + httpClient *http.Client + authType int + rateLimiter *rate.Limiter + retryPolicy RetryPolicy + logger Logger + Debug bool +} + +// newClient provides shared logic for New and NewWithUserServiceKey. +func newClient(opts ...Option) (*API, error) { + silentLogger := log.New(io.Discard, "", log.LstdFlags) + + api := &API{ + BaseURL: fmt.Sprintf("%s://%s%s", defaultScheme, defaultHostname, defaultBasePath), + UserAgent: userAgent + "/" + Version, + headers: make(http.Header), + rateLimiter: rate.NewLimiter(rate.Limit(4), 1), // 4rps equates to default api limit (1200 req/5 min) + retryPolicy: RetryPolicy{ + MaxRetries: 3, + MinRetryDelay: 1 * time.Second, + MaxRetryDelay: 30 * time.Second, + }, + logger: silentLogger, + } + + err := api.parseOptions(opts...) + if err != nil { + return nil, fmt.Errorf("options parsing failed: %w", err) + } + + // Fall back to http.DefaultClient if the package user does not provide + // their own. + if api.httpClient == nil { + api.httpClient = http.DefaultClient + } + + return api, nil +} + +// New creates a new Cloudflare v4 API client. +func New(key, email string, opts ...Option) (*API, error) { + if key == "" || email == "" { + return nil, errors.New(errEmptyCredentials) + } + + api, err := newClient(opts...) + if err != nil { + return nil, err + } + + api.APIKey = key + api.APIEmail = email + api.authType = AuthKeyEmail + + return api, nil +} + +// NewWithAPIToken creates a new Cloudflare v4 API client using API Tokens. +func NewWithAPIToken(token string, opts ...Option) (*API, error) { + if token == "" { + return nil, errors.New(errEmptyAPIToken) + } + + api, err := newClient(opts...) + if err != nil { + return nil, err + } + + api.APIToken = token + api.authType = AuthToken + + return api, nil +} + +// NewWithUserServiceKey creates a new Cloudflare v4 API client using service key authentication. +func NewWithUserServiceKey(key string, opts ...Option) (*API, error) { + if key == "" { + return nil, errors.New(errEmptyCredentials) + } + + api, err := newClient(opts...) + if err != nil { + return nil, err + } + + api.APIUserServiceKey = key + api.authType = AuthUserService + + return api, nil +} + +// SetAuthType sets the authentication method (AuthKeyEmail, AuthToken, or AuthUserService). +func (api *API) SetAuthType(authType int) { + api.authType = authType +} + +// ZoneIDByName retrieves a zone's ID from the name. +func (api *API) ZoneIDByName(zoneName string) (string, error) { + zoneName = normalizeZoneName(zoneName) + res, err := api.ListZonesContext(context.Background(), WithZoneFilters(zoneName, "", "")) + if err != nil { + return "", fmt.Errorf("ListZonesContext command failed: %w", err) + } + + switch len(res.Result) { + case 0: + return "", errors.New("zone could not be found") + case 1: + return res.Result[0].ID, nil + default: + return "", errors.New("ambiguous zone name; an account ID might help") + } +} + +// makeRequest makes a HTTP request and returns the body as a byte slice, +// closing it before returning. params will be serialized to JSON. +func (api *API) makeRequest(method, uri string, params interface{}) ([]byte, error) { + return api.makeRequestWithAuthType(context.Background(), method, uri, params, api.authType) +} + +func (api *API) makeRequestContext(ctx context.Context, method, uri string, params interface{}) ([]byte, error) { + return api.makeRequestWithAuthType(ctx, method, uri, params, api.authType) +} + +func (api *API) makeRequestContextWithHeaders(ctx context.Context, method, uri string, params interface{}, headers http.Header) ([]byte, error) { + return api.makeRequestWithAuthTypeAndHeaders(ctx, method, uri, params, api.authType, headers) +} + +func (api *API) makeRequestWithAuthType(ctx context.Context, method, uri string, params interface{}, authType int) ([]byte, error) { + return api.makeRequestWithAuthTypeAndHeaders(ctx, method, uri, params, authType, nil) +} + +// APIResponse holds the structure for a response from the API. It looks alot +// like `http.Response` however, uses a `[]byte` for the `Body` instead of a +// `io.ReadCloser`. +// +// This may go away in the experimental client in favour of `http.Response`. +type APIResponse struct { + Body []byte + Status string + StatusCode int + Headers http.Header +} + +func (api *API) makeRequestWithAuthTypeAndHeaders(ctx context.Context, method, uri string, params interface{}, authType int, headers http.Header) ([]byte, error) { + res, err := api.makeRequestWithAuthTypeAndHeadersComplete(ctx, method, uri, params, authType, headers) + if err != nil { + return nil, err + } + return res.Body, err +} + +// Use this method if an API response can have different Content-Type headers and different body formats. +func (api *API) makeRequestContextWithHeadersComplete(ctx context.Context, method, uri string, params interface{}, headers http.Header) (*APIResponse, error) { + return api.makeRequestWithAuthTypeAndHeadersComplete(ctx, method, uri, params, api.authType, headers) +} + +func (api *API) makeRequestWithAuthTypeAndHeadersComplete(ctx context.Context, method, uri string, params interface{}, authType int, headers http.Header) (*APIResponse, error) { + var err error + var resp *http.Response + var respErr error + var respBody []byte + + for i := 0; i <= api.retryPolicy.MaxRetries; i++ { + var reqBody io.Reader + if params != nil { + if r, ok := params.(io.Reader); ok { + reqBody = r + } else if paramBytes, ok := params.([]byte); ok { + reqBody = bytes.NewReader(paramBytes) + } else { + var jsonBody []byte + jsonBody, err = json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("error marshalling params to JSON: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + } + + if i > 0 { + // expect the backoff introduced here on errored requests to dominate the effect of rate limiting + // don't need a random component here as the rate limiter should do something similar + // nb time duration could truncate an arbitrary float. Since our inputs are all ints, we should be ok + sleepDuration := time.Duration(math.Pow(2, float64(i-1)) * float64(api.retryPolicy.MinRetryDelay)) + + if sleepDuration > api.retryPolicy.MaxRetryDelay { + sleepDuration = api.retryPolicy.MaxRetryDelay + } + // useful to do some simple logging here, maybe introduce levels later + api.logger.Printf("Sleeping %s before retry attempt number %d for request %s %s", sleepDuration.String(), i, method, uri) + + select { + case <-time.After(sleepDuration): + case <-ctx.Done(): + return nil, fmt.Errorf("operation aborted during backoff: %w", ctx.Err()) + } + } + + err = api.rateLimiter.Wait(ctx) + if err != nil { + return nil, fmt.Errorf("error caused by request rate limiting: %w", err) + } + + resp, respErr = api.request(ctx, method, uri, reqBody, authType, headers) + + // short circuit processing on context timeouts + if respErr != nil && errors.Is(respErr, context.DeadlineExceeded) { + return nil, respErr + } + + // retry if the server is rate limiting us or if it failed + // assumes server operations are rolled back on failure + if respErr != nil || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { + if resp != nil && resp.StatusCode == http.StatusTooManyRequests { + respErr = errors.New("exceeded available rate limit retries") + } + + if respErr == nil { + respErr = fmt.Errorf("received %s response (HTTP %d), please try again later", strings.ToLower(http.StatusText(resp.StatusCode)), resp.StatusCode) + } + continue + } else { + respBody, err = io.ReadAll(resp.Body) + defer resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("could not read response body: %w", err) + } + + break + } + } + + // still had an error after all retries + if respErr != nil { + return nil, respErr + } + + if resp.StatusCode >= http.StatusBadRequest { + if strings.HasSuffix(resp.Request.URL.Path, "/filters/validate-expr") { + return nil, fmt.Errorf("%s", respBody) + } + + if resp.StatusCode >= http.StatusInternalServerError { + return nil, &ServiceError{cloudflareError: &Error{ + StatusCode: resp.StatusCode, + RayID: resp.Header.Get("cf-ray"), + Errors: []ResponseInfo{{ + Message: errInternalServiceError, + }}, + }} + } + + errBody := &Response{} + err = json.Unmarshal(respBody, &errBody) + if err != nil { + return nil, fmt.Errorf(errUnmarshalErrorBody+": %w", err) + } + + errCodes := make([]int, 0, len(errBody.Errors)) + errMsgs := make([]string, 0, len(errBody.Errors)) + for _, e := range errBody.Errors { + errCodes = append(errCodes, e.Code) + errMsgs = append(errMsgs, e.Message) + } + + err := &Error{ + StatusCode: resp.StatusCode, + RayID: resp.Header.Get("cf-ray"), + Errors: errBody.Errors, + ErrorCodes: errCodes, + ErrorMessages: errMsgs, + Messages: errBody.Messages, + } + + switch resp.StatusCode { + case http.StatusUnauthorized: + err.Type = ErrorTypeAuthorization + return nil, &AuthorizationError{cloudflareError: err} + case http.StatusForbidden: + err.Type = ErrorTypeAuthentication + return nil, &AuthenticationError{cloudflareError: err} + case http.StatusNotFound: + err.Type = ErrorTypeNotFound + return nil, &NotFoundError{cloudflareError: err} + case http.StatusTooManyRequests: + err.Type = ErrorTypeRateLimit + return nil, &RatelimitError{cloudflareError: err} + default: + err.Type = ErrorTypeRequest + return nil, &RequestError{cloudflareError: err} + } + } + + return &APIResponse{ + Body: respBody, + StatusCode: resp.StatusCode, + Status: resp.Status, + Headers: resp.Header, + }, nil +} + +// request makes a HTTP request to the given API endpoint, returning the raw +// *http.Response, or an error if one occurred. The caller is responsible for +// closing the response body. +func (api *API) request(ctx context.Context, method, uri string, reqBody io.Reader, authType int, headers http.Header) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, api.BaseURL+uri, reqBody) + if err != nil { + return nil, fmt.Errorf("HTTP request creation failed: %w", err) + } + + combinedHeaders := make(http.Header) + copyHeader(combinedHeaders, api.headers) + copyHeader(combinedHeaders, headers) + req.Header = combinedHeaders + + if authType&AuthKeyEmail != 0 { + req.Header.Set("X-Auth-Key", api.APIKey) + req.Header.Set("X-Auth-Email", api.APIEmail) + } + if authType&AuthUserService != 0 { + req.Header.Set("X-Auth-User-Service-Key", api.APIUserServiceKey) + } + if authType&AuthToken != 0 { + req.Header.Set("Authorization", "Bearer "+api.APIToken) + } + + if api.UserAgent != "" { + req.Header.Set("User-Agent", api.UserAgent) + } + + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + if api.Debug { + dump, err := httputil.DumpRequestOut(req, true) + if err != nil { + return nil, err + } + + // Strip out any sensitive information from the request payload. + sensitiveKeys := []string{api.APIKey, api.APIEmail, api.APIToken, api.APIUserServiceKey} + for _, key := range sensitiveKeys { + if key != "" { + valueRegex := regexp.MustCompile(fmt.Sprintf("(?m)%s", key)) + dump = valueRegex.ReplaceAll(dump, []byte("[redacted]")) + } + } + log.Printf("\n%s", string(dump)) + } + + resp, err := api.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + + if api.Debug { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return resp, err + } + log.Printf("\n%s", string(dump)) + } + + return resp, nil +} + +// copyHeader copies all headers for `source` and sets them on `target`. +// based on https://godoc.org/github.com/golang/gddo/httputil/header#Copy +func copyHeader(target, source http.Header) { + for k, vs := range source { + target[k] = vs + } +} + +// ResponseInfo contains a code and message returned by the API as errors or +// informational messages inside the response. +type ResponseInfo struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Response is a template. There will also be a result struct. There will be a +// unique response type for each response, which will include this type. +type Response struct { + Success bool `json:"success"` + Errors []ResponseInfo `json:"errors"` + Messages []ResponseInfo `json:"messages"` +} + +// ResultInfoCursors contains information about cursors. +type ResultInfoCursors struct { + Before string `json:"before" url:"before,omitempty"` + After string `json:"after" url:"after,omitempty"` +} + +// ResultInfo contains metadata about the Response. +type ResultInfo struct { + Page int `json:"page" url:"page,omitempty"` + PerPage int `json:"per_page" url:"per_page,omitempty"` + TotalPages int `json:"total_pages" url:"-"` + Count int `json:"count" url:"-"` + Total int `json:"total_count" url:"-"` + Cursor string `json:"cursor" url:"cursor,omitempty"` + Cursors ResultInfoCursors `json:"cursors" url:"cursors,omitempty"` +} + +// RawResponse keeps the result as JSON form. +type RawResponse struct { + Response + Result json.RawMessage `json:"result"` + ResultInfo *ResultInfo `json:"result_info,omitempty"` +} + +// Raw makes a HTTP request with user provided params and returns the +// result as a RawResponse, which contains the untouched JSON result. +func (api *API) Raw(ctx context.Context, method, endpoint string, data interface{}, headers http.Header) (RawResponse, error) { + var r RawResponse + res, err := api.makeRequestContextWithHeaders(ctx, method, endpoint, data, headers) + if err != nil { + return r, err + } + + if err := json.Unmarshal(res, &r); err != nil { + return r, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// PaginationOptions can be passed to a list request to configure paging +// These values will be defaulted if omitted, and PerPage has min/max limits set by resource. +type PaginationOptions struct { + Page int `json:"page,omitempty" url:"page,omitempty"` + PerPage int `json:"per_page,omitempty" url:"per_page,omitempty"` +} + +// RetryPolicy specifies number of retries and min/max retry delays +// This config is used when the client exponentially backs off after errored requests. +type RetryPolicy struct { + MaxRetries int + MinRetryDelay time.Duration + MaxRetryDelay time.Duration +} + +// Logger defines the interface this library needs to use logging +// This is a subset of the methods implemented in the log package. +type Logger interface { + Printf(format string, v ...interface{}) +} + +// ReqOption is a functional option for configuring API requests. +type ReqOption func(opt *reqOption) + +type reqOption struct { + params url.Values +} + +// WithZoneFilters applies a filter based on zone properties. +func WithZoneFilters(zoneName, accountID, status string) ReqOption { + return func(opt *reqOption) { + if zoneName != "" { + opt.params.Set("name", normalizeZoneName(zoneName)) + } + + if accountID != "" { + opt.params.Set("account.id", accountID) + } + + if status != "" { + opt.params.Set("status", status) + } + } +} + +// WithPagination configures the pagination for a response. +func WithPagination(opts PaginationOptions) ReqOption { + return func(opt *reqOption) { + if opts.Page > 0 { + opt.params.Set("page", strconv.Itoa(opts.Page)) + } + + if opts.PerPage > 0 { + opt.params.Set("per_page", strconv.Itoa(opts.PerPage)) + } + } +} + +// checkResultInfo checks whether ResultInfo is reasonable except that it currently +// ignores the cursor information. perPage, page, and count are the requested #items +// per page, the requested page number, and the actual length of the Result array. +// +// Responses from the actual Cloudflare servers should pass all these checks (or we +// discover a serious bug in the Cloudflare servers). However, the unit tests can +// easily violate these constraints and this utility function can help debugging. +// Correct pagination information is crucial for more advanced List* functions that +// handle pagination automatically and fetch different pages in parallel. +// +// TODO: check cursors as well. +func checkResultInfo(perPage, page, count int, info *ResultInfo) bool { + if info.Cursor != "" || info.Cursors.Before != "" || info.Cursors.After != "" { + panic("checkResultInfo could not handle cursors yet.") + } + + switch { + case info.PerPage != perPage || info.Page != page || info.Count != count: + return false + + case info.PerPage <= 0: + return false + + case info.Total == 0 && info.TotalPages == 0 && info.Page == 1 && info.Count == 0: + return true + + case info.Total <= 0 || info.TotalPages <= 0: + return false + + case info.Total > info.PerPage*info.TotalPages || info.Total <= info.PerPage*(info.TotalPages-1): + return false + } + + switch { + case info.Page > info.TotalPages || info.Page <= 0: + return false + + case info.Page < info.TotalPages: + return info.Count == info.PerPage + + case info.Page == info.TotalPages: + return info.Count == info.Total-info.PerPage*(info.TotalPages-1) + + default: + // This is actually impossible, but Go compiler does not know trichotomy + panic("checkResultInfo: impossible") + } +} + +type OrderDirection string + +const ( + OrderDirectionAsc OrderDirection = "asc" + OrderDirectionDesc OrderDirection = "desc" +) diff --git a/pkg/cloudflare-go/cloudflare_experimental.go b/pkg/cloudflare-go/cloudflare_experimental.go new file mode 100644 index 000000000..c09e803a5 --- /dev/null +++ b/pkg/cloudflare-go/cloudflare_experimental.go @@ -0,0 +1,343 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net/http" + "net/http/httputil" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/goccy/go-json" + + "github.com/hashicorp/go-retryablehttp" +) + +type service struct { + client *Client +} + +type ClientParams struct { + Key string + Email string + UserServiceKey string + Token string + STS *SecurityTokenConfiguration + BaseURL *url.URL + UserAgent string + Headers http.Header + HTTPClient *http.Client + RetryPolicy RetryPolicy + Logger LeveledLoggerInterface + Debug bool +} + +// A Client manages communication with the Cloudflare API. +type Client struct { + clientMu sync.Mutex + + *ClientParams + + common service // Reuse a single struct instead of allocating one for each service on the heap. + + Zones *ZonesService +} + +// Client returns the http.Client used by this Cloudflare client. +func (c *Client) Client() *http.Client { + c.clientMu.Lock() + defer c.clientMu.Unlock() + clientCopy := *c.HTTPClient + return &clientCopy +} + +// Call is the entrypoint to making API calls with the correct request setup. +func (c *Client) Call(ctx context.Context, method, path string, payload interface{}) ([]byte, error) { + return c.makeRequest(ctx, method, path, payload, nil) +} + +// CallWithHeaders is the entrypoint to making API calls with the correct +// request setup (like `Call`) but allows passing in additional HTTP headers +// with the request. +func (c *Client) CallWithHeaders(ctx context.Context, method, path string, payload interface{}, headers http.Header) ([]byte, error) { + return c.makeRequest(ctx, method, path, payload, headers) +} + +// New creates a new instance of the API client by merging ClientParams with the +// default values. +func NewExperimental(config *ClientParams) (*Client, error) { + c := &Client{ClientParams: &ClientParams{}} + c.common.client = c + + defaultURL, _ := url.Parse(defaultScheme + "://" + defaultHostname + defaultBasePath) + if config.BaseURL != nil { + c.ClientParams.BaseURL = config.BaseURL + } else { + c.ClientParams.BaseURL = defaultURL + } + + if config.UserAgent != "" { + c.ClientParams.UserAgent = config.UserAgent + } else { + c.ClientParams.UserAgent = userAgent + "/" + Version + } + + if config.HTTPClient != nil { + c.ClientParams.HTTPClient = config.HTTPClient + } else { + retryClient := retryablehttp.NewClient() + + if c.RetryPolicy.MaxRetries > 0 { + retryClient.RetryMax = c.RetryPolicy.MaxRetries + } else { + retryClient.RetryMax = 4 + } + + if c.RetryPolicy.MinRetryDelay > 0 { + retryClient.RetryWaitMin = c.RetryPolicy.MinRetryDelay + } else { + retryClient.RetryWaitMin = 1 * time.Second + } + + if c.RetryPolicy.MaxRetryDelay > 0 { + retryClient.RetryWaitMax = c.RetryPolicy.MaxRetryDelay + } else { + retryClient.RetryWaitMax = 30 * time.Second + } + + retryClient.Logger = silentRetryLogger + c.ClientParams.HTTPClient = retryClient.StandardClient() + } + + if config.Headers != nil { + c.ClientParams.Headers = config.Headers + } else { + c.ClientParams.Headers = make(http.Header) + } + + if config.Key != "" && config.Token != "" { + return nil, ErrAPIKeysAndTokensAreMutuallyExclusive + } + + if config.Key != "" { + c.ClientParams.Key = config.Key + c.ClientParams.Email = config.Email + } + + if config.Token != "" { + c.ClientParams.Token = config.Token + } + + if config.UserServiceKey != "" { + c.ClientParams.UserServiceKey = config.UserServiceKey + } + + c.ClientParams.Debug = config.Debug + if c.ClientParams.Debug { + c.ClientParams.Logger = &LeveledLogger{Level: 4} + } else { + c.ClientParams.Logger = SilentLeveledLogger + } + + if config.STS != nil { + stsToken, err := fetchSTSCredentials(config.STS) + if err != nil { + return nil, ErrSTSFailure + } + c.ClientParams.Token = stsToken + } + + c.Zones = (*ZonesService)(&c.common) + + return c, nil +} + +// request makes a HTTP request to the given API endpoint, returning the raw +// *http.Response, or an error if one occurred. The caller is responsible for +// closing the response body. +func (c *Client) request(ctx context.Context, method, uri string, reqBody io.Reader, headers http.Header) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL.String()+uri, reqBody) + if err != nil { + return nil, fmt.Errorf("HTTP request creation failed: %w", err) + } + + combinedHeaders := make(http.Header) + copyHeader(combinedHeaders, c.Headers) + copyHeader(combinedHeaders, headers) + req.Header = combinedHeaders + + if c.Key == "" && c.Email == "" && c.Token == "" && c.UserServiceKey == "" { + return nil, ErrMissingCredentials + } + + if c.Key != "" { + req.Header.Set("X-Auth-Key", c.ClientParams.Key) + req.Header.Set("X-Auth-Email", c.ClientParams.Email) + } + + if c.UserServiceKey != "" { + req.Header.Set("X-Auth-User-Service-Key", c.ClientParams.UserServiceKey) + } + + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.ClientParams.Token) + } + + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.ClientParams.UserAgent) + } + + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + if c.Debug { + dump, err := httputil.DumpRequestOut(req, true) + if err != nil { + return nil, err + } + + // Strip out any sensitive information from the request payload. + sensitiveKeys := []string{c.Key, c.Email, c.Token, c.UserServiceKey} + for _, key := range sensitiveKeys { + if key != "" { + valueRegex := regexp.MustCompile(fmt.Sprintf("(?m)%s", key)) + dump = valueRegex.ReplaceAll(dump, []byte("[redacted]")) + } + } + log.Printf("\n%s", string(dump)) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + + if c.Debug { + dump, err := httputil.DumpResponse(resp, true) + if err != nil { + return resp, err + } + log.Printf("\n%s", string(dump)) + } + + return resp, nil +} + +func (c *Client) makeRequest(ctx context.Context, method, uri string, params interface{}, headers http.Header) ([]byte, error) { + var reqBody io.Reader + var err error + + if params != nil { + if r, ok := params.(io.Reader); ok { + reqBody = r + } else if paramBytes, ok := params.([]byte); ok { + reqBody = bytes.NewReader(paramBytes) + } else { + var jsonBody []byte + jsonBody, err = json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("error marshalling params to JSON: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + } + + var resp *http.Response + var respErr error + var respBody []byte + + resp, respErr = c.request(ctx, method, uri, reqBody, headers) + if respErr != nil { + return nil, respErr + } + + respBody, err = io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("could not read response body: %w", err) + } + + if resp.StatusCode >= http.StatusBadRequest { + if strings.HasSuffix(resp.Request.URL.Path, "/filters/validate-expr") { + return nil, fmt.Errorf("%s", respBody) + } + + if resp.StatusCode >= http.StatusInternalServerError { + return nil, &ServiceError{cloudflareError: &Error{ + StatusCode: resp.StatusCode, + RayID: resp.Header.Get("cf-ray"), + Errors: []ResponseInfo{{ + Message: errInternalServiceError, + }}, + }} + } + + errBody := &Response{} + err = json.Unmarshal(respBody, &errBody) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) + } + + errCodes := make([]int, 0, len(errBody.Errors)) + errMsgs := make([]string, 0, len(errBody.Errors)) + for _, e := range errBody.Errors { + errCodes = append(errCodes, e.Code) + errMsgs = append(errMsgs, e.Message) + } + + err := &Error{ + StatusCode: resp.StatusCode, + RayID: resp.Header.Get("cf-ray"), + Errors: errBody.Errors, + ErrorCodes: errCodes, + ErrorMessages: errMsgs, + } + + switch resp.StatusCode { + case http.StatusUnauthorized: + err.Type = ErrorTypeAuthorization + return nil, &AuthorizationError{cloudflareError: err} + case http.StatusForbidden: + err.Type = ErrorTypeAuthentication + return nil, &AuthenticationError{cloudflareError: err} + case http.StatusNotFound: + err.Type = ErrorTypeNotFound + return nil, &NotFoundError{cloudflareError: err} + case http.StatusTooManyRequests: + err.Type = ErrorTypeRateLimit + return nil, &RatelimitError{cloudflareError: err} + default: + err.Type = ErrorTypeRequest + return nil, &RequestError{cloudflareError: err} + } + } + + return respBody, nil +} + +func (c *Client) get(ctx context.Context, path string, payload interface{}) ([]byte, error) { + return c.makeRequest(ctx, http.MethodGet, path, payload, nil) +} + +func (c *Client) post(ctx context.Context, path string, payload interface{}) ([]byte, error) { + return c.makeRequest(ctx, http.MethodPost, path, payload, nil) +} + +func (c *Client) patch(ctx context.Context, path string, payload interface{}) ([]byte, error) { + return c.makeRequest(ctx, http.MethodPatch, path, payload, nil) +} + +func (c *Client) put(ctx context.Context, path string, payload interface{}) ([]byte, error) { + return c.makeRequest(ctx, http.MethodPut, path, payload, nil) +} + +func (c *Client) delete(ctx context.Context, path string, payload interface{}) ([]byte, error) { + return c.makeRequest(ctx, http.MethodDelete, path, payload, nil) +} diff --git a/pkg/cloudflare-go/cloudflare_test.go b/pkg/cloudflare-go/cloudflare_test.go new file mode 100644 index 000000000..e7ffb09f6 --- /dev/null +++ b/pkg/cloudflare-go/cloudflare_test.go @@ -0,0 +1,549 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +var ( + // mux is the HTTP request multiplexer used with the test server. + mux *http.ServeMux + + // client is the API client being tested. + client *API + + // server is a test HTTP server used to provide mock API responses. + server *httptest.Server + + // testAccountRC is a test account resource container. + testAccountRC = AccountIdentifier(testAccountID) + + // testZoneRC is a test zone resource container. + testZoneRC = ZoneIdentifier(testZoneID) +) + +func setup(opts ...Option) { + // test server + mux = http.NewServeMux() + server = httptest.NewServer(mux) + + // disable rate limits and retries in testing - prepended so any provided value overrides this + opts = append([]Option{UsingRateLimit(100000), UsingRetryPolicy(0, 0, 0)}, opts...) + + // Cloudflare client configured to use test server + client, _ = New("deadbeef", "cloudflare@example.org", opts...) + client.BaseURL = server.URL +} + +func teardown() { + server.Close() +} + +func TestClient_Headers(t *testing.T) { + // it should set default headers + setup() + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "cloudflare@example.org", r.Header.Get("X-Auth-Email")) + assert.Equal(t, "deadbeef", r.Header.Get("X-Auth-Key")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + client.UserDetails(context.Background()) //nolint + teardown() + + // it should override appropriate default headers when custom headers given + headers := make(http.Header) + headers.Set("Content-Type", "application/xhtml+xml") + headers.Add("X-Random", "a random header") + setup(Headers(headers)) + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "cloudflare@example.org", r.Header.Get("X-Auth-Email")) + assert.Equal(t, "deadbeef", r.Header.Get("X-Auth-Key")) + assert.Equal(t, "application/xhtml+xml", r.Header.Get("Content-Type")) + assert.Equal(t, "a random header", r.Header.Get("X-Random")) + }) + client.UserDetails(context.Background()) //nolint + teardown() + + // it should set X-Auth-User-Service-Key and omit X-Auth-Email and X-Auth-Key when client.authType is AuthUserService + setup() + client.SetAuthType(AuthUserService) + client.APIUserServiceKey = "userservicekey" + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Empty(t, r.Header.Get("X-Auth-Email")) + assert.Empty(t, r.Header.Get("X-Auth-Key")) + assert.Empty(t, r.Header.Get("Authorization")) + assert.Equal(t, "userservicekey", r.Header.Get("X-Auth-User-Service-Key")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + client.UserDetails(context.Background()) //nolint + teardown() + + // it should set X-Auth-User-Service-Key and omit X-Auth-Email and X-Auth-Key when using NewWithUserServiceKey + setup() + client, err := NewWithUserServiceKey("userservicekey") + assert.NoError(t, err) + client.BaseURL = server.URL + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Empty(t, r.Header.Get("X-Auth-Email")) + assert.Empty(t, r.Header.Get("X-Auth-Key")) + assert.Empty(t, r.Header.Get("Authorization")) + assert.Equal(t, "userservicekey", r.Header.Get("X-Auth-User-Service-Key")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + client.UserDetails(context.Background()) //nolint + teardown() + + // it should set Authorization and omit others credential headers when using NewWithAPIToken + setup() + client, err = NewWithAPIToken("my-api-token") + assert.NoError(t, err) + client.BaseURL = server.URL + mux.HandleFunc("/zones/123456", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Empty(t, r.Header.Get("X-Auth-Email")) + assert.Empty(t, r.Header.Get("X-Auth-Key")) + assert.Empty(t, r.Header.Get("X-Auth-User-Service-Key")) + assert.Equal(t, "Bearer my-api-token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + }) + client.UserDetails(context.Background()) //nolint + teardown() +} + +func TestClient_RetryCanSucceedAfterErrors(t *testing.T) { + setup(UsingRetryPolicy(2, 0, 1)) + defer teardown() + + requestsReceived := 0 + // could test any function, using ListLoadBalancerPools + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + actual := LoadBalancerPool{} + assert.NoError(t, json.NewDecoder(r.Body).Decode(&actual)) + assert.Equal(t, "123", actual.ID) + w.Header().Set("content-type", "application/json") + + // we are doing three *retries* + if requestsReceived == 0 { + // return error causing client to retry + w.WriteHeader(500) + fmt.Fprint(w, `{ + "success": false, + "errors": [ "server created some error"], + "messages": [], + "result": [] + }`) + } else if requestsReceived == 1 { + // return error causing client to retry + w.WriteHeader(429) + fmt.Fprint(w, `{ + "success": false, + "errors": [ "this is a rate limiting error"], + "messages": [], + "result": [] + }`) + } else { + // return success response + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": + { + "id": "17b5962d775c646f3f9725cbc7a53df4", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Primary data center - Provider XYZ", + "name": "primary-dc-1", + "enabled": true, + "monitor": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "origins": [ + { + "name": "app-server-1", + "address": "198.51.100.1", + "enabled": true + } + ], + "notification_email": "someone@example.com" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + requestsReceived++ + } + + mux.HandleFunc("/user/load_balancers/pools", handler) + + _, err := client.CreateLoadBalancerPool(context.Background(), UserIdentifier(testUserID), CreateLoadBalancerPoolParams{LoadBalancerPool: LoadBalancerPool{ID: "123"}}) + assert.NoError(t, err) +} + +func TestClient_RetryReturnsPersistentErrorResponse(t *testing.T) { + setup(UsingRetryPolicy(2, 0, 1)) + defer teardown() + + // could test any function, using ListLoadBalancerPools + 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") + + // return error causing client to retry + w.WriteHeader(500) + fmt.Fprint(w, `{ + "success": false, + "errors": [ "server created some error"], + "messages": [], + "result": [] + }`) + } + + mux.HandleFunc("/user/load_balancers/pools", handler) + + _, err := client.ListLoadBalancerPools(context.Background(), UserIdentifier(testUserID), ListLoadBalancerPoolParams{}) + assert.Error(t, err) +} + +func TestZoneIDByNameWithNonUniqueZonesWithoutOrgID(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "example.com", + "development_mode": 7200, + "original_name_servers": [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + "original_registrar": "GoDaddy", + "original_dnshost": "NameCheap", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "owner": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "owner_type": "user" + }, + "account": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Demo Account" + }, + "permissions": [ + "#zone:read", + "#zone:edit" + ], + "plan": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "plan_pending": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "status": "active", + "paused": false, + "type": "full", + "name_servers": [ + "tony.ns.cloudflare.com", + "woz.ns.cloudflare.com" + ] + }, + { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "example.com", + "development_mode": 7200, + "original_name_servers": [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + "original_registrar": "GoDaddy", + "original_dnshost": "NameCheap", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "owner": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "owner_type": "user" + }, + "account": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Demo Account" + }, + "permissions": [ + "#zone:read", + "#zone:edit" + ], + "plan": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "plan_pending": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "status": "active", + "paused": false, + "type": "full", + "name_servers": [ + "tony.ns.cloudflare.com", + "woz.ns.cloudflare.com" + ] + } + ], + "result_info": { + "page": 1, + "per_page": 50, + "count": 2, + "total_count": 2, + "total_pages": 1 + } + } + `) + } + + // `HandleFunc` doesn't handle query parameters so we just need to + // handle the `/zones` endpoint instead. + mux.HandleFunc("/zones", handler) + + _, err := client.ZoneIDByName("example.com") + assert.EqualError(t, err, "ambiguous zone name; an account ID might help") +} + +func TestZoneIDByNameWithIDN(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "7c5dae5552338874e5053f2534d2767a", + "name": "exämple.com", + "development_mode": 7200, + "original_name_servers": [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + "original_registrar": "GoDaddy", + "original_dnshost": "NameCheap", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "owner": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@exämple.com", + "owner_type": "user" + }, + "account": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Demo Account" + }, + "permissions": [ + "#zone:read", + "#zone:edit" + ], + "plan": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "plan_pending": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "status": "active", + "paused": false, + "type": "full", + "name_servers": [ + "tony.ns.cloudflare.com", + "woz.ns.cloudflare.com" + ] + } + ], + "result_info": { + "page": 1, + "per_page": 50, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + } + `) + } + + // `HandleFunc` doesn't handle query parameters so we just need to + // handle the `/zones` endpoint instead. + mux.HandleFunc("/zones", handler) + + actual, err := client.ZoneIDByName("exämple.com") + if assert.NoError(t, err) { + assert.Equal(t, actual, "7c5dae5552338874e5053f2534d2767a") + } + + actual, err = client.ZoneIDByName("xn--exmple-cua.com") + if assert.NoError(t, err) { + assert.Equal(t, actual, "7c5dae5552338874e5053f2534d2767a") + } +} + +func TestClient_ContextIsPassedToRequest(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + httpClient := &http.Client{ + Transport: RoundTripperFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, ctx, r.Context()) + + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + return rec.Result(), nil + }), + } + + cfClient, _ := New("deadbeef", "cloudflare@example.org", HTTPClient(httpClient)) + + cfClient.ListZonesContext(ctx) //nolint +} + +func TestErrorFromResponseWithUnmarshalingError(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + w.WriteHeader(400) + fmt.Fprintf(w, `{ not valid json`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/access/apps", handler) + + _, err := client.CreateAccessApplication(context.Background(), AccountIdentifier("01a7362d577a6c3019a474fd6f485823"), CreateAccessApplicationParams{ + Name: "Admin Site", + Domain: "test.example.com/admin", + SessionDuration: "24h", + }) + + assert.Regexp(t, "error unmarshalling the JSON response error body: ", err) +} + +type RoundTripperFunc func(*http.Request) (*http.Response, error) + +func (t RoundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return t(request) +} + +func TestContextTimeout(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + } + + mux.HandleFunc("/timeout", handler) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + start := time.Now() + _, err := client.makeRequestContext(ctx, http.MethodHead, "/timeout", nil) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.WithinDuration(t, start, time.Now(), 2*time.Second, + "makeRequestContext took too much time with an expiring context") +} + +func TestCheckResultInfo(t *testing.T) { + for _, c := range [...]struct { + TestName string + PerPage int + Page int + Count int + ResultInfo ResultInfo + Verdict bool + }{ + {"per_page do not match", 20, 1, 0, ResultInfo{Page: 1, PerPage: 30, TotalPages: 0, Count: 0, Total: 0}, false}, + {"page counts do not match", 20, 2, 20, ResultInfo{Page: 1, PerPage: 20, TotalPages: 2, Count: 20, Total: 40}, false}, + {"counts do not match", 20, 1, 20, ResultInfo{Page: 1, PerPage: 20, TotalPages: 2, Count: 19, Total: 21}, false}, + {"counts do not match", 20, 1, 19, ResultInfo{Page: 1, PerPage: 20, TotalPages: 2, Count: 20, Total: 21}, false}, + {"per_page 0", 0, 1, 0, ResultInfo{Page: 1, PerPage: 0, TotalPages: 1, Count: 0, Total: 0}, false}, + {"number of items is zero", 20, 1, 0, ResultInfo{Page: 1, PerPage: 20, TotalPages: 0, Count: 0, Total: 0}, true}, + {"number of items is 0 but number of pages is greater than 0", 20, 1, 0, ResultInfo{Page: 1, PerPage: 20, TotalPages: 1, Count: 0, Total: 0}, false}, + {"total number of items greater than 0 but number of pages is 0", 20, 1, 1, ResultInfo{Page: 1, PerPage: 20, TotalPages: 0, Count: 1, Total: 1}, false}, + {"too many total number of items (one more page is needed)", 20, 1, 20, ResultInfo{Page: 1, PerPage: 20, TotalPages: 1, Count: 20, Total: 21}, false}, + {"too few total number of items (the second page would be empty)", 20, 1, 20, ResultInfo{Page: 1, PerPage: 20, TotalPages: 2, Count: 20, Total: 20}, false}, + {"page number cannot be zero", 20, 0, 20, ResultInfo{Page: 0, PerPage: 20, TotalPages: 1, Count: 20, Total: 20}, false}, + {"page number cannot go beyond number of pages", 20, 2, 20, ResultInfo{Page: 2, PerPage: 20, TotalPages: 1, Count: 20, Total: 20}, false}, + {"the last page is full of results", 20, 1, 20, ResultInfo{Page: 1, PerPage: 20, TotalPages: 1, Count: 20, Total: 20}, true}, + {"we are not on the last page so it should be full of results", 20, 1, 19, ResultInfo{Page: 1, PerPage: 20, TotalPages: 2, Count: 19, Total: 39}, false}, + {"last page only has 19 items not 20", 20, 2, 20, ResultInfo{Page: 2, PerPage: 20, TotalPages: 2, Count: 20, Total: 39}, false}, + {"fully working result info", 20, 2, 19, ResultInfo{Page: 2, PerPage: 20, TotalPages: 2, Count: 19, Total: 39}, true}, + } { + t.Run(c.TestName, func(t *testing.T) { + assert.Equal(t, c.Verdict, checkResultInfo(c.PerPage, c.Page, c.Count, &c.ResultInfo)) + }) + } +} diff --git a/pkg/cloudflare-go/cmd/flarectl/.goreleaser.yml b/pkg/cloudflare-go/cmd/flarectl/.goreleaser.yml new file mode 100644 index 000000000..131b233f2 --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/.goreleaser.yml @@ -0,0 +1,23 @@ +before: + hooks: + - cp ../../LICENSE . + - go mod download +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm + - arm64 + binary: flarectl + mod_timestamp: '{{ .CommitTimestamp }}' +archives: + - name_template: "{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}{{ if .Mips }}_{{ .Mips }}{{ end }}" +checksum: + disable: true +changelog: + disable: true diff --git a/pkg/cloudflare-go/cmd/flarectl/README.md b/pkg/cloudflare-go/cmd/flarectl/README.md new file mode 100644 index 000000000..8bbdce4ca --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/README.md @@ -0,0 +1,108 @@ +# flarectl + +A CLI application for interacting with a Cloudflare account. Powered by [cloudflare-go](https://github.com/cloudflare/cloudflare-go). + +## Installation + +Install it when you install our command-line library: + +```sh +go install github.com/cloudflare/cloudflare-go/cmd/flarectl@latest +``` + +# Usage + +You must authenticate with Cloudflare using either an API Token or API Key. + +To use an API Token, set the `CF_API_TOKEN` environment variable: + +``` +$ export CF_API_TOKEN=Abc123Xyz +``` + +To use an API Key, set the `CF_API_KEY` and `CF_API_EMAIL` environment variables: + +``` +$ export CF_API_KEY=abcdef1234567890 +$ export CF_API_EMAIL=someone@example.com +``` + +Once authenticated, you can run flarectl commands: + +``` +$ flarectl: + + flarectl - Cloudflare CLI + +USAGE: + flarectl [global options] command [command options] [arguments...] + +VERSION: + 2017.10.0 + +COMMANDS: + ips, i Print Cloudflare IP ranges + user, u User information + zone, z Zone information + dns, d DNS records + user-agents, ua User-Agent blocking + pagerules, p Page Rules + railgun, r Railgun information + firewall, f Firewall + origin-ca-root-cert, ocrc Print Origin CA Root Certificate (in PEM format) + help, h Shows a list of commands or help for one command + +GLOBAL OPTIONS: + --help, -h show help + --version, -v print the version + +``` + +## Examples + +## Block an IP via the IP Firewall + +```sh +flarectl firewall rules create --zone="example.com" --value="8.8.8.8" --mode="block" --notes="Block bad actor" + +ID Value Scope Mode Notes +-------------------------------- ------- ----- ----- ---------------- +7bc6fa4569f78777039ef5ebd7b4cedd 8.8.8.8 zone block Block bad actor +``` + +### List Firewall Rules + +```sh +~ flarectl firewall rules list + +ID Value Scope Mode Notes +-------------------------------- --------------- ----- --------- ----- +210173b610198c8ce3dfe39987e4df78 8.8.8.8 user whitelist +36e86aebff4cb8cb2020e622c2ff2b90 8.8.4.4 user whitelist +ba6bea6e646e2d453c394a41c6ab931a 45.55.2.6 user whitelist +edff311e3f81b35e9cd64e4fa9d18465 45.55.2.5 user whitelist +``` + +### Challenge All Requests for a specific User-Agent + +``` +~ flarectl ua create --zone="example.com" --mode="challenge" --description="Challenge Chrome v61" --value="Mozilla/5.0 (Macintosh Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML like Gecko) Chrome/61.0.3163.100 Safari/537.36" + +ID Description Mode Value Paused +-------------------------------- -------------------- --------- --------------------------------------------------------------------------------------------------------------------- ------ +a23b50de3c064a5a860e8b84cd2b382c Challenge Chrome v61 challenge Mozilla/5.0 (Macintosh Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML like Gecko) Chrome/61.0.3163.100 Safari/537.36 false +``` + +### Add a DNS record + +```sh +~ flarectl dns create --zone="example.com" --name="app" --type="CNAME" --content="myapp.herokuapp.com" --proxy + +ID Name Type Content TTL Proxiable Proxy +-------------------------------- ------------------------- ----- ------------------- --- --------- ----- +5c5d051f7944cf4715127270dd4d05f4 app.questionable.services CNAME myapp.herokuapp.com 1 true true +``` + +## License + +BSD licensed. See the [LICENSE](LICENSE) file for details. diff --git a/pkg/cloudflare-go/cmd/flarectl/dns.go b/pkg/cloudflare-go/cmd/flarectl/dns.go new file mode 100644 index 000000000..84c57095d --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/dns.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/cloudflare/cloudflare-go" + "github.com/urfave/cli/v2" +) + +func formatDNSRecord(record cloudflare.DNSRecord) []string { + return []string{ + record.ID, + record.Name, + record.Type, + record.Content, + strconv.FormatInt(int64(record.TTL), 10), + strconv.FormatBool(record.Proxiable), + strconv.FormatBool(*record.Proxied), + } +} + +func dnsCreate(c *cli.Context) error { + if err := checkFlags(c, "zone", "name", "type", "content"); err != nil { + return err + } + zone := c.String("zone") + name := c.String("name") + rtype := c.String("type") + content := c.String("content") + ttl := c.Int("ttl") + proxy := c.Bool("proxy") + priority := uint16(c.Uint("priority")) + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + record := cloudflare.CreateDNSRecordParams{ + Name: name, + Type: strings.ToUpper(rtype), + Content: content, + TTL: ttl, + Proxied: &proxy, + Priority: &priority, + } + result, err := api.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), record) + if err != nil { + fmt.Fprintln(os.Stderr, "Error creating DNS record: ", err) + return err + } + + output := [][]string{ + formatDNSRecord(result), + } + + writeTable(c, output, "ID", "Name", "Type", "Content", "TTL", "Proxiable", "Proxy") + + return nil +} + +func dnsCreateOrUpdate(c *cli.Context) error { + if err := checkFlags(c, "zone", "name", "type", "content"); err != nil { + fmt.Println(err) + return err + } + zone := c.String("zone") + name := c.String("name") + rtype := strings.ToUpper(c.String("type")) + content := c.String("content") + ttl := c.Int("ttl") + proxy := c.Bool("proxy") + priority := uint16(c.Uint("priority")) + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Fprintln(os.Stderr, "Error updating DNS record: ", err) + return err + } + + records, _, err := api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{Name: name + "." + zone}) + if err != nil { + fmt.Fprintln(os.Stderr, "Error fetching DNS records: ", err) + return err + } + + var result cloudflare.DNSRecord + if len(records) > 0 { + // Record exists - find the ID and update it. + // This is imprecise without knowing the original content; if a label + // has multiple RRs we'll just update the first one. + for _, r := range records { + if r.Type == rtype { + rr := cloudflare.UpdateDNSRecordParams{} + rr.ID = r.ID + rr.Type = r.Type + rr.Content = content + rr.TTL = ttl + rr.Proxied = &proxy + rr.Priority = &priority + + result, err = api.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), rr) + if err != nil { + fmt.Println("Error updating DNS record:", err) + return err + } + } + } + } else { + // Record doesn't exist - create it + rr := cloudflare.CreateDNSRecordParams{ + Name: name, + Type: rtype, + Content: content, + TTL: ttl, + Proxied: &proxy, + Priority: &priority, + } + + // TODO: Print the response. + result, err = api.CreateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), rr) + if err != nil { + fmt.Println("Error creating DNS record:", err) + return err + } + } + + output := [][]string{ + formatDNSRecord(result), + } + + writeTable(c, output, "ID", "Name", "Type", "Content", "TTL", "Proxiable", "Proxy") + + return nil +} + +func dnsUpdate(c *cli.Context) error { + if err := checkFlags(c, "zone", "id"); err != nil { + fmt.Println(err) + return err + } + zone := c.String("zone") + recordID := c.String("id") + name := c.String("name") + rtype := c.String("type") + content := c.String("content") + ttl := c.Int("ttl") + proxy := c.Bool("proxy") + priority := uint16(c.Uint("priority")) + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + record := cloudflare.UpdateDNSRecordParams{ + ID: recordID, + Name: name, + Type: strings.ToUpper(rtype), + Content: content, + TTL: ttl, + Proxied: &proxy, + Priority: &priority, + } + _, err = api.UpdateDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), record) + if err != nil { + fmt.Fprintln(os.Stderr, "Error updating DNS record: ", err) + return err + } + + return nil +} + +func dnsDelete(c *cli.Context) error { + if err := checkFlags(c, "zone", "id"); err != nil { + fmt.Println(err) + return err + } + zone := c.String("zone") + recordID := c.String("id") + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + err = api.DeleteDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), recordID) + if err != nil { + fmt.Fprintln(os.Stderr, "Error deleting DNS record: ", err) + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/cmd/flarectl/firewall.go b/pkg/cloudflare-go/cmd/flarectl/firewall.go new file mode 100644 index 000000000..8003ee78f --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/firewall.go @@ -0,0 +1,380 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "strconv" + + "github.com/cloudflare/cloudflare-go" + "github.com/urfave/cli/v2" +) + +func formatAccessRule(rule cloudflare.AccessRule) []string { + return []string{ + rule.ID, + rule.Configuration.Value, + rule.Scope.Type, + rule.Mode, + rule.Notes, + } +} + +func firewallAccessRules(c *cli.Context) error { + accountID, zoneID, err := getScope(c) + if err != nil { + return err + } + + // Create an empty access rule for searching for rules + rule := cloudflare.AccessRule{ + Configuration: getConfiguration(c), + } + if c.String("scope-type") != "" { + rule.Scope.Type = c.String("scope-type") + } + if c.String("notes") != "" { + rule.Notes = c.String("notes") + } + if c.String("mode") != "" { + rule.Mode = c.String("mode") + } + + var response *cloudflare.AccessRuleListResponse + switch { + case accountID != "": + response, err = api.ListAccountAccessRules(context.Background(), accountID, rule, 1) + case zoneID != "": + response, err = api.ListZoneAccessRules(context.Background(), zoneID, rule, 1) + default: + response, err = api.ListUserAccessRules(context.Background(), rule, 1) + } + if err != nil { + fmt.Println(err) + return err + } + totalPages := response.ResultInfo.TotalPages + rules := make([]cloudflare.AccessRule, 0, response.ResultInfo.Total) + rules = append(rules, response.Result...) + if totalPages > 1 { + for page := 2; page <= totalPages; page++ { + switch { + case accountID != "": + response, err = api.ListAccountAccessRules(context.Background(), accountID, rule, page) + case zoneID != "": + response, err = api.ListZoneAccessRules(context.Background(), zoneID, rule, page) + default: + response, err = api.ListUserAccessRules(context.Background(), rule, page) + } + if err != nil { + fmt.Println(err) + return err + } + rules = append(rules, response.Result...) + } + } + + output := make([][]string, 0, len(rules)) + for _, rule := range rules { + output = append(output, formatAccessRule(rule)) + } + writeTable(c, output, "ID", "Value", "Scope", "Mode", "Notes") + + return nil +} + +func firewallAccessRuleCreate(c *cli.Context) error { + if err := checkFlags(c, "mode", "value"); err != nil { + fmt.Println(err) + return err + } + accountID, zoneID, err := getScope(c) + if err != nil { + return err + } + configuration := getConfiguration(c) + mode := c.String("mode") + notes := c.String("notes") + + rule := cloudflare.AccessRule{ + Configuration: configuration, + Mode: mode, + Notes: notes, + } + + var ( + rules []cloudflare.AccessRule + ) + + switch { + case accountID != "": + resp, err := api.CreateAccountAccessRule(context.Background(), accountID, rule) + if err != nil { + fmt.Fprintln(os.Stderr, "error creating account access rule: ", err) + return err + } + rules = append(rules, resp.Result) + case zoneID != "": + resp, err := api.CreateZoneAccessRule(context.Background(), zoneID, rule) + if err != nil { + fmt.Fprintln(os.Stderr, "error creating zone access rule: ", err) + return err + } + rules = append(rules, resp.Result) + default: + resp, err := api.CreateUserAccessRule(context.Background(), rule) + if err != nil { + fmt.Fprintln(os.Stderr, "error creating user access rule: ", err) + return err + } + rules = append(rules, resp.Result) + } + + output := make([][]string, 0, len(rules)) + for _, rule := range rules { + output = append(output, formatAccessRule(rule)) + } + writeTable(c, output, "ID", "Value", "Scope", "Mode", "Notes") + + return nil +} + +func firewallAccessRuleUpdate(c *cli.Context) error { + if err := checkFlags(c, "id"); err != nil { + fmt.Println(err) + return err + } + id := c.String("id") + accountID, zoneID, err := getScope(c) + if err != nil { + return err + } + mode := c.String("mode") + notes := c.String("notes") + + rule := cloudflare.AccessRule{ + Mode: mode, + Notes: notes, + } + + var ( + rules []cloudflare.AccessRule + errUpdating = "error updating firewall access rule" + ) + switch { + case accountID != "": + resp, err := api.UpdateAccountAccessRule(context.Background(), accountID, id, rule) + if err != nil { + return fmt.Errorf(errUpdating+": %w", err) + } + rules = append(rules, resp.Result) + case zoneID != "": + resp, err := api.UpdateZoneAccessRule(context.Background(), zoneID, id, rule) + if err != nil { + return fmt.Errorf(errUpdating+": %w", err) + } + rules = append(rules, resp.Result) + default: + resp, err := api.UpdateUserAccessRule(context.Background(), id, rule) + if err != nil { + return fmt.Errorf(errUpdating+": %w", err) + } + rules = append(rules, resp.Result) + } + + output := make([][]string, 0, len(rules)) + for _, rule := range rules { + output = append(output, formatAccessRule(rule)) + } + writeTable(c, output, "ID", "Value", "Scope", "Mode", "Notes") + + return nil +} + +func firewallAccessRuleCreateOrUpdate(c *cli.Context) error { + if err := checkFlags(c, "mode", "value"); err != nil { + fmt.Println(err) + return err + } + accountID, zoneID, err := getScope(c) + if err != nil { + return err + } + configuration := getConfiguration(c) + mode := c.String("mode") + notes := c.String("notes") + + // Look for an existing record + rule := cloudflare.AccessRule{ + Configuration: configuration, + } + var response *cloudflare.AccessRuleListResponse + switch { + case accountID != "": + response, err = api.ListAccountAccessRules(context.Background(), accountID, rule, 1) + case zoneID != "": + response, err = api.ListZoneAccessRules(context.Background(), zoneID, rule, 1) + default: + response, err = api.ListUserAccessRules(context.Background(), rule, 1) + } + if err != nil { + fmt.Println("Error creating or updating firewall access rule:", err) + return err + } + + rule.Mode = mode + rule.Notes = notes + if len(response.Result) > 0 { + for _, r := range response.Result { + if mode == "" { + rule.Mode = r.Mode + } + if notes == "" { + rule.Notes = r.Notes + } + switch { + case accountID != "": + _, err = api.UpdateAccountAccessRule(context.Background(), accountID, r.ID, rule) + case zoneID != "": + _, err = api.UpdateZoneAccessRule(context.Background(), zoneID, r.ID, rule) + default: + _, err = api.UpdateUserAccessRule(context.Background(), r.ID, rule) + } + if err != nil { + fmt.Println("Error updating firewall access rule:", err) + } + } + } else { + switch { + case accountID != "": + _, err = api.CreateAccountAccessRule(context.Background(), accountID, rule) + case zoneID != "": + _, err = api.CreateZoneAccessRule(context.Background(), zoneID, rule) + default: + _, err = api.CreateUserAccessRule(context.Background(), rule) + } + if err != nil { + fmt.Println("Error creating firewall access rule:", err) + } + } + + return nil +} + +func firewallAccessRuleDelete(c *cli.Context) error { + if err := checkFlags(c, "id"); err != nil { + fmt.Println(err) + return err + } + ruleID := c.String("id") + + accountID, zoneID, err := getScope(c) + if err != nil { + return err + } + + var ( + rules []cloudflare.AccessRule + errDeleting = "error deleting firewall access rule" + ) + switch { + case accountID != "": + resp, err := api.DeleteAccountAccessRule(context.Background(), accountID, ruleID) + if err != nil { + return fmt.Errorf(errDeleting+": %w", err) + } + rules = append(rules, resp.Result) + case zoneID != "": + resp, err := api.DeleteZoneAccessRule(context.Background(), zoneID, ruleID) + if err != nil { + return fmt.Errorf(errDeleting+": %w", err) + } + rules = append(rules, resp.Result) + default: + resp, err := api.DeleteUserAccessRule(context.Background(), ruleID) + if err != nil { + return fmt.Errorf(errDeleting+": %w", err) + } + rules = append(rules, resp.Result) + } + if err != nil { + fmt.Println("Error deleting firewall access rule:", err) + } + + output := make([][]string, 0, len(rules)) + for _, rule := range rules { + output = append(output, formatAccessRule(rule)) + } + writeTable(c, output, "ID", "Value", "Scope", "Mode", "Notes") + + return nil +} + +func getScope(c *cli.Context) (string, string, error) { + var account, accountID string + if c.String("account") != "" { + account = c.String("account") + params := cloudflare.AccountsListParams{} + accounts, _, err := api.Accounts(context.Background(), params) + if err != nil { + fmt.Println(err) + return "", "", err + } + for _, acc := range accounts { + if acc.Name == account { + accountID = acc.ID + break + } + } + if accountID == "" { + err := errors.New("account could not be found") + fmt.Println(err) + return "", "", err + } + } + + var zone, zoneID string + if c.String("zone") != "" { + zone = c.String("zone") + id, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return "", "", err + } + zoneID = id + } + + if zoneID != "" && accountID != "" { + err := errors.New("Cannot specify both --zone and --account") + fmt.Println(err) + return "", "", err + } + + return accountID, zoneID, nil +} + +func getConfiguration(c *cli.Context) cloudflare.AccessRuleConfiguration { + configuration := cloudflare.AccessRuleConfiguration{} + if c.String("value") != "" { + ip := net.ParseIP(c.String("value")) + _, cidr, cidrErr := net.ParseCIDR(c.String("value")) + _, asnErr := strconv.ParseInt(c.String("value"), 10, 32) + if ip != nil { + configuration.Target = "ip" + configuration.Value = ip.String() + } else if cidrErr == nil { + cidr.IP = cidr.IP.Mask(cidr.Mask) + configuration.Target = "ip_range" + configuration.Value = cidr.String() + } else if asnErr == nil { + configuration.Target = "asn" + configuration.Value = c.String("value") + } else { + configuration.Target = "country" + configuration.Value = c.String("value") + } + } + return configuration +} diff --git a/pkg/cloudflare-go/cmd/flarectl/flarectl.go b/pkg/cloudflare-go/cmd/flarectl/flarectl.go new file mode 100644 index 000000000..5cec7e396 --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/flarectl.go @@ -0,0 +1,720 @@ +package main + +import ( + "os" + + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/urfave/cli/v2" +) + +var ( + version = "dev" //nolint + commit = "none" //nolint + date = "unknown" //nolint + builtBy = "unknown" //nolint +) + +var api *cloudflare.API + +func main() { + app := cli.NewApp() + app.Name = "flarectl" + app.Usage = "Cloudflare CLI" + app.Version = version + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "account-id", + Usage: "Optional account ID", + Value: "", + EnvVars: []string{"CF_ACCOUNT_ID"}, + }, + &cli.BoolFlag{ + Name: "json", + Usage: "show output as JSON instead of as a table", + }, + } + app.Commands = []*cli.Command{ + { + Name: "ips", + Aliases: []string{"i"}, + Action: ips, + Usage: "Print Cloudflare IP ranges", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "ip-type", + Usage: "type of IPs ( ipv4 | ipv6 | all )", + Value: "all", + }, + &cli.BoolFlag{ + Name: "ip-only", + Usage: "show only addresses", + }, + }, + }, + { + Name: "user", + Aliases: []string{"u"}, + Usage: "User information", + Before: initializeAPI, + Subcommands: []*cli.Command{ + { + Name: "info", + Aliases: []string{"i"}, + Action: userInfo, + Usage: "User details", + }, + { + Name: "update", + Aliases: []string{"u"}, + Action: userUpdate, + Usage: "Update user details", + }, + }, + }, + + { + Name: "zone", + Aliases: []string{"z"}, + Usage: "Zone information", + Before: initializeAPI, + Subcommands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"l"}, + Action: zoneList, + Usage: "List all zones on an account", + }, + { + Name: "create", + Aliases: []string{"c"}, + Action: zoneCreate, + Usage: "Create a new zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.BoolFlag{ + Name: "jumpstart", + Usage: "automatically fetch DNS records", + }, + &cli.StringFlag{ + Name: "account-id", + Usage: "account ID", + }, + }, + }, + { + Name: "delete", + Action: zoneDelete, + Usage: "Delete a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + }, + }, + { + Name: "check", + Action: zoneCheck, + Usage: "Initiate a zone activation check", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + }, + }, + { + Name: "info", + Aliases: []string{"i"}, + Action: zoneInfo, + Usage: "Information on one zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + }, + }, + { + Name: "lockdown", + Aliases: []string{"lo"}, + Action: zoneCreateLockdown, + Usage: "Lockdown a zone based on config", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringSliceFlag{ + Name: "urls", + Usage: "a list of [exact] URLs to lockdown", + }, + &cli.StringSliceFlag{ + Name: "targets", + Usage: "a list of targets type", + }, + &cli.StringSliceFlag{ + Name: "values", + Usage: "a list of values such as ip, ip_range etc.", + }, + }, + }, + { + Name: "plan", + Aliases: []string{"p"}, + Action: zonePlan, + Usage: "Plan information for one zone", + }, + { + Name: "settings", + Aliases: []string{"s"}, + Action: zoneSettings, + Usage: "Settings for one zone", + }, + { + Name: "purge", + Action: zoneCachePurge, + Usage: "(Selectively) Purge the cache for a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.BoolFlag{ + Name: "everything", + Usage: "purge everything from cache for the zone", + }, + &cli.StringSliceFlag{ + Name: "hosts", + Usage: "a list of hostnames to purge the cache for", + }, + &cli.StringSliceFlag{ + Name: "tags", + Usage: "the cache tags to purge (Enterprise only)", + }, + &cli.StringSliceFlag{ + Name: "files", + Usage: "a list of [exact] URLs to purge", + }, + &cli.StringSliceFlag{ + Name: "prefixes", + Usage: "a list of host/path prefixes to purge", + }, + }, + }, + { + Name: "dns", + Aliases: []string{"d"}, + Action: zoneRecords, + Usage: "DNS records for a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + }, + }, + { + Name: "railgun", + Aliases: []string{"r"}, + Action: zoneRailgun, + Usage: "Railguns for a zone", + }, + { + Name: "certs", + Aliases: []string{"ct"}, + Action: zoneCerts, + Usage: "Custom SSL certificates for a zone", + }, + { + Name: "keyless", + Aliases: []string{"k"}, + Action: zoneKeyless, + Usage: "Keyless SSL for a zone", + }, + { + Name: "export", + Aliases: []string{"x"}, + Action: zoneExport, + Usage: "Export DNS records for a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + }, + }, + }, + }, + + { + Name: "dns", + Aliases: []string{"d"}, + Usage: "DNS records", + Before: initializeAPI, + Subcommands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"l"}, + Action: zoneRecords, + Usage: "List DNS records for a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "record id", + }, + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "type", + Usage: "record type", + }, + &cli.StringFlag{ + Name: "name", + Usage: "record name", + }, + &cli.StringFlag{ + Name: "content", + Usage: "record content", + }, + }, + }, + { + Name: "create", + Aliases: []string{"c"}, + Action: dnsCreate, + Usage: "Create a DNS record", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "name", + Usage: "record name", + }, + &cli.StringFlag{ + Name: "type", + Usage: "record type", + }, + &cli.StringFlag{ + Name: "content", + Usage: "record content", + }, + &cli.IntFlag{ + Name: "ttl", + Usage: "TTL (1 = automatic)", + Value: 1, + }, + &cli.BoolFlag{ + Name: "proxy", + Usage: "proxy through Cloudflare (orange cloud)", + }, + &cli.UintFlag{ + Name: "priority", + Usage: "priority for an MX record. Only used for MX", + }, + }, + }, + { + Name: "update", + Aliases: []string{"u"}, + Action: dnsUpdate, + Usage: "Update a DNS record", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "id", + Usage: "record id", + }, + &cli.StringFlag{ + Name: "name", + Usage: "record name", + }, + &cli.StringFlag{ + Name: "content", + Usage: "record content", + }, + &cli.StringFlag{ + Name: "type", + Usage: "record type", + }, + &cli.IntFlag{ + Name: "ttl", + Usage: "TTL (1 = automatic)", + Value: 1, + }, + &cli.BoolFlag{ + Name: "proxy", + Usage: "proxy through Cloudflare (orange cloud)", + }, + &cli.UintFlag{ + Name: "priority", + Usage: "priority for an MX record. Only used for MX", + }, + }, + }, + { + Name: "create-or-update", + Aliases: []string{"o"}, + Action: dnsCreateOrUpdate, + Usage: "Create a DNS record, or update if it exists", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "name", + Usage: "record name", + }, + &cli.StringFlag{ + Name: "content", + Usage: "record content", + }, + &cli.StringFlag{ + Name: "type", + Usage: "record type", + }, + &cli.IntFlag{ + Name: "ttl", + Usage: "TTL (1 = automatic)", + Value: 1, + }, + &cli.BoolFlag{ + Name: "proxy", + Usage: "proxy through Cloudflare (orange cloud)", + }, + &cli.UintFlag{ + Name: "priority", + Usage: "priority for an MX record. Only used for MX", + }, + }, + }, + { + Name: "delete", + Aliases: []string{"d"}, + Action: dnsDelete, + Usage: "Delete a DNS record", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "id", + Usage: "record id", + }, + }, + }, + }, + }, + { + Name: "user-agents", + Aliases: []string{"ua"}, + Usage: "User-Agent blocking", + Before: initializeAPI, + Subcommands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"l"}, + Action: userAgentList, + Usage: "List User-Agent blocks for a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.IntFlag{ + Name: "page", + Usage: "result page to return", + }, + }, + }, + { + Name: "create", + Aliases: []string{"c"}, + Action: userAgentCreate, + Usage: "Create a User-Agent blocking rule", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "mode", + Usage: "the blocking mode: block, challenge, js_challenge, whitelist", + }, + &cli.StringFlag{ + Name: "value", + Usage: "the exact User-Agent to block", + }, + &cli.BoolFlag{ + Name: "paused", + Usage: "whether the rule should be paused (default: false)", + }, + &cli.StringFlag{ + Name: "description", + Usage: "a description for the rule", + }, + }, + }, + { + Name: "update", + Aliases: []string{"u"}, + Action: userAgentUpdate, + Usage: "Update an existing User-Agent block", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "id", + Usage: "User-Agent blocking rule ID", + }, + &cli.StringFlag{ + Name: "mode", + Usage: "the blocking mode: block, challenge, js_challenge, whitelist", + }, + &cli.StringFlag{ + Name: "value", + Usage: "the exact User-Agent to block", + }, + &cli.BoolFlag{ + Name: "paused", + Usage: "whether the rule should be paused (default: false)", + }, + &cli.StringFlag{ + Name: "description", + Usage: "a description for the rule", + }, + }, + }, + { + Name: "delete", + Aliases: []string{"d"}, + Action: userAgentDelete, + Usage: "Delete a User-Agent block", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "id", + Usage: "User-Agent blocking rule ID", + }, + }, + }, + }, + }, + { + Name: "pagerules", + Aliases: []string{"p"}, + Usage: "Page Rules", + Before: initializeAPI, + Subcommands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"l"}, + Action: pageRules, + Usage: "List Page Rules for a zone", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + }, + }, + }, + }, + + { + Name: "railgun", + Aliases: []string{"r"}, + Usage: "Railgun information", + Before: initializeAPI, + Action: railgun, + }, + + { + Name: "firewall", + Aliases: []string{"f"}, + Usage: "Firewall", + Before: initializeAPI, + Subcommands: []*cli.Command{ + { + Name: "rules", + Aliases: []string{"r"}, + Usage: "Access Rules", + Subcommands: []*cli.Command{ + { + Name: "list", + Aliases: []string{"l"}, + Action: firewallAccessRules, + Usage: "List firewall access rules", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "account", + Usage: "account name", + }, + &cli.StringFlag{ + Name: "value", + Usage: "rule value", + }, + &cli.StringFlag{ + Name: "scope-type", + Usage: "rule scope", + }, + + &cli.StringFlag{ + Name: "mode", + Usage: "rule mode", + }, + &cli.StringFlag{ + Name: "notes", + Usage: "rule notes", + }, + }, + }, + { + Name: "create", + Aliases: []string{"c"}, + Action: firewallAccessRuleCreate, + Usage: "Create a firewall access rule", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "account", + Usage: "account name", + }, + &cli.StringFlag{ + Name: "value", + Usage: "rule value", + }, + &cli.StringFlag{ + Name: "mode", + Usage: "rule mode", + }, + &cli.StringFlag{ + Name: "notes", + Usage: "rule notes", + }, + }, + }, + { + Name: "update", + Aliases: []string{"u"}, + Action: firewallAccessRuleUpdate, + Usage: "Update a firewall access rule", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "rule id", + }, + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "account", + Usage: "account name", + }, + &cli.StringFlag{ + Name: "mode", + Usage: "rule mode", + }, + &cli.StringFlag{ + Name: "notes", + Usage: "rule notes", + }, + }, + }, + { + Name: "create-or-update", + Aliases: []string{"o"}, + Action: firewallAccessRuleCreateOrUpdate, + Usage: "Create a firewall access rule, or update it if it exists", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "account", + Usage: "account name", + }, + &cli.StringFlag{ + Name: "value", + Usage: "rule value", + }, + &cli.StringFlag{ + Name: "mode", + Usage: "rule mode", + }, + &cli.StringFlag{ + Name: "notes", + Usage: "rule notes", + }, + }, + }, + { + Name: "delete", + Aliases: []string{"d"}, + Action: firewallAccessRuleDelete, + Usage: "Delete a firewall access rule", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "id", + Usage: "rule id", + }, + &cli.StringFlag{ + Name: "zone", + Usage: "zone name", + }, + &cli.StringFlag{ + Name: "account", + Usage: "account name", + }, + }, + }, + }, + }, + }, + }, + { + Name: "origin-ca-root-cert", + Aliases: []string{"ocrc"}, + Action: originCARootCertificate, + Usage: "Print Origin CA Root Certificate (in PEM format)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "algorithm", + Usage: "certificate algorithm ( ecc | rsa )", + Required: true, + }, + }, + }, + } + err := app.Run(os.Args) + if err != nil { + os.Exit(1) + } + os.Exit(0) +} diff --git a/pkg/cloudflare-go/cmd/flarectl/misc.go b/pkg/cloudflare-go/cmd/flarectl/misc.go new file mode 100644 index 000000000..d6d4357c3 --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/misc.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/goccy/go-json" + "github.com/olekukonko/tablewriter" + "github.com/urfave/cli/v2" +) + +func initializeAPI(c *cli.Context) error { + apiToken := os.Getenv("CF_API_TOKEN") + apiKey := os.Getenv("CF_API_KEY") + apiEmail := os.Getenv("CF_API_EMAIL") + + // Be aware the following code sets the global package `api` variable + var err error + + if apiToken != "" { + api, err = cloudflare.NewWithAPIToken(apiToken) + } else { + if apiKey == "" { + err := errors.New("No CF_API_KEY or CF_API_TOKEN environment set") + fmt.Fprintln(os.Stderr, err) + return err + } + + if apiEmail == "" { + err := errors.New("No CF_API_EMAIL environment set") + fmt.Fprintln(os.Stderr, err) + return err + } + + api, err = cloudflare.New(apiKey, apiEmail) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "cloudflare api: %s", err) + return err + } + + return nil +} + +// writeTableTabular outputs tabular data to STDOUT. +func writeTableTabular(data [][]string, cols ...string) { + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader(cols) + table.SetBorder(false) + table.AppendBulk(data) + + table.Render() +} + +// writeTableJSON outputs JSON data to STDOUT. +func writeTableJSON(data [][]string, cols ...string) { + mappedData := make([]map[string]string, 0) + for i := range data { + rowData := make(map[string]string) + for j := range data[i] { + rowData[cols[j]] = data[i][j] + } + mappedData = append(mappedData, rowData) + } + jsonData, err := json.Marshal(mappedData) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(string(jsonData)) +} + +// writeTable outputs JSON or tabular data to STDOUT. +func writeTable(c *cli.Context, data [][]string, cols ...string) { + if c.Bool("json") { + writeTableJSON(data, cols...) + } else { + writeTableTabular(data, cols...) + } +} + +// Utility function to check if CLI flags were given. +func checkFlags(c *cli.Context, flags ...string) error { + for _, flag := range flags { + if c.String(flag) == "" { + cli.ShowSubcommandHelp(c) // nolint + err := fmt.Errorf("error: the required flag %q was empty or not provided", flag) + fmt.Fprintln(os.Stderr, err) + return err + } + } + + return nil +} + +func ips(c *cli.Context) error { + if c.String("ip-type") == "all" { + _getIps("ipv4", c.Bool("ip-only")) + _getIps("ipv6", c.Bool("ip-only")) + } else { + _getIps(c.String("ip-type"), c.Bool("ip-only")) + } + + return nil +} + +func _getIps(ipType string, showMsgType bool) { + ips, _ := cloudflare.IPs() + + switch ipType { + case "ipv4": + if showMsgType { + fmt.Println("IPv4 ranges:") + } + for _, r := range ips.IPv4CIDRs { + fmt.Println(" ", r) + } + case "ipv6": + if showMsgType { + fmt.Println("IPv6 ranges:") + } + for _, r := range ips.IPv6CIDRs { + fmt.Println(" ", r) + } + } +} + +func userInfo(c *cli.Context) error { + user, err := api.UserDetails(context.Background()) + if err != nil { + fmt.Println(err) + return err + } + var output [][]string + output = append(output, []string{ + user.ID, + user.Email, + user.Username, + user.FirstName + " " + user.LastName, + fmt.Sprintf("%t", user.TwoFA), + }) + writeTable(c, output, "ID", "Email", "Username", "Name", "2FA") + + return nil +} + +func userUpdate(*cli.Context) error { + return nil +} + +func pageRules(c *cli.Context) error { + if err := checkFlags(c, "zone"); err != nil { + return err + } + zone := c.String("zone") + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + rules, err := api.ListPageRules(context.Background(), zoneID) + if err != nil { + fmt.Println(err) + return err + } + + fmt.Printf("%3s %-32s %-8s %s\n", "Pri", "ID", "Status", "URL") + for _, r := range rules { + var settings []string + fmt.Printf("%3d %s %-8s %s\n", r.Priority, r.ID, r.Status, r.Targets[0].Constraint.Value) + for _, a := range r.Actions { + var s string + switch v := a.Value.(type) { + case int: + s = fmt.Sprintf("%s: %d", cloudflare.PageRuleActions[a.ID], v) + case float64: + s = fmt.Sprintf("%s: %.f", cloudflare.PageRuleActions[a.ID], v) + case map[string]interface{}: + s = fmt.Sprintf("%s: %.f - %s", cloudflare.PageRuleActions[a.ID], v["status_code"], v["url"]) + case nil: + s = cloudflare.PageRuleActions[a.ID] + default: + vs := fmt.Sprintf("%s", v) + s = fmt.Sprintf("%s: %s", cloudflare.PageRuleActions[a.ID], strings.Title(strings.Replace(vs, "_", " ", -1))) + } + settings = append(settings, s) + } + fmt.Println(" ", strings.Join(settings, ", ")) + } + + return nil +} + +func originCARootCertificate(c *cli.Context) error { + cert, err := cloudflare.GetOriginCARootCertificate(c.String("algorithm")) + if err != nil { + return err + } + + fmt.Println(string(cert[:])) + return nil +} + +func railgun(*cli.Context) error { + return nil +} diff --git a/pkg/cloudflare-go/cmd/flarectl/user_agent.go b/pkg/cloudflare-go/cmd/flarectl/user_agent.go new file mode 100644 index 000000000..a00bb40d9 --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/user_agent.go @@ -0,0 +1,147 @@ +package main + +import ( + "context" + "fmt" + "os" + "strconv" + + "github.com/cloudflare/cloudflare-go" + "github.com/urfave/cli/v2" +) + +func formatUserAgentRule(rule cloudflare.UserAgentRule) []string { + return []string{ + rule.ID, + rule.Description, + rule.Mode, + rule.Configuration.Value, + strconv.FormatBool(rule.Paused), + } +} + +func userAgentCreate(c *cli.Context) error { + if err := checkFlags(c, "zone", "mode", "value"); err != nil { + fmt.Println(err) + return err + } + + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Println(err) + return err + } + + userAgentRule := cloudflare.UserAgentRule{ + Description: c.String("description"), + Mode: c.String("mode"), + Paused: c.Bool("paused"), + Configuration: cloudflare.UserAgentRuleConfig{ + Target: "ua", + Value: c.String("value"), + }, + } + + resp, err := api.CreateUserAgentRule(context.Background(), zoneID, userAgentRule) + if err != nil { + fmt.Fprintln(os.Stderr, "Error creating User-Agent block rule: ", err) + return err + } + + output := [][]string{ + formatUserAgentRule(resp.Result), + } + + writeTable(c, output, "ID", "Description", "Mode", "Value", "Paused") + + return nil +} + +func userAgentUpdate(c *cli.Context) error { + if err := checkFlags(c, "zone", "id", "mode", "value"); err != nil { + return err + } + + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + + userAgentRule := cloudflare.UserAgentRule{ + Description: c.String("description"), + Mode: c.String("mode"), + Paused: c.Bool("paused"), + Configuration: cloudflare.UserAgentRuleConfig{ + Target: "ua", + Value: c.String("value"), + }, + } + + resp, err := api.UpdateUserAgentRule(context.Background(), zoneID, c.String("id"), userAgentRule) + if err != nil { + fmt.Fprintln(os.Stderr, "Error updating User-Agent block rule: ", err) + return err + } + + output := [][]string{ + formatUserAgentRule(resp.Result), + } + + writeTable(c, output, "ID", "Description", "Mode", "Value", "Paused") + + return nil +} + +func userAgentDelete(c *cli.Context) error { + if err := checkFlags(c, "zone", "id"); err != nil { + return err + } + + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + + resp, err := api.DeleteUserAgentRule(context.Background(), zoneID, c.String("id")) + if err != nil { + fmt.Fprintln(os.Stderr, "Error deleting User-Agent block rule: ", err) + return err + } + + output := [][]string{ + formatUserAgentRule(resp.Result), + } + + writeTable(c, output, "ID", "Description", "Mode", "Value", "Paused") + + return nil +} + +func userAgentList(c *cli.Context) error { + if err := checkFlags(c, "zone", "page"); err != nil { + return err + } + + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + + resp, err := api.ListUserAgentRules(context.Background(), zoneID, c.Int("page")) + if err != nil { + fmt.Fprintln(os.Stderr, "Error listing User-Agent block rules: ", err) + return err + } + + output := make([][]string, 0, len(resp.Result)) + for _, rule := range resp.Result { + output = append(output, formatUserAgentRule(rule)) + } + + writeTable(c, output, "ID", "Description", "Mode", "Value", "Paused") + + return nil +} diff --git a/pkg/cloudflare-go/cmd/flarectl/zone.go b/pkg/cloudflare-go/cmd/flarectl/zone.go new file mode 100644 index 000000000..5ab4e24b4 --- /dev/null +++ b/pkg/cloudflare-go/cmd/flarectl/zone.go @@ -0,0 +1,367 @@ +package main + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/urfave/cli/v2" +) + +func zoneCerts(*cli.Context) error { + return nil +} + +func zoneKeyless(*cli.Context) error { + return nil +} + +func zoneRailgun(*cli.Context) error { + return nil +} + +func zoneCreate(c *cli.Context) error { + if err := checkFlags(c, "zone"); err != nil { + return err + } + zone := c.String("zone") + jumpstart := c.Bool("jumpstart") + accountID := c.String("account-id") + zoneType := c.String("type") + var account cloudflare.Account + if accountID != "" { + account.ID = accountID + } + + if zoneType != "partial" { + zoneType = "full" + } + + _, err := api.CreateZone(context.Background(), zone, jumpstart, account, zoneType) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()+"\n") + return err + } + + return nil +} + +func zoneCheck(c *cli.Context) error { + if err := checkFlags(c, "zone"); err != nil { + return err + } + zone := c.String("zone") + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + res, err := api.ZoneActivationCheck(context.Background(), zoneID) + if err != nil { + fmt.Println(err) + return err + } + fmt.Printf("%s\n", res.Messages[0].Message) + + return nil +} + +func zoneList(c *cli.Context) error { + zones, err := api.ListZones(context.Background()) + if err != nil { + fmt.Println(err) + return err + } + output := make([][]string, 0, len(zones)) + for _, z := range zones { + output = append(output, []string{ + z.ID, + z.Name, + z.Plan.Name, + z.Status, + }) + } + writeTable(c, output, "ID", "Name", "Plan", "Status") + + return nil +} + +func zoneDelete(c *cli.Context) error { + if err := checkFlags(c, "zone"); err != nil { + return err + } + + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + + _, err = api.DeleteZone(context.Background(), zoneID) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()+"\n") + return err + } + + return nil +} + +func zoneCreateLockdown(c *cli.Context) error { + if err := checkFlags(c, "zone", "urls", "targets", "values"); err != nil { + return err + } + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Println(err) + return err + } + targets := c.StringSlice("targets") + values := c.StringSlice("values") + if len(targets) != len(values) { + cli.ShowCommandHelp(c, "targets and values does not match") //nolint + return nil + } + var zonelockdownconfigs = []cloudflare.ZoneLockdownConfig{} + for index := 0; index < len(targets); index++ { + zonelockdownconfigs = append(zonelockdownconfigs, cloudflare.ZoneLockdownConfig{ + Target: c.StringSlice("targets")[index], + Value: c.StringSlice("values")[index], + }) + } + params := cloudflare.ZoneLockdownCreateParams{ + Description: c.String("description"), + URLs: c.StringSlice("urls"), + Configurations: zonelockdownconfigs, + } + + resp, err := api.CreateZoneLockdown(context.Background(), cloudflare.ZoneIdentifier(zoneID), params) + if err != nil { + fmt.Fprintln(os.Stderr, "Error creating ZONE lock down: ", err) + return err + } + + output := make([][]string, 0, 1) + + format := []string{ + resp.ID, + } + + output = append(output, format) + + writeTable(c, output, "ID") + + return nil +} + +func zoneInfo(c *cli.Context) error { + var zone string + if c.NArg() > 0 { + zone = c.Args().First() + } else if c.String("zone") != "" { + zone = c.String("zone") + } else { + cli.ShowSubcommandHelp(c) //nolint + return nil + } + zones, err := api.ListZones(context.Background(), zone) + if err != nil { + fmt.Println(err) + return err + } + output := make([][]string, 0, len(zones)) + for _, z := range zones { + var nameservers []string + if len(z.VanityNS) > 0 { + nameservers = z.VanityNS + } else { + nameservers = z.NameServers + } + output = append(output, []string{ + z.ID, + z.Name, + z.Plan.Name, + z.Status, + strings.Join(nameservers, ", "), + fmt.Sprintf("%t", z.Paused), + z.Type, + }) + } + writeTable(c, output, "ID", "Zone", "Plan", "Status", "Name Servers", "Paused", "Type") + + return nil +} + +func zonePlan(*cli.Context) error { + return nil +} + +func zoneSettings(*cli.Context) error { + return nil +} + +func zoneCachePurge(c *cli.Context) error { + if err := checkFlags(c, "zone"); err != nil { + cli.ShowSubcommandHelp(c) //nolint + return err + } + + zoneName := c.String("zone") + zoneID, err := api.ZoneIDByName(c.String("zone")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + + var resp cloudflare.PurgeCacheResponse + + // Purge everything + if c.Bool("everything") { + resp, err = api.PurgeEverything(context.Background(), zoneID) + if err != nil { + fmt.Fprintf(os.Stderr, "Error purging all from zone %q: %s\n", zoneName, err) + return err + } + } else { + var ( + files = c.StringSlice("files") + tags = c.StringSlice("tags") + hosts = c.StringSlice("hosts") + prefixes = c.StringSlice("prefixes") + ) + + if len(files) == 0 && len(tags) == 0 && len(hosts) == 0 && len(prefixes) == 0 { + fmt.Fprintln(os.Stderr, "You must provide at least one of the --files, --tags, --prefixes or --hosts flags") + return nil + } + + // Purge selectively + purgeReq := cloudflare.PurgeCacheRequest{ + Files: c.StringSlice("files"), + Tags: c.StringSlice("tags"), + Hosts: c.StringSlice("hosts"), + Prefixes: c.StringSlice("prefixes"), + } + + resp, err = api.PurgeCache(context.Background(), zoneID, purgeReq) + if err != nil { + fmt.Fprintf(os.Stderr, "Error purging the cache from zone %q: %s\n", zoneName, err) + return err + } + } + + output := make([][]string, 0, 1) + output = append(output, formatCacheResponse(resp)) + + writeTable(c, output, "ID") + + return nil +} + +func zoneRecords(c *cli.Context) error { + var zone string + if c.NArg() > 0 { + zone = c.Args().First() + } else if c.String("zone") != "" { + zone = c.String("zone") + } else { + cli.ShowSubcommandHelp(c) //nolint + return nil + } + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + // Create an empty record for searching for records + rr := cloudflare.ListDNSRecordsParams{} + var records []cloudflare.DNSRecord + if c.String("id") != "" { + rec, err := api.GetDNSRecord(context.Background(), cloudflare.ZoneIdentifier(zoneID), c.String("id")) + if err != nil { + fmt.Println(err) + return err + } + records = append(records, rec) + } else { + if c.String("type") != "" { + rr.Type = c.String("type") + } + if c.String("name") != "" { + rr.Name = c.String("name") + } + if c.String("content") != "" { + rr.Content = c.String("content") + } + var err error + records, _, err = api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), rr) + if err != nil { + fmt.Println(err) + return err + } + } + output := make([][]string, 0, len(records)) + for _, r := range records { + switch r.Type { + case "MX": + r.Content = fmt.Sprintf("%d %s", *r.Priority, r.Content) + case "SRV": + dp := r.Data.(map[string]interface{}) + r.Content = fmt.Sprintf("%.f %s", dp["priority"], r.Content) + // Cloudflare's API, annoyingly, automatically prepends the weight + // and port into content, separated by tabs. + // XXX: File this as a bug. LOC doesn't do this. + r.Content = strings.Replace(r.Content, "\t", " ", -1) + } + output = append(output, []string{ + r.ID, + r.Type, + r.Name, + r.Content, + strconv.FormatBool(*r.Proxied), + fmt.Sprintf("%d", r.TTL), + }) + } + writeTable(c, output, "ID", "Type", "Name", "Content", "Proxied", "TTL") + + return nil +} + +func formatCacheResponse(resp cloudflare.PurgeCacheResponse) []string { + return []string{ + resp.Result.ID, + } +} + +func zoneExport(c *cli.Context) error { + var zone string + if c.NArg() > 0 { + zone = c.Args().First() + } else if c.String("zone") != "" { + zone = c.String("zone") + } else { + cli.ShowSubcommandHelp(c) //nolint + return nil + } + + zoneID, err := api.ZoneIDByName(zone) + if err != nil { + fmt.Println(err) + return err + } + + res, err := api.ZoneExport(context.Background(), zoneID) + if err != nil { + fmt.Println(err) + return err + } + fmt.Print(res) + + return nil +} diff --git a/pkg/cloudflare-go/consts.go b/pkg/cloudflare-go/consts.go new file mode 100644 index 000000000..668ddc315 --- /dev/null +++ b/pkg/cloudflare-go/consts.go @@ -0,0 +1,27 @@ +package cloudflare + +// RouteRoot represents the name of the route namespace. +type RouteRoot string + +const ( + defaultScheme = "https" + defaultHostname = "api.cloudflare.com" + defaultBasePath = "/client/v4" + userAgent = "cloudflare-go" + + // AccountRouteRoot is the accounts route namespace. + AccountRouteRoot RouteRoot = "accounts" + + // ZoneRouteRoot is the zones route namespace. + ZoneRouteRoot RouteRoot = "zones" + + originCARootCertEccURL = "https://developers.cloudflare.com/ssl/static/origin_ca_ecc_root.pem" + originCARootCertRsaURL = "https://developers.cloudflare.com/ssl/static/origin_ca_rsa_root.pem" + + // Used for testing. + testAccountID = "01a7362d577a6c3019a474fd6f485823" + testZoneID = "d56084adb405e0b7e32c52321bf07be6" + testUserID = "a81be4e9b20632860d20a64c054c4150" + testCertPackUUID = "a77f8bd7-3b47-46b4-a6f1-75cf98109948" + testTunnelID = "f174e90a-fafe-4643-bbbc-4a0ed4fc8415" +) diff --git a/pkg/cloudflare-go/convert_types.go b/pkg/cloudflare-go/convert_types.go new file mode 100644 index 000000000..f3ebc83d0 --- /dev/null +++ b/pkg/cloudflare-go/convert_types.go @@ -0,0 +1,932 @@ +// File contains helper methods for accepting variants (pointers, values, +// slices, etc) of a particular type and returning them in another. A common use +// is pointer to values and back. +// +// _Most_ follow the convention of (where is a Golang type such as Bool): +// +// Ptr: Accepts a value and returns a pointer. +// : Accepts a pointer and returns a value. +// PtrSlice: Accepts a slice of values and returns a slice of pointers. +// Slice: Accepts a slice of pointers and returns a slice of values. +// PtrMap: Accepts a string map of values into a string map of pointers. +// Map: Accepts a string map of pointers into a string map of values. +// +// Not all Golang types are covered here, only those that are commonly used. +package cloudflare + +import ( + "reflect" + "time" +) + +// AnyPtr is a helper routine that allocates a new interface value +// to store v and returns a pointer to it. +// +// Usage: var _ *Type = AnyPtr(Type(value) | value).(*Type) +// +// var _ *bool = AnyPtr(true).(*bool) +// var _ *byte = AnyPtr(byte(1)).(*byte) +// var _ *complex64 = AnyPtr(complex64(1.1)).(*complex64) +// var _ *complex128 = AnyPtr(complex128(1.1)).(*complex128) +// var _ *float32 = AnyPtr(float32(1.1)).(*float32) +// var _ *float64 = AnyPtr(float64(1.1)).(*float64) +// var _ *int = AnyPtr(int(1)).(*int) +// var _ *int8 = AnyPtr(int8(8)).(*int8) +// var _ *int16 = AnyPtr(int16(16)).(*int16) +// var _ *int32 = AnyPtr(int32(32)).(*int32) +// var _ *int64 = AnyPtr(int64(64)).(*int64) +// var _ *rune = AnyPtr(rune(1)).(*rune) +// var _ *string = AnyPtr("ptr").(*string) +// var _ *uint = AnyPtr(uint(1)).(*uint) +// var _ *uint8 = AnyPtr(uint8(8)).(*uint8) +// var _ *uint16 = AnyPtr(uint16(16)).(*uint16) +// var _ *uint32 = AnyPtr(uint32(32)).(*uint32) +// var _ *uint64 = AnyPtr(uint64(64)).(*uint64) +func AnyPtr(v interface{}) interface{} { + r := reflect.New(reflect.TypeOf(v)) + reflect.ValueOf(r.Interface()).Elem().Set(reflect.ValueOf(v)) + return r.Interface() +} + +// BytePtr is a helper routine that allocates a new byte value to store v and +// returns a pointer to it. +func BytePtr(v byte) *byte { return &v } + +// Complex64Ptr is a helper routine that allocates a new complex64 value to +// store v and returns a pointer to it. +func Complex64Ptr(v complex64) *complex64 { return &v } + +// Complex128Ptr is a helper routine that allocates a new complex128 value +// to store v and returns a pointer to it. +func Complex128Ptr(v complex128) *complex128 { return &v } + +// RunePtr is a helper routine that allocates a new rune value to store v +// and returns a pointer to it. +func RunePtr(v rune) *rune { return &v } + +// TimePtr is a helper routine that allocates a new time.Time value +// to store v and returns a pointer to it. +func TimePtr(v time.Time) *time.Time { return &v } + +// DurationPtr is a helper routine that allocates a new time.Duration value +// to store v and returns a pointer to it. +func DurationPtr(v time.Duration) *time.Duration { return &v } + +// BoolPtr is a helper routine that allocates a new bool value to store v and +// returns a pointer to it. +func BoolPtr(v bool) *bool { return &v } + +// Bool is a helper routine that accepts a bool pointer and returns a value +// to it. +func Bool(v *bool) bool { + if v != nil { + return *v + } + return false +} + +// BoolPtrSlice converts a slice of bool values into a slice of bool pointers. +func BoolPtrSlice(src []bool) []*bool { + dst := make([]*bool, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// BoolSlice converts a slice of bool pointers into a slice of bool values. +func BoolSlice(src []*bool) []bool { + dst := make([]bool, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// BoolPtrMap converts a string map of bool values into a string map of bool +// pointers. +func BoolPtrMap(src map[string]bool) map[string]*bool { + dst := make(map[string]*bool) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// BoolMap converts a string map of bool pointers into a string map of bool +// values. +func BoolMap(src map[string]*bool) map[string]bool { + dst := make(map[string]bool) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Byte is a helper routine that accepts a byte pointer and returns a +// value to it. +func Byte(v *byte) byte { + if v != nil { + return *v + } + return byte(0) +} + +// Complex64 is a helper routine that accepts a complex64 pointer and +// returns a value to it. +func Complex64(v *complex64) complex64 { + if v != nil { + return *v + } + return 0 +} + +// Complex128 is a helper routine that accepts a complex128 pointer and +// returns a value to it. +func Complex128(v *complex128) complex128 { + if v != nil { + return *v + } + return 0 +} + +// Float32Ptr is a helper routine that allocates a new float32 value to store v +// and returns a pointer to it. +func Float32Ptr(v float32) *float32 { return &v } + +// Float32 is a helper routine that accepts a float32 pointer and returns a +// value to it. +func Float32(v *float32) float32 { + if v != nil { + return *v + } + return 0 +} + +// Float32PtrSlice converts a slice of float32 values into a slice of float32 +// pointers. +func Float32PtrSlice(src []float32) []*float32 { + dst := make([]*float32, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Float32Slice converts a slice of float32 pointers into a slice of +// float32 values. +func Float32Slice(src []*float32) []float32 { + dst := make([]float32, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Float32PtrMap converts a string map of float32 values into a string map of +// float32 pointers. +func Float32PtrMap(src map[string]float32) map[string]*float32 { + dst := make(map[string]*float32) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Float32Map converts a string map of float32 pointers into a string +// map of float32 values. +func Float32Map(src map[string]*float32) map[string]float32 { + dst := make(map[string]float32) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Float64Ptr is a helper routine that allocates a new float64 value to store v +// and returns a pointer to it. +func Float64Ptr(v float64) *float64 { return &v } + +// Float64 is a helper routine that accepts a float64 pointer and returns a +// value to it. +func Float64(v *float64) float64 { + if v != nil { + return *v + } + return 0 +} + +// Float64PtrSlice converts a slice of float64 values into a slice of float64 +// pointers. +func Float64PtrSlice(src []float64) []*float64 { + dst := make([]*float64, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Float64Slice converts a slice of float64 pointers into a slice of +// float64 values. +func Float64Slice(src []*float64) []float64 { + dst := make([]float64, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Float64PtrMap converts a string map of float64 values into a string map of +// float64 pointers. +func Float64PtrMap(src map[string]float64) map[string]*float64 { + dst := make(map[string]*float64) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Float64Map converts a string map of float64 pointers into a string +// map of float64 values. +func Float64Map(src map[string]*float64) map[string]float64 { + dst := make(map[string]float64) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// IntPtr is a helper routine that allocates a new int value to store v and +// returns a pointer to it. +func IntPtr(v int) *int { return &v } + +// Int is a helper routine that accepts a int pointer and returns a value +// to it. +func Int(v *int) int { + if v != nil { + return *v + } + return 0 +} + +// IntPtrSlice converts a slice of int values into a slice of int pointers. +func IntPtrSlice(src []int) []*int { + dst := make([]*int, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// IntSlice converts a slice of int pointers into a slice of int values. +func IntSlice(src []*int) []int { + dst := make([]int, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// IntPtrMap converts a string map of int values into a string map of int +// pointers. +func IntPtrMap(src map[string]int) map[string]*int { + dst := make(map[string]*int) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// IntMap converts a string map of int pointers into a string map of int +// values. +func IntMap(src map[string]*int) map[string]int { + dst := make(map[string]int) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Int8Ptr is a helper routine that allocates a new int8 value to store v and +// returns a pointer to it. +func Int8Ptr(v int8) *int8 { return &v } + +// Int8 is a helper routine that accepts a int8 pointer and returns a value +// to it. +func Int8(v *int8) int8 { + if v != nil { + return *v + } + return 0 +} + +// Int8PtrSlice converts a slice of int8 values into a slice of int8 pointers. +func Int8PtrSlice(src []int8) []*int8 { + dst := make([]*int8, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Int8Slice converts a slice of int8 pointers into a slice of int8 values. +func Int8Slice(src []*int8) []int8 { + dst := make([]int8, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Int8PtrMap converts a string map of int8 values into a string map of int8 +// pointers. +func Int8PtrMap(src map[string]int8) map[string]*int8 { + dst := make(map[string]*int8) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Int8Map converts a string map of int8 pointers into a string map of int8 +// values. +func Int8Map(src map[string]*int8) map[string]int8 { + dst := make(map[string]int8) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Int16Ptr is a helper routine that allocates a new int16 value to store v +// and returns a pointer to it. +func Int16Ptr(v int16) *int16 { return &v } + +// Int16 is a helper routine that accepts a int16 pointer and returns a +// value to it. +func Int16(v *int16) int16 { + if v != nil { + return *v + } + return 0 +} + +// Int16PtrSlice converts a slice of int16 values into a slice of int16 +// pointers. +func Int16PtrSlice(src []int16) []*int16 { + dst := make([]*int16, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Int16Slice converts a slice of int16 pointers into a slice of int16 +// values. +func Int16Slice(src []*int16) []int16 { + dst := make([]int16, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Int16PtrMap converts a string map of int16 values into a string map of int16 +// pointers. +func Int16PtrMap(src map[string]int16) map[string]*int16 { + dst := make(map[string]*int16) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Int16Map converts a string map of int16 pointers into a string map of +// int16 values. +func Int16Map(src map[string]*int16) map[string]int16 { + dst := make(map[string]int16) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Int32Ptr is a helper routine that allocates a new int32 value to store v +// and returns a pointer to it. +func Int32Ptr(v int32) *int32 { return &v } + +// Int32 is a helper routine that accepts a int32 pointer and returns a +// value to it. +func Int32(v *int32) int32 { + if v != nil { + return *v + } + return 0 +} + +// Int32PtrSlice converts a slice of int32 values into a slice of int32 +// pointers. +func Int32PtrSlice(src []int32) []*int32 { + dst := make([]*int32, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Int32Slice converts a slice of int32 pointers into a slice of int32 +// values. +func Int32Slice(src []*int32) []int32 { + dst := make([]int32, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Int32PtrMap converts a string map of int32 values into a string map of int32 +// pointers. +func Int32PtrMap(src map[string]int32) map[string]*int32 { + dst := make(map[string]*int32) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Int32Map converts a string map of int32 pointers into a string map of +// int32 values. +func Int32Map(src map[string]*int32) map[string]int32 { + dst := make(map[string]int32) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Int64Ptr is a helper routine that allocates a new int64 value to store v +// and returns a pointer to it. +func Int64Ptr(v int64) *int64 { return &v } + +// Int64 is a helper routine that accepts a int64 pointer and returns a +// value to it. +func Int64(v *int64) int64 { + if v != nil { + return *v + } + return 0 +} + +// Int64PtrSlice converts a slice of int64 values into a slice of int64 +// pointers. +func Int64PtrSlice(src []int64) []*int64 { + dst := make([]*int64, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Int64Slice converts a slice of int64 pointers into a slice of int64 +// values. +func Int64Slice(src []*int64) []int64 { + dst := make([]int64, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Int64PtrMap converts a string map of int64 values into a string map of int64 +// pointers. +func Int64PtrMap(src map[string]int64) map[string]*int64 { + dst := make(map[string]*int64) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Int64Map converts a string map of int64 pointers into a string map of +// int64 values. +func Int64Map(src map[string]*int64) map[string]int64 { + dst := make(map[string]int64) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Rune is a helper routine that accepts a rune pointer and returns a value +// to it. +func Rune(v *rune) rune { + if v != nil { + return *v + } + return rune(0) +} + +// StringPtr is a helper routine that allocates a new string value to store v +// and returns a pointer to it. +func StringPtr(v string) *string { return &v } + +// String is a helper routine that accepts a string pointer and returns a +// value to it. +func String(v *string) string { + if v != nil { + return *v + } + return "" +} + +// StringPtrSlice converts a slice of string values into a slice of string +// pointers. +func StringPtrSlice(src []string) []*string { + dst := make([]*string, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// StringSlice converts a slice of string pointers into a slice of string +// values. +func StringSlice(src []*string) []string { + dst := make([]string, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// StringPtrMap converts a string map of string values into a string map of +// string pointers. +func StringPtrMap(src map[string]string) map[string]*string { + dst := make(map[string]*string) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// StringMap converts a string map of string pointers into a string map of +// string values. +func StringMap(src map[string]*string) map[string]string { + dst := make(map[string]string) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// UintPtr is a helper routine that allocates a new uint value to store v +// and returns a pointer to it. +func UintPtr(v uint) *uint { return &v } + +// Uint is a helper routine that accepts a uint pointer and returns a value +// to it. +func Uint(v *uint) uint { + if v != nil { + return *v + } + return 0 +} + +// UintPtrSlice converts a slice of uint values uinto a slice of uint pointers. +func UintPtrSlice(src []uint) []*uint { + dst := make([]*uint, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// UintSlice converts a slice of uint pointers uinto a slice of uint +// values. +func UintSlice(src []*uint) []uint { + dst := make([]uint, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// UintPtrMap converts a string map of uint values uinto a string map of uint +// pointers. +func UintPtrMap(src map[string]uint) map[string]*uint { + dst := make(map[string]*uint) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// UintMap converts a string map of uint pointers uinto a string map of +// uint values. +func UintMap(src map[string]*uint) map[string]uint { + dst := make(map[string]uint) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Uint8Ptr is a helper routine that allocates a new uint8 value to store v +// and returns a pointer to it. +func Uint8Ptr(v uint8) *uint8 { return &v } + +// Uint8 is a helper routine that accepts a uint8 pointer and returns a +// value to it. +func Uint8(v *uint8) uint8 { + if v != nil { + return *v + } + return 0 +} + +// Uint8PtrSlice converts a slice of uint8 values into a slice of uint8 +// pointers. +func Uint8PtrSlice(src []uint8) []*uint8 { + dst := make([]*uint8, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Uint8Slice converts a slice of uint8 pointers into a slice of uint8 +// values. +func Uint8Slice(src []*uint8) []uint8 { + dst := make([]uint8, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Uint8PtrMap converts a string map of uint8 values into a string map of uint8 +// pointers. +func Uint8PtrMap(src map[string]uint8) map[string]*uint8 { + dst := make(map[string]*uint8) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Uint8Map converts a string map of uint8 pointers into a string +// map of uint8 values. +func Uint8Map(src map[string]*uint8) map[string]uint8 { + dst := make(map[string]uint8) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Uint16Ptr is a helper routine that allocates a new uint16 value to store v +// and returns a pointer to it. +func Uint16Ptr(v uint16) *uint16 { return &v } + +// Uint16 is a helper routine that accepts a uint16 pointer and returns a +// value to it. +func Uint16(v *uint16) uint16 { + if v != nil { + return *v + } + return 0 +} + +// Uint16PtrSlice converts a slice of uint16 values into a slice of uint16 +// pointers. +func Uint16PtrSlice(src []uint16) []*uint16 { + dst := make([]*uint16, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Uint16Slice converts a slice of uint16 pointers into a slice of uint16 +// values. +func Uint16Slice(src []*uint16) []uint16 { + dst := make([]uint16, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Uint16PtrMap converts a string map of uint16 values into a string map of +// uint16 pointers. +func Uint16PtrMap(src map[string]uint16) map[string]*uint16 { + dst := make(map[string]*uint16) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Uint16Map converts a string map of uint16 pointers into a string map of +// uint16 values. +func Uint16Map(src map[string]*uint16) map[string]uint16 { + dst := make(map[string]uint16) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Uint32Ptr is a helper routine that allocates a new uint32 value to store v +// and returns a pointer to it. +func Uint32Ptr(v uint32) *uint32 { return &v } + +// Uint32 is a helper routine that accepts a uint32 pointer and returns a +// value to it. +func Uint32(v *uint32) uint32 { + if v != nil { + return *v + } + return 0 +} + +// Uint32PtrSlice converts a slice of uint32 values into a slice of uint32 +// pointers. +func Uint32PtrSlice(src []uint32) []*uint32 { + dst := make([]*uint32, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Uint32Slice converts a slice of uint32 pointers into a slice of uint32 +// values. +func Uint32Slice(src []*uint32) []uint32 { + dst := make([]uint32, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Uint32PtrMap converts a string map of uint32 values into a string map of +// uint32 pointers. +func Uint32PtrMap(src map[string]uint32) map[string]*uint32 { + dst := make(map[string]*uint32) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Uint32Map converts a string map of uint32 pointers into a string +// map of uint32 values. +func Uint32Map(src map[string]*uint32) map[string]uint32 { + dst := make(map[string]uint32) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Uint64Ptr is a helper routine that allocates a new uint64 value to store v +// and returns a pointer to it. +func Uint64Ptr(v uint64) *uint64 { return &v } + +// Uint64 is a helper routine that accepts a uint64 pointer and returns a +// value to it. +func Uint64(v *uint64) uint64 { + if v != nil { + return *v + } + return 0 +} + +// Uint64PtrSlice converts a slice of uint64 values into a slice of uint64 +// pointers. +func Uint64PtrSlice(src []uint64) []*uint64 { + dst := make([]*uint64, len(src)) + for i := 0; i < len(src); i++ { + dst[i] = &(src[i]) + } + return dst +} + +// Uint64Slice converts a slice of uint64 pointers into a slice of uint64 +// values. +func Uint64Slice(src []*uint64) []uint64 { + dst := make([]uint64, len(src)) + for i := 0; i < len(src); i++ { + if src[i] != nil { + dst[i] = *(src[i]) + } + } + return dst +} + +// Uint64PtrMap converts a string map of uint64 values into a string map of +// uint64 pointers. +func Uint64PtrMap(src map[string]uint64) map[string]*uint64 { + dst := make(map[string]*uint64) + for k, val := range src { + v := val + dst[k] = &v + } + return dst +} + +// Uint64Map converts a string map of uint64 pointers into a string map of +// uint64 values. +func Uint64Map(src map[string]*uint64) map[string]uint64 { + dst := make(map[string]uint64) + for k, val := range src { + if val != nil { + dst[k] = *val + } + } + return dst +} + +// Time is a helper routine that accepts a time pointer value and returns a +// value to it. +func Time(v *time.Time) time.Time { + if v != nil { + return *v + } + return time.Time{} +} + +// Duration is a helper routine that accepts a time pointer ion value +// and returns a value to it. +// func Duration(v *time.Duration) time.Duration { +// if v != nil { +// return *v +// } +// return time.Duration(0) +// } diff --git a/pkg/cloudflare-go/custom_hostname.go b/pkg/cloudflare-go/custom_hostname.go new file mode 100644 index 000000000..b625b7232 --- /dev/null +++ b/pkg/cloudflare-go/custom_hostname.go @@ -0,0 +1,330 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/goccy/go-json" +) + +// CustomHostnameStatus is the enumeration of valid state values in the CustomHostnameSSL. +type CustomHostnameStatus string + +const ( + // PENDING status represents state of CustomHostname is pending. + PENDING CustomHostnameStatus = "pending" + // ACTIVE status represents state of CustomHostname is active. + ACTIVE CustomHostnameStatus = "active" + // MOVED status represents state of CustomHostname is moved. + MOVED CustomHostnameStatus = "moved" + // DELETED status represents state of CustomHostname is deleted. + DELETED CustomHostnameStatus = "deleted" + // BLOCKED status represents state of CustomHostname is blocked from going active. + BLOCKED CustomHostnameStatus = "blocked" +) + +// CustomHostnameSSLSettings represents the SSL settings for a custom hostname. +type CustomHostnameSSLSettings struct { + HTTP2 string `json:"http2,omitempty"` + HTTP3 string `json:"http3,omitempty"` + TLS13 string `json:"tls_1_3,omitempty"` + MinTLSVersion string `json:"min_tls_version,omitempty"` + Ciphers []string `json:"ciphers,omitempty"` + EarlyHints string `json:"early_hints,omitempty"` +} + +// CustomHostnameOwnershipVerification represents ownership verification status of a given custom hostname. +type CustomHostnameOwnershipVerification struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` +} + +// SSLValidationError represents errors that occurred during SSL validation. +type SSLValidationError struct { + Message string `json:"message,omitempty"` +} + +// CustomHostnameSSLCertificates represent certificate properties like issuer, expires date and etc. +type CustomHostnameSSLCertificates struct { + Issuer string `json:"issuer"` + SerialNumber string `json:"serial_number"` + Signature string `json:"signature"` + ExpiresOn *time.Time `json:"expires_on"` + IssuedOn *time.Time `json:"issued_on"` + FingerprintSha256 string `json:"fingerprint_sha256"` + ID string `json:"id"` +} + +// CustomHostnameSSL represents the SSL section in a given custom hostname. +type CustomHostnameSSL struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Method string `json:"method,omitempty"` + Type string `json:"type,omitempty"` + Wildcard *bool `json:"wildcard,omitempty"` + CustomCertificate string `json:"custom_certificate,omitempty"` + CustomKey string `json:"custom_key,omitempty"` + CertificateAuthority string `json:"certificate_authority,omitempty"` + Issuer string `json:"issuer,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + Settings CustomHostnameSSLSettings `json:"settings,omitempty"` + Certificates []CustomHostnameSSLCertificates `json:"certificates,omitempty"` + // Deprecated: use ValidationRecords. + // If there a single validation record, this will equal ValidationRecords[0] for backwards compatibility. + SSLValidationRecord + ValidationRecords []SSLValidationRecord `json:"validation_records,omitempty"` + ValidationErrors []SSLValidationError `json:"validation_errors,omitempty"` + BundleMethod string `json:"bundle_method,omitempty"` +} + +// CustomMetadata defines custom metadata for the hostname. This requires logic to be implemented by Cloudflare to act on the data provided. +type CustomMetadata map[string]interface{} + +// CustomHostname represents a custom hostname in a zone. +type CustomHostname struct { + ID string `json:"id,omitempty"` + Hostname string `json:"hostname,omitempty"` + CustomOriginServer string `json:"custom_origin_server,omitempty"` + CustomOriginSNI string `json:"custom_origin_sni,omitempty"` + SSL *CustomHostnameSSL `json:"ssl,omitempty"` + CustomMetadata *CustomMetadata `json:"custom_metadata,omitempty"` + Status CustomHostnameStatus `json:"status,omitempty"` + VerificationErrors []string `json:"verification_errors,omitempty"` + OwnershipVerification CustomHostnameOwnershipVerification `json:"ownership_verification,omitempty"` + OwnershipVerificationHTTP CustomHostnameOwnershipVerificationHTTP `json:"ownership_verification_http,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// CustomHostnameOwnershipVerificationHTTP represents a response from the Custom Hostnames endpoints. +type CustomHostnameOwnershipVerificationHTTP struct { + HTTPUrl string `json:"http_url,omitempty"` + HTTPBody string `json:"http_body,omitempty"` +} + +// CustomHostnameResponse represents a response from the Custom Hostnames endpoints. +type CustomHostnameResponse struct { + Result CustomHostname `json:"result"` + Response +} + +// CustomHostnameListResponse represents a response from the Custom Hostnames endpoints. +type CustomHostnameListResponse struct { + Result []CustomHostname `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// CustomHostnameFallbackOrigin represents a Custom Hostnames Fallback Origin. +type CustomHostnameFallbackOrigin struct { + Origin string `json:"origin,omitempty"` + Status string `json:"status,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// CustomHostnameFallbackOriginResponse represents a response from the Custom Hostnames Fallback Origin endpoint. +type CustomHostnameFallbackOriginResponse struct { + Result CustomHostnameFallbackOrigin `json:"result"` + Response +} + +// UpdateCustomHostnameSSL modifies SSL configuration for the given custom +// hostname in the given zone. +// +// API reference: https://api.cloudflare.com/#custom-hostname-for-a-zone-update-custom-hostname-configuration +func (api *API) UpdateCustomHostnameSSL(ctx context.Context, zoneID string, customHostnameID string, ssl *CustomHostnameSSL) (*CustomHostnameResponse, error) { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, customHostnameID) + ch := CustomHostname{ + SSL: ssl, + } + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, ch) + if err != nil { + return nil, err + } + + var response *CustomHostnameResponse + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response, nil +} + +// UpdateCustomHostname modifies configuration for the given custom +// hostname in the given zone. +// +// API reference: https://api.cloudflare.com/#custom-hostname-for-a-zone-update-custom-hostname-configuration +func (api *API) UpdateCustomHostname(ctx context.Context, zoneID string, customHostnameID string, ch CustomHostname) (*CustomHostnameResponse, error) { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, customHostnameID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, ch) + if err != nil { + return nil, err + } + + var response *CustomHostnameResponse + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response, nil +} + +// DeleteCustomHostname deletes a custom hostname (and any issued SSL +// certificates). +// +// API reference: https://api.cloudflare.com/#custom-hostname-for-a-zone-delete-a-custom-hostname-and-any-issued-ssl-certificates- +func (api *API) DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, customHostnameID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var response *CustomHostnameResponse + err = json.Unmarshal(res, &response) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// CreateCustomHostname creates a new custom hostname and requests that an SSL certificate be issued for it. +// +// API reference: https://api.cloudflare.com/#custom-hostname-for-a-zone-create-custom-hostname +func (api *API) CreateCustomHostname(ctx context.Context, zoneID string, ch CustomHostname) (*CustomHostnameResponse, error) { + uri := fmt.Sprintf("/zones/%s/custom_hostnames", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, ch) + if err != nil { + return nil, err + } + + var response *CustomHostnameResponse + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// CustomHostnames fetches custom hostnames for the given zone, +// by applying filter.Hostname if not empty and scoping the result to page'th 50 items. +// +// The returned ResultInfo can be used to implement pagination. +// +// API reference: https://api.cloudflare.com/#custom-hostname-for-a-zone-list-custom-hostnames +func (api *API) CustomHostnames(ctx context.Context, zoneID string, page int, filter CustomHostname) ([]CustomHostname, ResultInfo, error) { + v := url.Values{} + v.Set("per_page", "50") + v.Set("page", strconv.Itoa(page)) + if filter.Hostname != "" { + v.Set("hostname", filter.Hostname) + } + + uri := fmt.Sprintf("/zones/%s/custom_hostnames?%s", zoneID, v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []CustomHostname{}, ResultInfo{}, err + } + var customHostnameListResponse CustomHostnameListResponse + err = json.Unmarshal(res, &customHostnameListResponse) + if err != nil { + return []CustomHostname{}, ResultInfo{}, err + } + + return customHostnameListResponse.Result, customHostnameListResponse.ResultInfo, nil +} + +// CustomHostname inspects the given custom hostname in the given zone. +// +// API reference: https://api.cloudflare.com/#custom-hostname-for-a-zone-custom-hostname-configuration-details +func (api *API) CustomHostname(ctx context.Context, zoneID string, customHostnameID string) (CustomHostname, error) { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/%s", zoneID, customHostnameID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return CustomHostname{}, err + } + + var response CustomHostnameResponse + err = json.Unmarshal(res, &response) + if err != nil { + return CustomHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// CustomHostnameIDByName retrieves the ID for the given hostname in the given zone. +func (api *API) CustomHostnameIDByName(ctx context.Context, zoneID string, hostname string) (string, error) { + customHostnames, _, err := api.CustomHostnames(ctx, zoneID, 1, CustomHostname{Hostname: hostname}) + if err != nil { + return "", fmt.Errorf("CustomHostnames command failed: %w", err) + } + for _, ch := range customHostnames { + if ch.Hostname == hostname { + return ch.ID, nil + } + } + return "", errors.New("CustomHostname could not be found") +} + +// UpdateCustomHostnameFallbackOrigin modifies the Custom Hostname Fallback origin in the given zone. +// +// API reference: https://api.cloudflare.com/#custom-hostname-fallback-origin-for-a-zone-update-fallback-origin-for-custom-hostnames +func (api *API) UpdateCustomHostnameFallbackOrigin(ctx context.Context, zoneID string, chfo CustomHostnameFallbackOrigin) (*CustomHostnameFallbackOriginResponse, error) { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/fallback_origin", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, chfo) + if err != nil { + return nil, err + } + + var response *CustomHostnameFallbackOriginResponse + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response, nil +} + +// DeleteCustomHostnameFallbackOrigin deletes the Custom Hostname Fallback origin in the given zone. +// +// API reference: https://api.cloudflare.com/#custom-hostname-fallback-origin-for-a-zone-delete-fallback-origin-for-custom-hostnames +func (api *API) DeleteCustomHostnameFallbackOrigin(ctx context.Context, zoneID string) error { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/fallback_origin", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var response *CustomHostnameFallbackOriginResponse + err = json.Unmarshal(res, &response) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// CustomHostnameFallbackOrigin inspects the Custom Hostname Fallback origin in the given zone. +// +// API reference: https://api.cloudflare.com/#custom-hostname-fallback-origin-for-a-zone-properties +func (api *API) CustomHostnameFallbackOrigin(ctx context.Context, zoneID string) (CustomHostnameFallbackOrigin, error) { + uri := fmt.Sprintf("/zones/%s/custom_hostnames/fallback_origin", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return CustomHostnameFallbackOrigin{}, err + } + + var response CustomHostnameFallbackOriginResponse + err = json.Unmarshal(res, &response) + if err != nil { + return CustomHostnameFallbackOrigin{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} diff --git a/pkg/cloudflare-go/custom_hostname_test.go b/pkg/cloudflare-go/custom_hostname_test.go new file mode 100644 index 000000000..c38bbbee3 --- /dev/null +++ b/pkg/cloudflare-go/custom_hostname_test.go @@ -0,0 +1,1114 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCustomHostname_DeleteCustomHostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/bar", 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.Fprintf(w, ` +{ + "id": "bar" +}`) + }) + + err := client.DeleteCustomHostname(context.Background(), "foo", "bar") + + assert.NoError(t, err) +} + +func TestCustomHostname_CreateCustomHostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "cname", + "type": "dv", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "validation_records": [{ + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com" + }], + "settings": { + "http2": "on" + } + }, + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification": { + "type": "txt", + "name": "_cf-custom-hostname.app.example.com", + "value": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "ownership_verification_http": { + "http_url": "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + "http_body": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "created_at": "2020-02-06T18:11:23.531995Z" + } +}`) + }) + + response, err := client.CreateCustomHostname(context.Background(), "foo", CustomHostname{Hostname: "app.example.com", SSL: &CustomHostnameSSL{Method: "cname", Type: "dv"}}) + + createdAt, _ := time.Parse(time.RFC3339, "2020-02-06T18:11:23.531995Z") + + validationRec := SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + } + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: validationRec, + ValidationRecords: []SSLValidationRecord{validationRec}, + Settings: CustomHostnameSSLSettings{ + HTTP2: "on", + }, + }, + Status: "pending", + VerificationErrors: []string{"None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerification: CustomHostnameOwnershipVerification{ + Type: "txt", + Name: "_cf-custom-hostname.app.example.com", + Value: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + OwnershipVerificationHTTP: CustomHostnameOwnershipVerificationHTTP{ + HTTPUrl: "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + HTTPBody: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + CreatedAt: &createdAt, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CreateCustomHostname_MethodTxt(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "txt", + "type": "dv", + "txt_name": "app.example.com", + "txt_value": "ca3-f8db94da174g4c409b17fcaa5470deb2", + "settings": { + "http2": "on" + } + }, + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification": { + "type": "txt", + "name": "_cf-custom-hostname.app.example.com", + "value": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "ownership_verification_http": { + "http_url": "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + "http_body": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "created_at": "2020-02-06T18:11:23.531995Z" + } +}`) + }) + + response, err := client.CreateCustomHostname(context.Background(), "foo", CustomHostname{Hostname: "app.example.com", SSL: &CustomHostnameSSL{Method: "txt", Type: "dv"}}) + + createdAt, _ := time.Parse(time.RFC3339, "2020-02-06T18:11:23.531995Z") + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "txt", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + TxtName: "app.example.com", + TxtValue: "ca3-f8db94da174g4c409b17fcaa5470deb2", + }, + Settings: CustomHostnameSSLSettings{ + HTTP2: "on", + }, + }, + Status: "pending", + VerificationErrors: []string{"None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerification: CustomHostnameOwnershipVerification{ + Type: "txt", + Name: "_cf-custom-hostname.app.example.com", + Value: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + OwnershipVerificationHTTP: CustomHostnameOwnershipVerificationHTTP{ + HTTPUrl: "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + HTTPBody: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + CreatedAt: &createdAt, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CreateCustomHostname_CustomOrigin(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "cname", + "type": "dv", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "settings": { + "http2": "on" + } + } + } +}`) + }) + + response, err := client.CreateCustomHostname(context.Background(), "foo", CustomHostname{Hostname: "app.example.com", CustomOriginServer: "example.app.com", SSL: &CustomHostnameSSL{Method: "cname", Type: "dv"}}) + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + }, + Settings: CustomHostnameSSLSettings{ + HTTP2: "on", + }, + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CreateCustomHostname_No_SSL(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification": { + "type": "txt", + "name": "_cf-custom-hostname.app.example.com", + "value": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "ownership_verification_http": { + "http_url": "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + "http_body": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "created_at": "2020-02-06T18:11:23.531995Z" + } +}`) + }) + + response, err := client.CreateCustomHostname(context.Background(), "foo", CustomHostname{Hostname: "app.example.com"}) + + createdAt, _ := time.Parse(time.RFC3339, "2020-02-06T18:11:23.531995Z") + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + Status: "pending", + VerificationErrors: []string{"None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerification: CustomHostnameOwnershipVerification{ + Type: "txt", + Name: "_cf-custom-hostname.app.example.com", + Value: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + OwnershipVerificationHTTP: CustomHostnameOwnershipVerificationHTTP{ + HTTPUrl: "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + HTTPBody: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + CreatedAt: &createdAt, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CreateCustomHostname_CustomOriginSNI(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "custom_origin_sni": "app.example.com", + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification": { + "type": "txt", + "name": "_cf-custom-hostname.app.example.com", + "value": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "ownership_verification_http": { + "http_url": "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + "http_body": "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0" + }, + "created_at": "2020-02-06T18:11:23.531995Z" + } +}`) + }) + + response, err := client.CreateCustomHostname(context.Background(), "foo", CustomHostname{Hostname: "app.example.com", CustomOriginSNI: "app.example.com"}) + + createdAt, _ := time.Parse(time.RFC3339, "2020-02-06T18:11:23.531995Z") + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + CustomOriginSNI: "app.example.com", + Status: "pending", + VerificationErrors: []string{"None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerification: CustomHostnameOwnershipVerification{ + Type: "txt", + Name: "_cf-custom-hostname.app.example.com", + Value: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + OwnershipVerificationHTTP: CustomHostnameOwnershipVerificationHTTP{ + HTTPUrl: "http://app.example.com/.well-known/cf-custom-hostname-challenge/37c82d20-99fb-490e-ba0a-489fa483b776", + HTTPBody: "38ddbedc-6cc3-4a4c-af67-9c5b02344ce0", + }, + CreatedAt: &createdAt, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CustomHostnames(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", 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.Fprintf(w, `{ + "success": true, + "result": [ + { + "id": "custom_host_1", + "hostname": "custom.host.one", + "ssl": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "type": "dv", + "method": "cname", + "status": "pending_validation", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "issuer": "DigiCertInc", + "serial_number": "6743787633689793699141714808227354901", + "http_url": "http://app.example.com/.well-known/pki-validation/ca3-da12a1c25e7b48cf80408c6c1763b8a2.txt", + "http_body": "ca3-574923932a82475cb8592200f1a2a23d" + }, + "custom_metadata": { + "a_random_field": "random field value" + }, + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification": { + "type": "txt", + "name": "_cf-custom-hostname.app.example.com", + "value": "5cc07c04-ea62-4a5a-95f0-419334a875a4" + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 5, + "total_count": 5 + } +}`) + }) + + customHostnames, _, err := client.CustomHostnames(context.Background(), "foo", 1, CustomHostname{}) + + want := []CustomHostname{ + { + ID: "custom_host_1", + Hostname: "custom.host.one", + SSL: &CustomHostnameSSL{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + HTTPUrl: "http://app.example.com/.well-known/pki-validation/ca3-da12a1c25e7b48cf80408c6c1763b8a2.txt", + HTTPBody: "ca3-574923932a82475cb8592200f1a2a23d", + }, + Issuer: "DigiCertInc", + SerialNumber: "6743787633689793699141714808227354901", + }, + CustomMetadata: &CustomMetadata{"a_random_field": "random field value"}, + Status: PENDING, + VerificationErrors: []string{"None of the A or AAAA records are owned " + + "by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerification: CustomHostnameOwnershipVerification{ + Type: "txt", + Name: "_cf-custom-hostname.app.example.com", + Value: "5cc07c04-ea62-4a5a-95f0-419334a875a4", + }, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, customHostnames) + } +} + +func TestCustomHostname_CustomHostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/bar", 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.Fprintf(w, `{ +"success": true, +"result": { + "id": "bar", + "hostname": "foo.bar.com", + "ssl": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "type": "dv", + "method": "http", + "status": "active", + "issuer": "DigiCertInc", + "serial_number": "6743787633689793699141714808227354901", + "settings": { + "ciphers": ["ECDHE-RSA-AES128-GCM-SHA256","AES128-SHA"], + "http2": "on", + "min_tls_version": "1.2" + } + }, + "custom_metadata": { + "origin": "a.custom.origin" + }, + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification": { + "type": "txt", + "name": "_cf-custom-hostname.app.example.com", + "value": "5cc07c04-ea62-4a5a-95f0-419334a875a4" + } + } +}`) + }) + + customHostname, err := client.CustomHostname(context.Background(), "foo", "bar") + + want := CustomHostname{ + ID: "bar", + Hostname: "foo.bar.com", + SSL: &CustomHostnameSSL{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Status: "active", + Method: "http", + Type: "dv", + Issuer: "DigiCertInc", + SerialNumber: "6743787633689793699141714808227354901", + Settings: CustomHostnameSSLSettings{ + HTTP2: "on", + MinTLSVersion: "1.2", + Ciphers: []string{"ECDHE-RSA-AES128-GCM-SHA256", "AES128-SHA"}, + }, + }, + CustomMetadata: &CustomMetadata{"origin": "a.custom.origin"}, + Status: PENDING, + VerificationErrors: []string{"None of the A or AAAA records are owned " + + "by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerification: CustomHostnameOwnershipVerification{ + Type: "txt", + Name: "_cf-custom-hostname.app.example.com", + Value: "5cc07c04-ea62-4a5a-95f0-419334a875a4", + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, customHostname) + } +} + +func TestCustomHostname_CustomHostname_WithSSLError(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/bar", 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.Fprintf(w, `{ +"success": true, +"result": { + "id": "bar", + "hostname": "example.com", + "ssl": { + "type": "dv", + "method": "cname", + "status": "pending_validation", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.example.com", + "validation_errors": [{ + "message": "SERVFAIL looking up CAA for example.com" + }] + }, + "status": "pending", + "verification_errors": [ + "None of the A or AAAA records are owned by this account and the pre-generated ownership verification token was not found." + ], + "ownership_verification_http": { + "http_url": "http://example.com/.well-known/cf-custom-hostname-challenge/0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "http_body": "5cc07c04-ea62-4a5a-95f0-419334a875a4" + } +} +}`) + }) + + customHostname, err := client.CustomHostname(context.Background(), "foo", "bar") + + want := CustomHostname{ + ID: "bar", + Hostname: "example.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameName: "810b7d5f01154524b961ba0cd578acc2.example.com", + CnameTarget: "dcv.digicert.com", + }, + ValidationErrors: []SSLValidationError{ + { + Message: "SERVFAIL looking up CAA for example.com", + }, + }, + }, + Status: PENDING, + VerificationErrors: []string{"None of the A or AAAA records are owned " + + "by this account and the pre-generated ownership verification token was not found."}, + OwnershipVerificationHTTP: CustomHostnameOwnershipVerificationHTTP{ + HTTPBody: "5cc07c04-ea62-4a5a-95f0-419334a875a4", + HTTPUrl: "http://example.com/.well-known/cf-custom-hostname-challenge/0d89c70d-ad9f-4843-b99f-6cc0252067e9", + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, customHostname) + } +} + +func TestCustomHostname_UpdateCustomHostnameSSL(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/0d89c70d-ad9f-4843-b99f-6cc0252067e9", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "cname", + "type": "dv", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "settings": { + "http2": "off", + "tls_1_3": "on" + } + } + } +}`) + }) + + response, err := client.UpdateCustomHostnameSSL(context.Background(), "foo", "0d89c70d-ad9f-4843-b99f-6cc0252067e9", &CustomHostnameSSL{Method: "cname", Type: "dv", Settings: CustomHostnameSSLSettings{HTTP2: "off", TLS13: "on"}}) + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + }, + Settings: CustomHostnameSSLSettings{ + HTTP2: "off", + TLS13: "on", + }, + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_UpdateCustomHostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/0d89c70d-ad9f-4843-b99f-6cc0252067e9", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + defer r.Body.Close() + reqBody, err := io.ReadAll(r.Body) + assert.NoError(t, err, "Reading request body") + assert.JSONEq(t, ` +{ + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "method": "cname", + "type": "dv", + "wildcard": false, + "settings": {} + }, + "ownership_verification": {}, + "ownership_verification_http": {} +}`, string(reqBody), "Unexpected request body") + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "cname", + "type": "dv", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "settings": { + "http2": "off", + "tls_1_3": "on" + } + } + } +}`) + }) + + wildcard := false + response, err := client.UpdateCustomHostname(context.Background(), "foo", "0d89c70d-ad9f-4843-b99f-6cc0252067e9", CustomHostname{Hostname: "app.example.com", CustomOriginServer: "example.app.com", SSL: &CustomHostnameSSL{Method: "cname", Type: "dv", Wildcard: &wildcard}}) + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + }, + Settings: CustomHostnameSSLSettings{ + HTTP2: "off", + TLS13: "on", + }, + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_UpdateCustomHostnameWithCustomMetadata(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/0d89c70d-ad9f-4843-b99f-6cc0252067e9", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + defer r.Body.Close() + reqBody, err := io.ReadAll(r.Body) + assert.NoError(t, err, "Reading request body") + assert.JSONEq(t, ` +{ + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "method": "cname", + "type": "dv", + "wildcard": false, + "settings": {} + }, + "custom_metadata": { + "a_random_field": "updated field value" + }, + "ownership_verification": {}, + "ownership_verification_http": {} +}`, string(reqBody), "Unexpected request body") + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "cname", + "type": "dv", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "settings": { + "http2": "off", + "tls_1_3": "on" + } + }, + "custom_metadata": { + "a_random_field": "updated field value" + } + } +}`) + }) + + wildcard := false + response, err := client.UpdateCustomHostname(context.Background(), "foo", "0d89c70d-ad9f-4843-b99f-6cc0252067e9", CustomHostname{Hostname: "app.example.com", CustomOriginServer: "example.app.com", SSL: &CustomHostnameSSL{Method: "cname", Type: "dv", Wildcard: &wildcard}, CustomMetadata: &CustomMetadata{"a_random_field": "updated field value"}}) + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + }, + Settings: CustomHostnameSSLSettings{ + HTTP2: "off", + TLS13: "on", + }, + }, + CustomMetadata: &CustomMetadata{ + "a_random_field": "updated field value", + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_UpdateCustomHostnameWithEmptyCustomMetadata(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/0d89c70d-ad9f-4843-b99f-6cc0252067e9", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + defer r.Body.Close() + reqBody, err := io.ReadAll(r.Body) + assert.NoError(t, err, "Reading request body") + assert.JSONEq(t, ` +{ + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "method": "cname", + "type": "dv", + "wildcard": false, + "settings": {} + }, + "custom_metadata": {}, + "ownership_verification": {}, + "ownership_verification_http": {} +}`, string(reqBody), "Unexpected request body") + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + "hostname": "app.example.com", + "custom_origin_server": "example.app.com", + "ssl": { + "status": "pending_validation", + "method": "cname", + "type": "dv", + "cname_target": "dcv.digicert.com", + "cname": "810b7d5f01154524b961ba0cd578acc2.app.example.com", + "settings": { + "http2": "off", + "tls_1_3": "on" + } + }, + "custom_metadata": {} + } +}`) + }) + + wildcard := false + response, err := client.UpdateCustomHostname(context.Background(), "foo", "0d89c70d-ad9f-4843-b99f-6cc0252067e9", CustomHostname{Hostname: "app.example.com", CustomOriginServer: "example.app.com", SSL: &CustomHostnameSSL{Method: "cname", Type: "dv", Wildcard: &wildcard}, CustomMetadata: &CustomMetadata{}}) + + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "0d89c70d-ad9f-4843-b99f-6cc0252067e9", + Hostname: "app.example.com", + CustomOriginServer: "example.app.com", + SSL: &CustomHostnameSSL{ + Type: "dv", + Method: "cname", + Status: "pending_validation", + SSLValidationRecord: SSLValidationRecord{ + CnameTarget: "dcv.digicert.com", + CnameName: "810b7d5f01154524b961ba0cd578acc2.app.example.com", + }, + Settings: CustomHostnameSSLSettings{ + HTTP2: "off", + TLS13: "on", + }, + }, + CustomMetadata: &CustomMetadata{}, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CustomHostnameFallbackOrigin(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/fallback_origin", 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "origin": "fallback.example.com", + "status": "pending_deployment", + "errors": [ + "DNS records are not setup correctly. Origin should be a proxied A/AAAA/CNAME dns record" + ], + "created_at": "2019-10-28T18:11:23.37411Z", + "updated_at": "2020-03-16T18:11:23.531995Z" + } +}`) + }) + + customHostnameFallbackOrigin, err := client.CustomHostnameFallbackOrigin(context.Background(), "foo") + + want := CustomHostnameFallbackOrigin{ + Origin: "fallback.example.com", + Status: "pending_deployment", + Errors: []string{"DNS records are not setup correctly. Origin should be a proxied A/AAAA/CNAME dns record"}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, customHostnameFallbackOrigin) + } +} + +func TestCustomHostname_DeleteCustomHostnameFallbackOrigin(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/fallback_origin", 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.Fprintf(w, ` +{ + "id": "bar" +}`) + }) + + err := client.DeleteCustomHostnameFallbackOrigin(context.Background(), "foo") + + assert.NoError(t, err) +} + +func TestCustomHostname_UpdateCustomHostnameFallbackOrigin(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames/fallback_origin", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "origin": "fallback.example.com", + "status": "pending_deployment", + "errors": [ + "DNS records are not setup correctly. Origin should be a proxied A/AAAA/CNAME dns record" + ], + "created_at": "2019-10-28T18:11:23.37411Z", + "updated_at": "2020-03-16T18:11:23.531995Z" + } +}`) + }) + + response, err := client.UpdateCustomHostnameFallbackOrigin(context.Background(), "foo", CustomHostnameFallbackOrigin{Origin: "fallback.example.com"}) + + want := &CustomHostnameFallbackOriginResponse{ + Result: CustomHostnameFallbackOrigin{ + Origin: "fallback.example.com", + Status: "pending_deployment", + Errors: []string{"DNS records are not setup correctly. Origin should be a proxied A/AAAA/CNAME dns record"}, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func TestCustomHostname_CreateCustomHostnameCustomCertificateAuthority(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/custom_hostnames", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "result": { + "id": "614b3124-cd57-42f0-8307-000000000000", + "hostname": "app.example.com", + "ssl": { + "id": "d9ae4881-34d2-4820-8e28-000000000000", + "type": "dv", + "method": "http", + "status": "initializing", + "settings": { + "min_tls_version": "1.2" + }, + "wildcard": false, + "certificate_authority": "lets_encrypt" + }, + "custom_origin_server": "origin.example.com", + "created_at": "2020-06-30T21:37:36.563495Z" + }, + "success": true, + "errors": [], + "messages": [] +}`) + }) + + response, err := client.CreateCustomHostname(context.Background(), "foo", CustomHostname{Hostname: "app.example.com", SSL: &CustomHostnameSSL{Method: "cname", Type: "dv", CertificateAuthority: "lets_encrypt"}}) + + createdAt, _ := time.Parse(time.RFC3339, "2020-06-30T21:37:36.563495Z") + + wildcard := false + want := &CustomHostnameResponse{ + Result: CustomHostname{ + ID: "614b3124-cd57-42f0-8307-000000000000", + Hostname: "app.example.com", + CustomOriginServer: "origin.example.com", + SSL: &CustomHostnameSSL{ + ID: "d9ae4881-34d2-4820-8e28-000000000000", + Type: "dv", + Method: "http", + Status: "initializing", + Settings: CustomHostnameSSLSettings{ + MinTLSVersion: "1.2", + }, + Wildcard: &wildcard, + CertificateAuthority: "lets_encrypt", + }, + CreatedAt: &createdAt, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} diff --git a/pkg/cloudflare-go/custom_nameservers.go b/pkg/cloudflare-go/custom_nameservers.go new file mode 100644 index 000000000..dda3003c5 --- /dev/null +++ b/pkg/cloudflare-go/custom_nameservers.go @@ -0,0 +1,207 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type CustomNameserverRecord struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type CustomNameserver struct { + NSName string `json:"ns_name"` + NSSet int `json:"ns_set"` +} + +type CustomNameserverResult struct { + DNSRecords []CustomNameserverRecord `json:"dns_records"` + NSName string `json:"ns_name"` + NSSet int `json:"ns_set"` + Status string `json:"status"` + ZoneTag string `json:"zone_tag"` +} + +type CustomNameserverZoneMetadata struct { + NSSet int `json:"ns_set"` + Enabled bool `json:"enabled"` +} + +type customNameserverListResponse struct { + Response + Result []CustomNameserverResult `json:"result"` +} + +type customNameserverCreateResponse struct { + Response + Result CustomNameserverResult `json:"result"` +} + +type getEligibleZonesAccountCustomNameserversResponse struct { + Result []string `json:"result"` +} + +type customNameserverZoneMetadata struct { + Response + Result CustomNameserverZoneMetadata +} + +type GetCustomNameserversParams struct{} + +type CreateCustomNameserversParams struct { + NSName string `json:"ns_name"` + NSSet int `json:"ns_set"` +} + +type DeleteCustomNameserversParams struct { + NSName string +} + +type GetEligibleZonesAccountCustomNameserversParams struct{} + +type GetCustomNameserverZoneMetadataParams struct{} + +type UpdateCustomNameserverZoneMetadataParams struct { + NSSet int `json:"ns_set"` + Enabled bool `json:"enabled"` +} + +// GetCustomNameservers lists custom nameservers. +// +// API documentation: https://developers.cloudflare.com/api/operations/account-level-custom-nameservers-list-account-custom-nameservers +func (api *API) GetCustomNameservers(ctx context.Context, rc *ResourceContainer, params GetCustomNameserversParams) ([]CustomNameserverResult, error) { + if rc.Level != AccountRouteLevel { + return []CustomNameserverResult{}, ErrRequiredAccountLevelResourceContainer + } + uri := fmt.Sprintf("/%s/%s/custom_ns", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var response customNameserverListResponse + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// CreateCustomNameservers adds a custom nameserver. +// +// API documentation: https://developers.cloudflare.com/api/operations/account-level-custom-nameservers-add-account-custom-nameserver +func (api *API) CreateCustomNameservers(ctx context.Context, rc *ResourceContainer, params CreateCustomNameserversParams) (CustomNameserverResult, error) { + if rc.Level != AccountRouteLevel { + return CustomNameserverResult{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/custom_ns", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return CustomNameserverResult{}, err + } + + response := &customNameserverCreateResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return CustomNameserverResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// DeleteCustomNameservers removes a custom nameserver. +// +// API documentation: https://developers.cloudflare.com/api/operations/account-level-custom-nameservers-delete-account-custom-nameserver +func (api *API) DeleteCustomNameservers(ctx context.Context, rc *ResourceContainer, params DeleteCustomNameserversParams) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + if params.NSName == "" { + return errors.New("missing required NSName parameter") + } + + uri := fmt.Sprintf("/%s/%s/custom_ns/%s", rc.Level, rc.Identifier, params.NSName) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// GetEligibleZonesAccountCustomNameservers lists zones eligible for custom nameservers. +// +// API documentation: https://developers.cloudflare.com/api/operations/account-level-custom-nameservers-get-eligible-zones-for-account-custom-nameservers +func (api *API) GetEligibleZonesAccountCustomNameservers(ctx context.Context, rc *ResourceContainer, params GetEligibleZonesAccountCustomNameserversParams) ([]string, error) { + if rc.Level != AccountRouteLevel { + return []string{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/custom_ns/availability", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var response getEligibleZonesAccountCustomNameserversResponse + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// GetCustomNameserverZoneMetadata get metadata for custom nameservers on a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/account-level-custom-nameservers-usage-for-a-zone-get-account-custom-nameserver-related-zone-metadata +func (api *API) GetCustomNameserverZoneMetadata(ctx context.Context, rc *ResourceContainer, params GetCustomNameserverZoneMetadataParams) (CustomNameserverZoneMetadata, error) { + if rc.Level != ZoneRouteLevel { + return CustomNameserverZoneMetadata{}, ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/custom_ns", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return CustomNameserverZoneMetadata{}, err + } + + var response customNameserverZoneMetadata + err = json.Unmarshal(res, &response) + if err != nil { + return CustomNameserverZoneMetadata{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// UpdateCustomNameserverZoneMetadata set metadata for custom nameservers on a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/account-level-custom-nameservers-usage-for-a-zone-set-account-custom-nameserver-related-zone-metadata +func (api *API) UpdateCustomNameserverZoneMetadata(ctx context.Context, rc *ResourceContainer, params UpdateCustomNameserverZoneMetadataParams) error { + if rc.Level != ZoneRouteLevel { + return ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/custom_ns", rc.Level, rc.Identifier) + + _, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/custom_nameservers_test.go b/pkg/cloudflare-go/custom_nameservers_test.go new file mode 100644 index 000000000..423205cac --- /dev/null +++ b/pkg/cloudflare-go/custom_nameservers_test.go @@ -0,0 +1,223 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccountCustomNameserver_Get(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "ns_name": "ns1.example.com", + "ns_set": 1, + "dns_records": [ + { + "type": "A", + "value": "192.0.2.1" + }, + { + "type": "AAAA", + "value": "2400:cb00:2049:1::ffff:ffee" + } + ] + }, + { + "ns_name": "ns2.example.com", + "ns_set": 1, + "dns_records": [ + { + "type": "A", + "value": "192.0.2.2" + }, + { + "type": "AAAA", + "value": "2400:cb00:2049:1::ffff:fffe" + } + ] + } + ] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/custom_ns", handler) + want := []CustomNameserverResult{ + { + DNSRecords: []CustomNameserverRecord{ + { + Type: "A", + Value: "192.0.2.1", + }, + { + Type: "AAAA", + Value: "2400:cb00:2049:1::ffff:ffee", + }, + }, + NSName: "ns1.example.com", + NSSet: 1, + }, + { + DNSRecords: []CustomNameserverRecord{ + { + Type: "A", + Value: "192.0.2.2", + }, + { + Type: "AAAA", + Value: "2400:cb00:2049:1::ffff:fffe", + }, + }, + NSName: "ns2.example.com", + NSSet: 1, + }, + } + + actual, err := client.GetCustomNameservers(context.Background(), AccountIdentifier(testAccountID), GetCustomNameserversParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccountCustomNameserver_Create(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'GET', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ns_name": "ns1.example.com", + "ns_set": 1, + "dns_records": [ + { + "type": "A", + "value": "192.0.2.1" + }, + { + "type": "AAAA", + "value": "2400:cb00:2049:1::ffff:ffee" + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/custom_ns", handler) + want := CustomNameserverResult{ + DNSRecords: []CustomNameserverRecord{ + { + Type: "A", + Value: "192.0.2.1", + }, + { + Type: "AAAA", + Value: "2400:cb00:2049:1::ffff:ffee", + }, + }, + NSName: "ns1.example.com", + NSSet: 1, + } + + actual, err := client.CreateCustomNameservers( + context.Background(), + AccountIdentifier(testAccountID), + CreateCustomNameserversParams{ + NSName: "ns1.example.com", + NSSet: 1, + }, + ) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccountCustomNameserver_GetEligibleZones(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + "example.com", + "example2.com", + "example3.com" + ], + "success": true, + "errors": [], + "messages": [] +}`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/custom_ns/availability", handler) + want := []string{ + "example.com", + "example2.com", + "example3.com", + } + + actual, err := client.GetEligibleZonesAccountCustomNameservers( + context.Background(), + AccountIdentifier(testAccountID), + GetEligibleZonesAccountCustomNameserversParams{}, + ) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestAccountCustomNameserver_GetAccountCustomNameserverZoneMetadata(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "ns_set": 1, + "enabled": true + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/custom_ns", handler) + want := CustomNameserverZoneMetadata{ + NSSet: 1, + Enabled: true, + } + + actual, err := client.GetCustomNameserverZoneMetadata( + context.Background(), + ZoneIdentifier(testZoneID), + GetCustomNameserverZoneMetadataParams{}, + ) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/custom_pages.go b/pkg/cloudflare-go/custom_pages.go new file mode 100644 index 000000000..ae68d680f --- /dev/null +++ b/pkg/cloudflare-go/custom_pages.go @@ -0,0 +1,177 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// CustomPage represents a custom page configuration. +type CustomPage struct { + CreatedOn time.Time `json:"created_on"` + ModifiedOn time.Time `json:"modified_on"` + URL interface{} `json:"url"` + State string `json:"state"` + RequiredTokens []string `json:"required_tokens"` + PreviewTarget string `json:"preview_target"` + Description string `json:"description"` + ID string `json:"id"` +} + +// CustomPageResponse represents the response from the custom pages endpoint. +type CustomPageResponse struct { + Response + Result []CustomPage `json:"result"` +} + +// CustomPageDetailResponse represents the response from the custom page endpoint. +type CustomPageDetailResponse struct { + Response + Result CustomPage `json:"result"` +} + +// CustomPageOptions is used to determine whether or not the operation +// should take place on an account or zone level based on which is +// provided to the function. +// +// A non-empty value denotes desired use. +type CustomPageOptions struct { + AccountID string + ZoneID string +} + +// CustomPageParameters is used to update a particular custom page with +// the values provided. +type CustomPageParameters struct { + URL interface{} `json:"url"` + State string `json:"state"` +} + +// CustomPages lists custom pages for a zone or account. +// +// Zone API reference: https://api.cloudflare.com/#custom-pages-for-a-zone-list-available-custom-pages +// Account API reference: https://api.cloudflare.com/#custom-pages-account--list-custom-pages +func (api *API) CustomPages(ctx context.Context, options *CustomPageOptions) ([]CustomPage, error) { + var ( + pageType, identifier string + ) + + if options.AccountID == "" && options.ZoneID == "" { + return nil, ErrAccountIDOrZoneIDAreRequired + } + + if options.AccountID != "" && options.ZoneID != "" { + return nil, ErrAccountIDAndZoneIDAreMutuallyExclusive + } + + // Should the account ID be defined, treat this as an account level operation. + if options.AccountID != "" { + pageType = "accounts" + identifier = options.AccountID + } else { + pageType = "zones" + identifier = options.ZoneID + } + + uri := fmt.Sprintf("/%s/%s/custom_pages", pageType, identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var customPageResponse CustomPageResponse + err = json.Unmarshal(res, &customPageResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return customPageResponse.Result, nil +} + +// CustomPage lists a single custom page based on the ID. +// +// Zone API reference: https://api.cloudflare.com/#custom-pages-for-a-zone-custom-page-details +// Account API reference: https://api.cloudflare.com/#custom-pages-account--custom-page-details +func (api *API) CustomPage(ctx context.Context, options *CustomPageOptions, customPageID string) (CustomPage, error) { + var ( + pageType, identifier string + ) + + if options.AccountID == "" && options.ZoneID == "" { + return CustomPage{}, ErrAccountIDOrZoneIDAreRequired + } + + if options.AccountID != "" && options.ZoneID != "" { + return CustomPage{}, ErrAccountIDAndZoneIDAreMutuallyExclusive + } + + // Should the account ID be defined, treat this as an account level operation. + if options.AccountID != "" { + pageType = "accounts" + identifier = options.AccountID + } else { + pageType = "zones" + identifier = options.ZoneID + } + + uri := fmt.Sprintf("/%s/%s/custom_pages/%s", pageType, identifier, customPageID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return CustomPage{}, err + } + + var customPageResponse CustomPageDetailResponse + err = json.Unmarshal(res, &customPageResponse) + if err != nil { + return CustomPage{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return customPageResponse.Result, nil +} + +// UpdateCustomPage updates a single custom page setting. +// +// Zone API reference: https://api.cloudflare.com/#custom-pages-for-a-zone-update-custom-page-url +// Account API reference: https://api.cloudflare.com/#custom-pages-account--update-custom-page +func (api *API) UpdateCustomPage(ctx context.Context, options *CustomPageOptions, customPageID string, pageParameters CustomPageParameters) (CustomPage, error) { + var ( + pageType, identifier string + ) + + if options.AccountID == "" && options.ZoneID == "" { + return CustomPage{}, ErrAccountIDOrZoneIDAreRequired + } + + if options.AccountID != "" && options.ZoneID != "" { + return CustomPage{}, ErrAccountIDAndZoneIDAreMutuallyExclusive + } + + // Should the account ID be defined, treat this as an account level operation. + if options.AccountID != "" { + pageType = "accounts" + identifier = options.AccountID + } else { + pageType = "zones" + identifier = options.ZoneID + } + + uri := fmt.Sprintf("/%s/%s/custom_pages/%s", pageType, identifier, customPageID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, pageParameters) + if err != nil { + return CustomPage{}, err + } + + var customPageResponse CustomPageDetailResponse + err = json.Unmarshal(res, &customPageResponse) + if err != nil { + return CustomPage{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return customPageResponse.Result, nil +} diff --git a/pkg/cloudflare-go/custom_pages_test.go b/pkg/cloudflare-go/custom_pages_test.go new file mode 100644 index 000000000..a8d28beed --- /dev/null +++ b/pkg/cloudflare-go/custom_pages_test.go @@ -0,0 +1,351 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var timestamp, _ = time.Parse(time.RFC3339Nano, "2014-01-01T05:20:00.12345Z") +var expectedCustomPage = CustomPage{ + ID: "basic_challenge", + CreatedOn: timestamp, + ModifiedOn: timestamp, + URL: "http://www.example.com", + State: "default", + RequiredTokens: []string{"::CAPTCHA_BOX::"}, + PreviewTarget: "preview:target", + Description: "Basic challenge", +} +var updatedCustomPage = CustomPage{ + ID: "basic_challenge", + CreatedOn: timestamp, + ModifiedOn: timestamp, + URL: "https://mytestexample.com", + State: "customized", + RequiredTokens: []string{"::CAPTCHA_BOX::"}, + PreviewTarget: "preview:target", + Description: "Basic challenge", +} +var defaultCustomPage = CustomPage{ + ID: "basic_challenge", + CreatedOn: timestamp, + ModifiedOn: timestamp, + URL: nil, + State: "default", + RequiredTokens: []string{"::CAPTCHA_BOX::"}, + PreviewTarget: "preview:target", + Description: "Basic challenge", +} + +func TestCustomPagesWithoutZoneIDOrAccountID(t *testing.T) { + _, err := client.CustomPages(context.Background(), &CustomPageOptions{}) + assert.EqualError(t, err, "either account ID or zone ID must be provided") +} + +func TestCustomPagesWithZoneIDAndAccountID(t *testing.T) { + _, err := client.CustomPages(context.Background(), &CustomPageOptions{ZoneID: "abc123", AccountID: "321cba"}) + assert.EqualError(t, err, "account ID and zone ID are mutually exclusive") +} + +func TestCustomPagesForZone(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "basic_challenge", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url": "http://www.example.com", + "state": "default", + "required_tokens": [ + "::CAPTCHA_BOX::" + ], + "preview_target": "preview:target", + "description": "Basic challenge" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/zones/d992d6de698eaf2d8cf8fd53b89b18a4/custom_pages", handler) + want := []CustomPage{expectedCustomPage} + + pages, err := client.CustomPages(context.Background(), &CustomPageOptions{ZoneID: "d992d6de698eaf2d8cf8fd53b89b18a4"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, pages) + } +} + +func TestCustomPagesForAccount(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "basic_challenge", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url": "http://www.example.com", + "state": "default", + "required_tokens": [ + "::CAPTCHA_BOX::" + ], + "preview_target": "preview:target", + "description": "Basic challenge" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/custom_pages", handler) + want := []CustomPage{expectedCustomPage} + + pages, err := client.CustomPages(context.Background(), &CustomPageOptions{AccountID: "01a7362d577a6c3019a474fd6f485823"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, pages) + } +} + +func TestCustomPageForZone(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "basic_challenge", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url": "http://www.example.com", + "state": "default", + "required_tokens": [ + "::CAPTCHA_BOX::" + ], + "preview_target": "preview:target", + "description": "Basic challenge" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/zones/d992d6de698eaf2d8cf8fd53b89b18a4/custom_pages/basic_challenge", handler) + + page, err := client.CustomPage(context.Background(), &CustomPageOptions{ZoneID: "d992d6de698eaf2d8cf8fd53b89b18a4"}, "basic_challenge") + + if assert.NoError(t, err) { + assert.Equal(t, expectedCustomPage, page) + } +} + +func TestCustomPageForAccount(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "basic_challenge", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url": "http://www.example.com", + "state": "default", + "required_tokens": [ + "::CAPTCHA_BOX::" + ], + "preview_target": "preview:target", + "description": "Basic challenge" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/custom_pages/basic_challenge", handler) + + page, err := client.CustomPage(context.Background(), &CustomPageOptions{AccountID: "01a7362d577a6c3019a474fd6f485823"}, "basic_challenge") + + if assert.NoError(t, err) { + assert.Equal(t, expectedCustomPage, page) + } +} + +func TestUpdateCustomPagesForAccount(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "basic_challenge", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url": "https://mytestexample.com", + "state": "customized", + "required_tokens": [ + "::CAPTCHA_BOX::" + ], + "preview_target": "preview:target", + "description": "Basic challenge" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/custom_pages/basic_challenge", handler) + actual, err := client.UpdateCustomPage(context.Background(), &CustomPageOptions{AccountID: "01a7362d577a6c3019a474fd6f485823"}, "basic_challenge", CustomPageParameters{URL: "https://mytestexample.com", State: "customized"}) + + if assert.NoError(t, err) { + assert.Equal(t, updatedCustomPage, actual) + } +} + +func TestUpdateCustomPagesForZone(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "basic_challenge", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url": "https://mytestexample.com", + "state": "customized", + "required_tokens": [ + "::CAPTCHA_BOX::" + ], + "preview_target": "preview:target", + "description": "Basic challenge" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/zones/d992d6de698eaf2d8cf8fd53b89b18a4/custom_pages/basic_challenge", handler) + actual, err := client.UpdateCustomPage(context.Background(), &CustomPageOptions{ZoneID: "d992d6de698eaf2d8cf8fd53b89b18a4"}, "basic_challenge", CustomPageParameters{URL: "https://mytestexample.com", State: "customized"}) + + if assert.NoError(t, err) { + assert.Equal(t, updatedCustomPage, actual) + } +} + +func TestUpdateCustomPagesToDefault(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "result":{ + "id":"basic_challenge", + "description":"Basic challenge", + "required_tokens":[ + "::CAPTCHA_BOX::" + ], + "preview_target":"preview:target", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "url":null, + "state":"default" + }, + "success":true, + "errors":[], + "messages":[] + } + `) + } + + mux.HandleFunc("/zones/d992d6de698eaf2d8cf8fd53b89b18a4/custom_pages/basic_challenge", handler) + actual, err := client.UpdateCustomPage(context.Background(), &CustomPageOptions{ZoneID: "d992d6de698eaf2d8cf8fd53b89b18a4"}, "basic_challenge", CustomPageParameters{URL: nil, State: "default"}) + + if assert.NoError(t, err) { + assert.Equal(t, defaultCustomPage, actual) + } +} diff --git a/pkg/cloudflare-go/d1.go b/pkg/cloudflare-go/d1.go new file mode 100644 index 000000000..08a3ecc51 --- /dev/null +++ b/pkg/cloudflare-go/d1.go @@ -0,0 +1,199 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingDatabaseID = fmt.Errorf("required missing database ID") +) + +type D1Database struct { + Name string `json:"name"` + NumTables int `json:"num_tables"` + UUID string `json:"uuid"` + Version string `json:"version"` + CreatedAt *time.Time `json:"created_at"` + FileSize int64 `json:"file_size"` +} + +type ListD1DatabasesParams struct { + Name string `url:"name,omitempty"` + ResultInfo +} + +type ListD1Response struct { + Result []D1Database `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type CreateD1DatabaseParams struct { + Name string `json:"name"` +} + +type D1DatabaseResponse struct { + Result D1Database `json:"result"` + Response +} + +type QueryD1DatabaseParams struct { + DatabaseID string `json:"-"` + SQL string `json:"sql"` + Parameters []string `json:"params"` +} + +type D1DatabaseMetadata struct { + ChangedDB *bool `json:"changed_db,omitempty"` + Changes int `json:"changes"` + Duration float64 `json:"duration"` + LastRowID int `json:"last_row_id"` + RowsRead int `json:"rows_read"` + RowsWritten int `json:"rows_written"` + SizeAfter int `json:"size_after"` +} + +type D1Result struct { + Success *bool `json:"success"` + Results []map[string]any `json:"results"` + Meta D1DatabaseMetadata `json:"meta"` +} + +type QueryD1Response struct { + Result []D1Result `json:"result"` + Response +} + +// ListD1Databases returns all databases for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/cloudflare-d1-list-databases +func (api *API) ListD1Databases(ctx context.Context, rc *ResourceContainer, params ListD1DatabasesParams) ([]D1Database, *ResultInfo, error) { + if rc.Identifier == "" { + return []D1Database{}, &ResultInfo{}, ErrMissingAccountID + } + baseURL := fmt.Sprintf("/accounts/%s/d1/database", rc.Identifier) + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 100 + } + + if params.Page < 1 { + params.Page = 1 + } + var databases []D1Database + var r ListD1Response + for { + uri := buildURI(baseURL, params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []D1Database{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + err = json.Unmarshal(res, &r) + if err != nil { + return []D1Database{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + databases = append(databases, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + return databases, &r.ResultInfo, nil +} + +// CreateD1Database creates a new database for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/cloudflare-d1-create-database +func (api *API) CreateD1Database(ctx context.Context, rc *ResourceContainer, params CreateD1DatabaseParams) (D1Database, error) { + if rc.Identifier == "" { + return D1Database{}, ErrMissingAccountID + } + uri := fmt.Sprintf("/accounts/%s/d1/database", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return D1Database{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r D1DatabaseResponse + err = json.Unmarshal(res, &r) + if err != nil { + return D1Database{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteD1Database deletes a database for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/cloudflare-d1-delete-database +func (api *API) DeleteD1Database(ctx context.Context, rc *ResourceContainer, databaseID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + if databaseID == "" { + return ErrMissingDatabaseID + } + uri := fmt.Sprintf("/accounts/%s/d1/database/%s", rc.Identifier, databaseID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + return nil +} + +// GetD1Database returns a database for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/cloudflare-d1-get-database +func (api *API) GetD1Database(ctx context.Context, rc *ResourceContainer, databaseID string) (D1Database, error) { + if rc.Identifier == "" { + return D1Database{}, ErrMissingAccountID + } + uri := fmt.Sprintf("/accounts/%s/d1/database/%s", rc.Identifier, databaseID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return D1Database{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r D1DatabaseResponse + err = json.Unmarshal(res, &r) + if err != nil { + return D1Database{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// QueryD1Database queries a database for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/cloudflare-d1-query-database +func (api *API) QueryD1Database(ctx context.Context, rc *ResourceContainer, params QueryD1DatabaseParams) ([]D1Result, error) { + if rc.Identifier == "" { + return []D1Result{}, ErrMissingAccountID + } + if params.DatabaseID == "" { + return []D1Result{}, ErrMissingDatabaseID + } + uri := fmt.Sprintf("/accounts/%s/d1/database/%s/query", rc.Identifier, params.DatabaseID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return []D1Result{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r QueryD1Response + err = json.Unmarshal(res, &r) + if err != nil { + return []D1Result{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} diff --git a/pkg/cloudflare-go/d1_test.go b/pkg/cloudflare-go/d1_test.go new file mode 100644 index 000000000..1f3c5399a --- /dev/null +++ b/pkg/cloudflare-go/d1_test.go @@ -0,0 +1,238 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testD1DatabaseID = "480f4f691a284fdd92401ed29f0ac1df" + testD1Result = `{ + "created_at": "2014-01-01T05:20:00.12345Z", + "name": "my-database", + "uuid": "480f4f691a284fdd92401ed29f0ac1df", + "version": "beta", + "num_tables": 1, + "file_size": 100 + } +` +) + +var ( + d1CreatedAt, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + testD1Database = D1Database{ + Name: "my-database", + NumTables: 1, + UUID: testD1DatabaseID, + Version: "beta", + CreatedAt: &d1CreatedAt, + FileSize: 100, + } +) + +func TestListD1Databases(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/d1/database", 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ], + "result_info": { + "page": 1, + "per_page": 100, + "count": 1, + "total_count": 1 + } + }`, testD1Result) + }) + + _, _, err := client.ListD1Databases(context.Background(), AccountIdentifier(""), ListD1DatabasesParams{}) + if assert.Error(t, err, "Didn't get error for missing Account ID get listing D1 Database") { + assert.Equal(t, err.Error(), errMissingAccountID) + } + actual, _, err := client.ListD1Databases(context.Background(), testAccountRC, ListD1DatabasesParams{}) + if assert.NoError(t, err, "ListD1Databases returned error: %v", err) { + expected := []D1Database{testD1Database} + if !assert.Equal(t, expected, actual) { + t.Errorf("ListD1Databases returned %+v, expected %+v", actual, expected) + } + } +} + +func TestGetD1Databases(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/d1/database/"+testD1DatabaseID, 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testD1Result) + }) + + _, err := client.GetD1Database(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err, "Didn't get error for missing Account ID get getting D1 Database") { + assert.Equal(t, err.Error(), errMissingAccountID) + } + actual, err := client.GetD1Database(context.Background(), testAccountRC, testD1DatabaseID) + if assert.NoError(t, err, "GetD1Database returned error: %v", err) { + if !assert.Equal(t, testD1Database, actual) { + t.Errorf("GetD1Database returned %+v, expected %+v", actual, testD1Database) + } + } +} + +func TestCreateD1Database(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/d1/database", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testD1Result) + }) + + _, err := client.CreateD1Database(context.Background(), AccountIdentifier(""), CreateD1DatabaseParams{}) + if assert.Error(t, err, "Didn't get error for missing Account ID get creating D1 Database") { + assert.Equal(t, err.Error(), errMissingAccountID) + } + actual, err := client.CreateD1Database(context.Background(), testAccountRC, CreateD1DatabaseParams{ + Name: "my-database", + }) + if assert.NoError(t, err, "CreateD1Database returned error: %v", err) { + if !assert.Equal(t, testD1Database, actual) { + t.Errorf("CreateD1Database returned %+v, expected %+v", actual, testD1Database) + } + } +} + +func TestDeleteD1Database(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/d1/database/"+testD1DatabaseID, 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [] + }`) + }) + + err := client.DeleteD1Database(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err, "Didn't get error for missing Account ID get deleting D1 Database") { + assert.Equal(t, err.Error(), errMissingAccountID) + } + err = client.DeleteD1Database(context.Background(), testAccountRC, testD1DatabaseID) + assert.NoError(t, err, "DeleteD1Database returned error: %v", err) +} + +func TestQueryD1Database(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/d1/database/"+testD1DatabaseID+"/query", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("Error reading request body: %v", err) + } + if got := string(b); got != `{"sql":"SELECT * FROM my-database","params":["param1","param2"]}` { + t.Errorf("request Body is %s, want %s", got, `{"sql":"SELECT * FROM my-database","params":["param1","param2"]}`) + } + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "success": true, + "meta": { + "changed_db": false, + "changes": 0, + "duration": 3.3, + "last_row_id": 3, + "rows_read": 3, + "rows_written": 0, + "size_after": 10 + }, + "results": [ + { + "id": 1, + "name": "test user" + }, + { + "id": 2, + "name": "test user 2" + } + ] + } + ] + }`) + }) + + _, err := client.QueryD1Database(context.Background(), AccountIdentifier(""), QueryD1DatabaseParams{}) + if assert.Error(t, err, "Didn't get error for missing Account ID get querying D1 Database") { + assert.Equal(t, err, ErrMissingAccountID) + } + _, err = client.QueryD1Database(context.Background(), testAccountRC, QueryD1DatabaseParams{}) + if assert.Error(t, err, "Didn't get error for missing D1 Database ID get querying D1 Database") { + assert.Equal(t, err, ErrMissingDatabaseID) + } + actual, err := client.QueryD1Database(context.Background(), testAccountRC, QueryD1DatabaseParams{ + DatabaseID: testD1DatabaseID, + SQL: "SELECT * FROM my-database", + Parameters: []string{"param1", "param2"}, + }) + if assert.NoError(t, err, "QueryD1Database returned error: %v", err) { + expected := D1Result{ + Success: BoolPtr(true), + Meta: D1DatabaseMetadata{ + ChangedDB: BoolPtr(false), + Changes: 0, + Duration: 3.3, + LastRowID: 3, + RowsRead: 3, + RowsWritten: 0, + SizeAfter: 10, + }, + Results: []map[string]any{ + { + "id": float64(1), + "name": "test user", + }, + { + "id": float64(2), + "name": "test user 2", + }, + }, + } + if !assert.Equal(t, expected, actual[0]) { + t.Errorf("QueryD1Database returned %+v, expected %+v", actual, expected) + } + } +} diff --git a/pkg/cloudflare-go/dcv_delegation.go b/pkg/cloudflare-go/dcv_delegation.go new file mode 100644 index 000000000..db66db273 --- /dev/null +++ b/pkg/cloudflare-go/dcv_delegation.go @@ -0,0 +1,41 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type DCVDelegation struct { + UUID string `json:"uuid"` +} + +// DCVDelegationResponse represents the response from the dcv_delegation/uuid endpoint. +type DCVDelegationResponse struct { + Result DCVDelegation `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type GetDCVDelegationParams struct{} + +// GetDCVDelegation gets a zone DCV Delegation UUID. +// +// API documentation: https://developers.cloudflare.com/api/operations/dcv-delegation-uuid-get +func (api *API) GetDCVDelegation(ctx context.Context, rc *ResourceContainer, params GetDCVDelegationParams) (DCVDelegation, ResultInfo, error) { + uri := fmt.Sprintf("/zones/%s/dcv_delegation/uuid", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DCVDelegation{}, ResultInfo{}, err + } + var dcvResponse DCVDelegationResponse + err = json.Unmarshal(res, &dcvResponse) + if err != nil { + return DCVDelegation{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dcvResponse.Result, dcvResponse.ResultInfo, nil +} diff --git a/pkg/cloudflare-go/dcv_delegation_test.go b/pkg/cloudflare-go/dcv_delegation_test.go new file mode 100644 index 000000000..a7a0350b2 --- /dev/null +++ b/pkg/cloudflare-go/dcv_delegation_test.go @@ -0,0 +1,43 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetDCVDelegation(t *testing.T) { + setup() + defer teardown() + + testUuid := "b9ab465427f949ed" + + 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.Fprintf(w, `{ + "success" : true, + "errors": [], + "messages": [], + "result": { + "uuid": "%s" + } +} + `, testUuid) + } + + mux.HandleFunc("/zones/"+testZoneID+"/dcv_delegation/uuid", handler) + + want := DCVDelegation{ + UUID: testUuid, + } + + actual, _, err := client.GetDCVDelegation(context.Background(), ZoneIdentifier(testZoneID), GetDCVDelegationParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/device_posture_rule.go b/pkg/cloudflare-go/device_posture_rule.go new file mode 100644 index 000000000..4768b7704 --- /dev/null +++ b/pkg/cloudflare-go/device_posture_rule.go @@ -0,0 +1,331 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// DevicePostureIntegrationConfig contains authentication information +// for a device posture integration. +type DevicePostureIntegrationConfig struct { + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` + AuthUrl string `json:"auth_url,omitempty"` + ApiUrl string `json:"api_url,omitempty"` + ClientKey string `json:"client_key,omitempty"` + CustomerID string `json:"customer_id,omitempty"` + AccessClientID string `json:"access_client_id,omitempty"` + AccessClientSecret string `json:"access_client_secret,omitempty"` +} + +// DevicePostureIntegration represents a device posture integration. +type DevicePostureIntegration struct { + IntegrationID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Interval string `json:"interval,omitempty"` + Config DevicePostureIntegrationConfig `json:"config,omitempty"` +} + +// DevicePostureIntegrationResponse represents the response from the get +// device posture integrations endpoint. +type DevicePostureIntegrationResponse struct { + Result DevicePostureIntegration `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// DevicePostureIntegrationListResponse represents the response from the list +// device posture integrations endpoint. +type DevicePostureIntegrationListResponse struct { + Result []DevicePostureIntegration `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// CreateDevicePostureIntegration creates a device posture integration within an account. +// +// API reference: https://api.cloudflare.com/#device-posture-integrations-create-device-posture-integration +func (api *API) CreateDevicePostureIntegration(ctx context.Context, accountID string, integration DevicePostureIntegration) (DevicePostureIntegration, error) { + uri := fmt.Sprintf("/%s/%s/devices/posture/integration", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, integration) + if err != nil { + fmt.Printf("err:%+v res:%+v\n", err, res) + return DevicePostureIntegration{}, err + } + + var devicePostureIntegrationResponse DevicePostureIntegrationResponse + err = json.Unmarshal(res, &devicePostureIntegrationResponse) + if err != nil { + return DevicePostureIntegration{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureIntegrationResponse.Result, nil +} + +// UpdateDevicePostureIntegration updates a device posture integration within an account. +// +// API reference: https://api.cloudflare.com/#device-posture-integrations-update-device-posture-integration +func (api *API) UpdateDevicePostureIntegration(ctx context.Context, accountID string, integration DevicePostureIntegration) (DevicePostureIntegration, error) { + uri := fmt.Sprintf("/%s/%s/devices/posture/integration/%s", AccountRouteRoot, accountID, integration.IntegrationID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, integration) + if err != nil { + return DevicePostureIntegration{}, err + } + + var devicePostureIntegrationResponse DevicePostureIntegrationResponse + err = json.Unmarshal(res, &devicePostureIntegrationResponse) + if err != nil { + return DevicePostureIntegration{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureIntegrationResponse.Result, nil +} + +// DevicePostureIntegration returns a specific device posture integrations within an account. +// +// API reference: https://api.cloudflare.com/#device-posture-integrations-device-posture-integration-details +func (api *API) DevicePostureIntegration(ctx context.Context, accountID, integrationID string) (DevicePostureIntegration, error) { + uri := fmt.Sprintf("/%s/%s/devices/posture/integration/%s", AccountRouteRoot, accountID, integrationID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DevicePostureIntegration{}, err + } + + var devicePostureIntegrationResponse DevicePostureIntegrationResponse + err = json.Unmarshal(res, &devicePostureIntegrationResponse) + if err != nil { + return DevicePostureIntegration{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureIntegrationResponse.Result, nil +} + +// DevicePostureIntegrations returns all device posture integrations within an account. +// +// API reference: https://api.cloudflare.com/#device-posture-integrations-list-device-posture-integrations +func (api *API) DevicePostureIntegrations(ctx context.Context, accountID string) ([]DevicePostureIntegration, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/devices/posture/integration", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DevicePostureIntegration{}, ResultInfo{}, err + } + + var devicePostureIntegrationListResponse DevicePostureIntegrationListResponse + err = json.Unmarshal(res, &devicePostureIntegrationListResponse) + if err != nil { + return []DevicePostureIntegration{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureIntegrationListResponse.Result, devicePostureIntegrationListResponse.ResultInfo, nil +} + +// DeleteDevicePostureIntegration deletes a device posture integration. +// +// API reference: https://api.cloudflare.com/#device-posture-integrations-delete-device-posture-integration +func (api *API) DeleteDevicePostureIntegration(ctx context.Context, accountID, ruleID string) error { + uri := fmt.Sprintf( + "/%s/%s/devices/posture/integration/%s", + AccountRouteRoot, + accountID, + ruleID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// DevicePostureRule represents a device posture rule. +type DevicePostureRule struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Schedule string `json:"schedule,omitempty"` + Match []DevicePostureRuleMatch `json:"match,omitempty"` + Input DevicePostureRuleInput `json:"input,omitempty"` + Expiration string `json:"expiration,omitempty"` +} + +// DevicePostureRuleMatch represents the conditions that the client must match to run the rule. +type DevicePostureRuleMatch struct { + Platform string `json:"platform,omitempty"` +} + +// DevicePostureRuleInput represents the value to be checked against. +type DevicePostureRuleInput struct { + ID string `json:"id,omitempty"` + Path string `json:"path,omitempty"` + Exists bool `json:"exists,omitempty"` + Thumbprint string `json:"thumbprint,omitempty"` + Sha256 string `json:"sha256,omitempty"` + Running bool `json:"running,omitempty"` + RequireAll bool `json:"requireAll,omitempty"` + CheckDisks []string `json:"checkDisks,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Version string `json:"version,omitempty"` + VersionOperator string `json:"versionOperator,omitempty"` + Overall string `json:"overall,omitempty"` + SensorConfig string `json:"sensor_config,omitempty"` + Os string `json:"os,omitempty"` + OsDistroName string `json:"os_distro_name,omitempty"` + OsDistroRevision string `json:"os_distro_revision,omitempty"` + OSVersionExtra string `json:"os_version_extra,omitempty"` + Operator string `json:"operator,omitempty"` + Domain string `json:"domain,omitempty"` + ComplianceStatus string `json:"compliance_status,omitempty"` + ConnectionID string `json:"connection_id,omitempty"` + IssueCount string `json:"issue_count,omitempty"` + CountOperator string `json:"countOperator,omitempty"` + TotalScore int `json:"total_score,omitempty"` + ScoreOperator string `json:"scoreOperator,omitempty"` + CertificateID string `json:"certificate_id,omitempty"` + CommonName string `json:"cn,omitempty"` + ActiveThreats int `json:"active_threats,omitempty"` + NetworkStatus string `json:"network_status,omitempty"` + Infected bool `json:"infected,omitempty"` + IsActive bool `json:"is_active,omitempty"` + EidLastSeen string `json:"eid_last_seen,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + State string `json:"state,omitempty"` + LastSeen string `json:"last_seen,omitempty"` +} + +// DevicePostureRuleListResponse represents the response from the list +// device posture rules endpoint. +type DevicePostureRuleListResponse struct { + Result []DevicePostureRule `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// DevicePostureRuleDetailResponse is the API response, containing a single +// device posture rule. +type DevicePostureRuleDetailResponse struct { + Response + Result DevicePostureRule `json:"result"` +} + +// DevicePostureRules returns all device posture rules within an account. +// +// API reference: https://api.cloudflare.com/#device-posture-rules-list-device-posture-rules +func (api *API) DevicePostureRules(ctx context.Context, accountID string) ([]DevicePostureRule, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/devices/posture", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DevicePostureRule{}, ResultInfo{}, err + } + + var devicePostureRuleListResponse DevicePostureRuleListResponse + err = json.Unmarshal(res, &devicePostureRuleListResponse) + if err != nil { + return []DevicePostureRule{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureRuleListResponse.Result, devicePostureRuleListResponse.ResultInfo, nil +} + +// DevicePostureRule returns a single device posture rule based on the rule ID. +// +// API reference: https://api.cloudflare.com/#device-posture-rules-device-posture-rules-details +func (api *API) DevicePostureRule(ctx context.Context, accountID, ruleID string) (DevicePostureRule, error) { + uri := fmt.Sprintf( + "/%s/%s/devices/posture/%s", + AccountRouteRoot, + accountID, + ruleID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DevicePostureRule{}, err + } + + var devicePostureRuleDetailResponse DevicePostureRuleDetailResponse + err = json.Unmarshal(res, &devicePostureRuleDetailResponse) + if err != nil { + return DevicePostureRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureRuleDetailResponse.Result, nil +} + +// CreateDevicePostureRule creates a new device posture rule. +// +// API reference: https://api.cloudflare.com/#device-posture-rules-create-device-posture-rule +func (api *API) CreateDevicePostureRule(ctx context.Context, accountID string, rule DevicePostureRule) (DevicePostureRule, error) { + uri := fmt.Sprintf("/%s/%s/devices/posture", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, rule) + if err != nil { + return DevicePostureRule{}, err + } + + var devicePostureRuleDetailResponse DevicePostureRuleDetailResponse + err = json.Unmarshal(res, &devicePostureRuleDetailResponse) + if err != nil { + return DevicePostureRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureRuleDetailResponse.Result, nil +} + +// UpdateDevicePostureRule updates an existing device posture rule. +// +// API reference: https://api.cloudflare.com/#device-posture-rules-update-device-posture-rule +func (api *API) UpdateDevicePostureRule(ctx context.Context, accountID string, rule DevicePostureRule) (DevicePostureRule, error) { + if rule.ID == "" { + return DevicePostureRule{}, fmt.Errorf("device posture rule ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/devices/posture/%s", + AccountRouteRoot, + accountID, + rule.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, rule) + if err != nil { + return DevicePostureRule{}, err + } + + var devicePostureRuleDetailResponse DevicePostureRuleDetailResponse + err = json.Unmarshal(res, &devicePostureRuleDetailResponse) + if err != nil { + return DevicePostureRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return devicePostureRuleDetailResponse.Result, nil +} + +// DeleteDevicePostureRule deletes a device posture rule. +// +// API reference: https://api.cloudflare.com/#device-posture-rules-delete-device-posture-rule +func (api *API) DeleteDevicePostureRule(ctx context.Context, accountID, ruleID string) error { + uri := fmt.Sprintf( + "/%s/%s/devices/posture/%s", + AccountRouteRoot, + accountID, + ruleID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/device_posture_rule_test.go b/pkg/cloudflare-go/device_posture_rule_test.go new file mode 100644 index 000000000..b9b72b2b8 --- /dev/null +++ b/pkg/cloudflare-go/device_posture_rule_test.go @@ -0,0 +1,788 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDevicePostureIntegrations(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "interval": "1h", + "type": "workspace_one", + "name": "My integration name", + "config": { + "auth_url": "https://auth_url.example.com", + "api_url": "https://api_url.example.com", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "customer_id": "test_customer_id", + "client_key": "test_client_key" + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + want := []DevicePostureIntegration{{ + IntegrationID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My integration name", + Type: "workspace_one", + Interval: "1h", + Config: DevicePostureIntegrationConfig{ + AuthUrl: "https://auth_url.example.com", + ApiUrl: "https://api_url.example.com", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + CustomerID: "test_customer_id", + ClientKey: "test_client_key", + }, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/integration", handler) + + actual, _, err := client.DevicePostureIntegrations(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureIntegration(t *testing.T) { + setup() + defer teardown() + + id := "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "interval": "1h", + "type": "workspace_one", + "name": "My integration name", + "config": { + "auth_url": "https://auth_url.example.com", + "api_url": "https://api_url.example.com", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "customer_id": "test_customer_id", + "client_key": "test_client_key" + } + } + }`, id) + } + + want := DevicePostureIntegration{ + IntegrationID: id, + Name: "My integration name", + Type: "workspace_one", + Interval: "1h", + Config: DevicePostureIntegrationConfig{ + AuthUrl: "https://auth_url.example.com", + ApiUrl: "https://api_url.example.com", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + CustomerID: "test_customer_id", + ClientKey: "test_client_key", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/integration/"+id, handler) + + actual, err := client.DevicePostureIntegration(context.Background(), testAccountID, id) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureIntegrationUpdate(t *testing.T) { + setup() + defer teardown() + + id := "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'GET', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "interval": "1h", + "type": "workspace_one", + "name": "My integration name", + "config": { + "auth_url": "https://auth_url.example.com", + "api_url": "https://api_url.example.com", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "customer_id": "test_customer_id", + "client_key": "test_client_key" + } + } + }`, id) + } + + want := DevicePostureIntegration{ + IntegrationID: id, + Name: "My integration name", + Type: "workspace_one", + Interval: "1h", + Config: DevicePostureIntegrationConfig{ + AuthUrl: "https://auth_url.example.com", + ApiUrl: "https://api_url.example.com", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + CustomerID: "test_customer_id", + ClientKey: "test_client_key", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/integration/"+id, handler) + + actual, err := client.UpdateDevicePostureIntegration(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureIntegrationCreate(t *testing.T) { + setup() + defer teardown() + + id := "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "interval": "1h", + "type": "workspace_one", + "name": "My integration name", + "config": { + "auth_url": "https://auth_url.example.com", + "api_url": "https://api_url.example.com", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "customer_id": "test_customer_id", + "client_key": "test_client_key" + } + } + }`, id) + } + + want := DevicePostureIntegration{ + IntegrationID: id, + Name: "My integration name", + Type: "workspace_one", + Interval: "1h", + Config: DevicePostureIntegrationConfig{ + AuthUrl: "https://auth_url.example.com", + ApiUrl: "https://api_url.example.com", + ClientID: "test_client_id", + ClientSecret: "test_client_secret", + CustomerID: "test_customer_id", + ClientKey: "test_client_key", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/integration", handler) + + actual, err := client.CreateDevicePostureIntegration(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureIntegrationTaniumCreate(t *testing.T) { + setup() + defer teardown() + + id := "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "interval": "1h", + "type": "tanium_s2s", + "name": "My Tanium integration", + "config": { + "api_url": "https://api_url.example.com", + "client_secret": "test_client_secret", + "access_client_id": "test_access_client_id", + "access_client_secret": "test_access_client_secret" + } + } + }`, id) + } + + want := DevicePostureIntegration{ + IntegrationID: id, + Name: "My Tanium integration", + Type: "tanium_s2s", + Interval: "1h", + Config: DevicePostureIntegrationConfig{ + ApiUrl: "https://api_url.example.com", + ClientSecret: "test_client_secret", + AccessClientID: "test_access_client_id", + AccessClientSecret: "test_access_client_secret", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/integration", handler) + + actual, err := client.CreateDevicePostureIntegration(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureIntegrationDelete(t *testing.T) { + setup() + defer teardown() + + id := "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/integration/"+id, handler) + + err := client.DeleteDevicePostureIntegration(context.Background(), testAccountID, id) + assert.NoError(t, err) +} + +func TestDevicePostureRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "type": "file", + "name": "My rule name", + "description": "My description", + "expiration": "1h", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "id": "9e597887-345e-4a32-a09c-68811b129768", + "path": "/tmp/data.zta", + "exists": true, + "thumbprint": "asdfasdfasdfasdf", + "sha256": "D75398FC796D659DEB4170569DCFEC63E3897C71E3AE8642FD3139A554AEE21E", + "running": true + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + want := []DevicePostureRule{{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "file", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ + ID: "9e597887-345e-4a32-a09c-68811b129768", + Path: "/tmp/data.zta", + Exists: true, + Thumbprint: "asdfasdfasdfasdf", + Sha256: "D75398FC796D659DEB4170569DCFEC63E3897C71E3AE8642FD3139A554AEE21E", + Running: true, + }, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture", handler) + + actual, _, err := client.DevicePostureRules(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureFileRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "file", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "path": "/tmp/test", + "exists": true, + "sha256": "42b4daec3962691f5893a966245e5ea30f9f8df7254e7b7af43a171e3e29c857" + } + } + } + `) + } + + want := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "file", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ + Path: "/tmp/test", + Exists: true, + Sha256: "42b4daec3962691f5893a966245e5ea30f9f8df7254e7b7af43a171e3e29c857", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.DevicePostureRule(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureDiskEncryptionRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "disk_encryption", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "requireAll": true, + "checkDisks": ["C", "D"] + } + } + } + `) + } + + want := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "disk_encryption", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ + RequireAll: true, + CheckDisks: []string{"C", "D"}, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.DevicePostureRule(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureOsVersionRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "os_version", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "version": "10.0.1", + "operator": ">=" + } + } + } + `) + } + + want := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "os_version", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ + Version: "10.0.1", + Operator: ">=", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.DevicePostureRule(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureDomainJoinedRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "domain_joined", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "domain": "example.com" + } + } + } + `) + } + + want := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "domain_joined", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ + Domain: "example.com", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.DevicePostureRule(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDevicePostureClientCertificateRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "client_certificate", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "windows" + } + ], + "input": { + "certificate_id": "d2c04b78-3ba2-4294-8efa-4e85aef0777f", + "cn": "example.com" + } + } + } + `) + } + + want := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "client_certificate", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "windows"}}, + Input: DevicePostureRuleInput{ + CertificateID: "d2c04b78-3ba2-4294-8efa-4e85aef0777f", + CommonName: "example.com", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.DevicePostureRule(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateDevicePostureRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "file", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "id": "9e597887-345e-4a32-a09c-68811b129768" + } + } + } + `) + } + + rule := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "file", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ID: "9e597887-345e-4a32-a09c-68811b129768"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture", handler) + + actual, err := client.CreateDevicePostureRule(context.Background(), testAccountID, DevicePostureRule{ + Name: "My rule name", + Description: "My description", + Type: "file", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ID: "9e597887-345e-4a32-a09c-68811b129768"}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, rule, actual) + } +} + +func TestUpdateDevicePostureRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "schedule": "1h", + "expiration": "1h", + "type": "file", + "name": "My rule name", + "description": "My description", + "match": [ + { + "platform": "ios" + } + ], + "input": { + "id": "9e597887-345e-4a32-a09c-68811b129768" + } + } + } + `) + } + + rule := DevicePostureRule{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My rule name", + Description: "My description", + Type: "file", + Schedule: "1h", + Expiration: "1h", + Match: []DevicePostureRuleMatch{{Platform: "ios"}}, + Input: DevicePostureRuleInput{ID: "9e597887-345e-4a32-a09c-68811b129768"}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.UpdateDevicePostureRule(context.Background(), testAccountID, rule) + + if assert.NoError(t, err) { + assert.Equal(t, rule, actual) + } +} + +func TestUpdateDevicePostureRuleWithMissingID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateDevicePostureRule(context.Background(), testZoneID, DevicePostureRule{}) + assert.EqualError(t, err, "device posture rule ID cannot be empty") +} + +func TestDeleteDevicePostureRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/posture/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + err := client.DeleteDevicePostureRule(context.Background(), testAccountID, "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/devices_dex.go b/pkg/cloudflare-go/devices_dex.go new file mode 100644 index 000000000..4c9ef5070 --- /dev/null +++ b/pkg/cloudflare-go/devices_dex.go @@ -0,0 +1,174 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type DeviceDexTestData map[string]interface{} + +type DeviceDexTest struct { + TestID string `json:"test_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Interval string `json:"interval"` + Enabled bool `json:"enabled"` + Updated time.Time `json:"updated"` + Created time.Time `json:"created"` + Data *DeviceDexTestData `json:"data"` +} + +type DeviceDexTests struct { + DexTests []DeviceDexTest `json:"dex_tests"` +} + +type DeviceDexTestResponse struct { + Response + Result DeviceDexTest `json:"result"` +} + +type DeviceDexTestListResponse struct { + Response + Result DeviceDexTests `json:"result"` +} + +type ListDeviceDexTestParams struct{} + +type CreateDeviceDexTestParams struct { + TestID string `json:"test_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Interval string `json:"interval"` + Enabled bool `json:"enabled"` + Data *DeviceDexTestData `json:"data"` +} + +type UpdateDeviceDexTestParams struct { + TestID string `json:"test_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Interval string `json:"interval"` + Enabled bool `json:"enabled"` + Data *DeviceDexTestData `json:"data"` +} + +// ListDexTests returns all Device Dex Tests for a given account. +// +// API reference : https://developers.cloudflare.com/api/operations/device-dex-test-details +func (api *API) ListDexTests(ctx context.Context, rc *ResourceContainer, params ListDeviceDexTestParams) (DeviceDexTests, error) { + if rc.Level != AccountRouteLevel { + return DeviceDexTests{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/dex_tests", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DeviceDexTests{}, err + } + + var response DeviceDexTestListResponse + err = json.Unmarshal(res, &response) + if err != nil { + return DeviceDexTests{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// CreateDeviceDexTest created a new Device Dex Test +// +// API reference: https://developers.cloudflare.com/api/operations/device-dex-test-create-device-dex-test +func (api *API) CreateDeviceDexTest(ctx context.Context, rc *ResourceContainer, params CreateDeviceDexTestParams) (DeviceDexTest, error) { + if rc.Level != AccountRouteLevel { + return DeviceDexTest{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/dex_tests", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return DeviceDexTest{}, err + } + + var deviceDexTestResponse DeviceDexTestResponse + if err := json.Unmarshal(res, &deviceDexTestResponse); err != nil { + return DeviceDexTest{}, fmt.Errorf("%s: %w\n\nres: %s", errUnmarshalError, err, string(res)) + } + + return deviceDexTestResponse.Result, err +} + +// UpdateDeviceDexTest Updates a Device Dex Test. +// +// API reference: https://developers.cloudflare.com/api/operations/device-dex-test-update-device-dex-test +func (api *API) UpdateDeviceDexTest(ctx context.Context, rc *ResourceContainer, params UpdateDeviceDexTestParams) (DeviceDexTest, error) { + if rc.Level != AccountRouteLevel { + return DeviceDexTest{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/dex_tests/%s", rc.Level, rc.Identifier, params.TestID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return DeviceDexTest{}, err + } + + var deviceDexTestsResponse DeviceDexTestResponse + + if err := json.Unmarshal(res, &deviceDexTestsResponse); err != nil { + return DeviceDexTest{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return deviceDexTestsResponse.Result, err +} + +// GetDeviceDexTest gets a single Device Dex Test. +// +// API reference: https://developers.cloudflare.com/api/operations/device-dex-test-get-device-dex-test +func (api *API) GetDeviceDexTest(ctx context.Context, rc *ResourceContainer, testID string) (DeviceDexTest, error) { + if rc.Level != AccountRouteLevel { + return DeviceDexTest{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/dex_tests/%s", rc.Level, rc.Identifier, testID) + + deviceDexTestResponse := DeviceDexTestResponse{} + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DeviceDexTest{}, err + } + + if err := json.Unmarshal(res, &deviceDexTestResponse); err != nil { + return DeviceDexTest{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return deviceDexTestResponse.Result, err +} + +// DeleteDexTest deletes a Device Dex Test. +// +// API reference: https://developers.cloudflare.com/api/operations/device-dex-test-delete-device-dex-test +func (api *API) DeleteDexTest(ctx context.Context, rc *ResourceContainer, testID string) (DeviceDexTests, error) { + if rc.Level != AccountRouteLevel { + return DeviceDexTests{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/dex_tests/%s", rc.Level, rc.Identifier, testID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return DeviceDexTests{}, err + } + + var response DeviceDexTestListResponse + if err := json.Unmarshal(res, &response); err != nil { + return DeviceDexTests{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, err +} diff --git a/pkg/cloudflare-go/devices_dex_test.go b/pkg/cloudflare-go/devices_dex_test.go new file mode 100644 index 000000000..f5901f791 --- /dev/null +++ b/pkg/cloudflare-go/devices_dex_test.go @@ -0,0 +1,283 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testID = "f174e90a-fafe-4643-bbbc-4a0ed4fc8415" + +var dexTimestamp, _ = time.Parse(time.RFC3339, "2023-01-30T19:59:44.401278Z") + +func TestGetDeviceDexTests(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "dex_tests": [ + { + "test_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "http test dash", + "description": "dex test description", + "interval": "0h30m0s", + "enabled": true, + "data": { + "host": "https://dash.cloudflare.com", + "kind": "http", + "method": "GET" + }, + "updated": "2023-01-30T19:59:44.401278Z", + "created": "2023-01-30T19:59:44.401278Z" + } + ] + } + }`) + } + + dexTest := []DeviceDexTest{{ + TestID: testID, + Name: "http test dash", + Description: "dex test description", + Interval: "0h30m0s", + Enabled: true, + Data: &DeviceDexTestData{ + "kind": "http", + "method": "GET", + "host": "https://dash.cloudflare.com", + }, + Updated: dexTimestamp, + Created: dexTimestamp, + }} + + want := DeviceDexTests{ + DexTests: dexTest, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/dex_tests", handler) + + actual, err := client.ListDexTests(context.Background(), AccountIdentifier(testAccountID), ListDeviceDexTestParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeviceDexTest(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "test_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "http test dash", + "description": "dex test description", + "interval": "0h30m0s", + "enabled": true, + "data": { + "host": "https://dash.cloudflare.com", + "kind": "http", + "method": "GET" + }, + "updated": "2023-01-30T19:59:44.401278Z", + "created": "2023-01-30T19:59:44.401278Z" + } + }`) + } + + want := DeviceDexTest{ + TestID: testID, + Name: "http test dash", + Description: "dex test description", + Interval: "0h30m0s", + Enabled: true, + Data: &DeviceDexTestData{ + "kind": "http", + "method": "GET", + "host": "https://dash.cloudflare.com", + }, + Updated: dexTimestamp, + Created: dexTimestamp, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/dex_tests/"+testID, handler) + + actual, err := client.GetDeviceDexTest(context.Background(), AccountIdentifier(testAccountID), testID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateDeviceDexTest(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "test_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "http test dash", + "description": "dex test description", + "interval": "0h30m0s", + "enabled": true, + "data": { + "host": "https://dash.cloudflare.com", + "kind": "http", + "method": "GET" + }, + "updated": "2023-01-30T19:59:44.401278Z", + "created": "2023-01-30T19:59:44.401278Z" + } + }`) + } + + want := DeviceDexTest{ + TestID: testID, + Name: "http test dash", + Description: "dex test description", + Interval: "0h30m0s", + Enabled: true, + Data: &DeviceDexTestData{ + "kind": "http", + "method": "GET", + "host": "https://dash.cloudflare.com", + }, + Updated: dexTimestamp, + Created: dexTimestamp, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/dex_tests", handler) + + actual, err := client.CreateDeviceDexTest(context.Background(), AccountIdentifier(testAccountID), CreateDeviceDexTestParams{ + TestID: testID, + Name: "http test dash", + Description: "dex test description", + Interval: "0h30m0s", + Enabled: true, + Data: &DeviceDexTestData{ + "kind": "http", + "method": "GET", + "host": "https://dash.cloudflare.com", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateDeviceDexTest(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "test_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "http test dash", + "description": "dex test description", + "interval": "0h30m0s", + "enabled": true, + "data": { + "host": "https://dash.cloudflare.com", + "kind": "http", + "method": "GET" + }, + "updated": "2023-01-30T19:59:44.401278Z", + "created": "2023-01-30T19:59:44.401278Z" + } + }`) + } + + want := DeviceDexTest{ + TestID: testID, + Name: "http test dash", + Description: "dex test description", + Interval: "0h30m0s", + Enabled: true, + Data: &DeviceDexTestData{ + "kind": "http", + "method": "GET", + "host": "https://dash.cloudflare.com", + }, + Updated: dexTimestamp, + Created: dexTimestamp, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/dex_tests/"+testID, handler) + + actual, err := client.UpdateDeviceDexTest(context.Background(), AccountIdentifier(testAccountID), UpdateDeviceDexTestParams{ + TestID: testID, + Name: "http test dash", + Description: "dex test description", + Interval: "0h30m0s", + Enabled: true, + Data: &DeviceDexTestData{ + "kind": "http", + "method": "GET", + "host": "https://dash.cloudflare.com", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteDeviceDexTest(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "dex_tests": [] + } + }`) + } + + want := DeviceDexTests{ + DexTests: []DeviceDexTest{}, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/dex_tests/"+testID, handler) + + actual, err := client.DeleteDexTest(context.Background(), AccountIdentifier(testAccountID), testID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/devices_managed_networks.go b/pkg/cloudflare-go/devices_managed_networks.go new file mode 100644 index 000000000..11fc7ea10 --- /dev/null +++ b/pkg/cloudflare-go/devices_managed_networks.go @@ -0,0 +1,165 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type Config struct { + TlsSockAddr string `json:"tls_sockaddr,omitempty"` + Sha256 string `json:"sha256,omitempty"` +} + +type DeviceManagedNetwork struct { + NetworkID string `json:"network_id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Config *Config `json:"config"` +} + +type DeviceManagedNetworkResponse struct { + Response + Result DeviceManagedNetwork `json:"result"` +} + +type DeviceManagedNetworkListResponse struct { + Response + Result []DeviceManagedNetwork `json:"result"` +} + +type ListDeviceManagedNetworksParams struct{} + +type CreateDeviceManagedNetworkParams struct { + NetworkID string `json:"network_id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Config *Config `json:"config"` +} + +type UpdateDeviceManagedNetworkParams struct { + NetworkID string `json:"network_id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Config *Config `json:"config"` +} + +// ListDeviceManagedNetwork returns all Device Managed Networks for a given +// account. +// +// API reference : https://api.cloudflare.com/#device-managed-networks-list-device-managed-networks +func (api *API) ListDeviceManagedNetworks(ctx context.Context, rc *ResourceContainer, params ListDeviceManagedNetworksParams) ([]DeviceManagedNetwork, error) { + if rc.Level != AccountRouteLevel { + return []DeviceManagedNetwork{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/networks", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DeviceManagedNetwork{}, err + } + + var response DeviceManagedNetworkListResponse + err = json.Unmarshal(res, &response) + if err != nil { + return []DeviceManagedNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// CreateDeviceManagedNetwork creates a new Device Managed Network. +// +// API reference: https://api.cloudflare.com/#device-managed-networks-create-device-managed-network +func (api *API) CreateDeviceManagedNetwork(ctx context.Context, rc *ResourceContainer, params CreateDeviceManagedNetworkParams) (DeviceManagedNetwork, error) { + if rc.Level != AccountRouteLevel { + return DeviceManagedNetwork{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/networks", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return DeviceManagedNetwork{}, err + } + + var deviceManagedNetworksResponse DeviceManagedNetworkResponse + if err := json.Unmarshal(res, &deviceManagedNetworksResponse); err != nil { + return DeviceManagedNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return deviceManagedNetworksResponse.Result, err +} + +// UpdateDeviceManagedNetwork Update a Device Managed Network. +// +// API reference: https://api.cloudflare.com/#device-managed-networks-update-device-managed-network +func (api *API) UpdateDeviceManagedNetwork(ctx context.Context, rc *ResourceContainer, params UpdateDeviceManagedNetworkParams) (DeviceManagedNetwork, error) { + if rc.Level != AccountRouteLevel { + return DeviceManagedNetwork{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/networks/%s", rc.Level, rc.Identifier, params.NetworkID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return DeviceManagedNetwork{}, err + } + + var deviceManagedNetworksResponse DeviceManagedNetworkResponse + + if err := json.Unmarshal(res, &deviceManagedNetworksResponse); err != nil { + return DeviceManagedNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return deviceManagedNetworksResponse.Result, err +} + +// GetDeviceManagedNetwork gets a single Device Managed Network. +// +// API reference: https://api.cloudflare.com/#device-managed-networks-device-managed-network-details +func (api *API) GetDeviceManagedNetwork(ctx context.Context, rc *ResourceContainer, networkID string) (DeviceManagedNetwork, error) { + if rc.Level != AccountRouteLevel { + return DeviceManagedNetwork{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/networks/%s", rc.Level, rc.Identifier, networkID) + + deviceManagedNetworksResponse := DeviceManagedNetworkResponse{} + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DeviceManagedNetwork{}, err + } + + if err := json.Unmarshal(res, &deviceManagedNetworksResponse); err != nil { + return DeviceManagedNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return deviceManagedNetworksResponse.Result, err +} + +// DeleteManagedNetworks deletes a Device Managed Network. +// +// API reference: https://api.cloudflare.com/#device-managed-networks-delete-device-managed-network +func (api *API) DeleteManagedNetworks(ctx context.Context, rc *ResourceContainer, networkID string) ([]DeviceManagedNetwork, error) { + if rc.Level != AccountRouteLevel { + return []DeviceManagedNetwork{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/devices/networks/%s", rc.Level, rc.Identifier, networkID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return []DeviceManagedNetwork{}, err + } + + var response DeviceManagedNetworkListResponse + if err := json.Unmarshal(res, &response); err != nil { + return []DeviceManagedNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, err +} diff --git a/pkg/cloudflare-go/devices_managed_networks_test.go b/pkg/cloudflare-go/devices_managed_networks_test.go new file mode 100644 index 000000000..c62c7c679 --- /dev/null +++ b/pkg/cloudflare-go/devices_managed_networks_test.go @@ -0,0 +1,245 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testNetworkID = "f174e90a-fafe-4643-bbbc-4a0ed4fc8415" + +func TestGetDeviceManagedNetworks(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "network_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "type": "tls", + "name": "managed-network-1", + "config": { + "tls_sockaddr": "foobar:1234", + "sha256": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c" + } + } + ] + }`) + } + + want := []DeviceManagedNetwork{{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/networks", handler) + + actual, err := client.ListDeviceManagedNetworks(context.Background(), AccountIdentifier(testAccountID), ListDeviceManagedNetworksParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeviceManagedNetwork(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": + { + "network_id": "%s", + "type": "tls", + "name": "managed-network-1", + "config": { + "tls_sockaddr": "foobar:1234", + "sha256": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c" + } + } + }`, testNetworkID) + } + + want := DeviceManagedNetwork{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/networks/"+testNetworkID, handler) + + actual, err := client.GetDeviceManagedNetwork(context.Background(), AccountIdentifier(testAccountID), testNetworkID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateDeviceManagedNetwork(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": + { + "network_id": "%s", + "type": "tls", + "name": "managed-network-1", + "config": { + "tls_sockaddr": "foobar:1234", + "sha256": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c" + } + } + }`, testNetworkID) + } + + want := DeviceManagedNetwork{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/networks", handler) + + actual, err := client.CreateDeviceManagedNetwork(context.Background(), AccountIdentifier(testAccountID), CreateDeviceManagedNetworkParams{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateDeviceManagedNetwork(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": + { + "network_id": "%s", + "type": "tls", + "name": "managed-network-1", + "config": { + "tls_sockaddr": "foobar:1234", + "sha256": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c" + } + } + }`, testNetworkID) + } + + want := DeviceManagedNetwork{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/networks/"+testNetworkID, handler) + + actual, err := client.UpdateDeviceManagedNetwork(context.Background(), AccountIdentifier(testAccountID), UpdateDeviceManagedNetworkParams{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteDeviceManagedNetwork(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "network_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "type": "tls", + "name": "managed-network-1", + "config": { + "tls_sockaddr": "foobar:1234", + "sha256": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c" + } + } + ] + }`) + } + + want := []DeviceManagedNetwork{{ + NetworkID: testNetworkID, + Type: "tls", + Name: "managed-network-1", + Config: &Config{ + TlsSockAddr: "foobar:1234", + Sha256: "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + }, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/networks/"+testNetworkID, handler) + + actual, err := client.DeleteManagedNetworks(context.Background(), AccountIdentifier(testAccountID), testNetworkID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/devices_policy.go b/pkg/cloudflare-go/devices_policy.go new file mode 100644 index 000000000..1a8b03b9d --- /dev/null +++ b/pkg/cloudflare-go/devices_policy.go @@ -0,0 +1,377 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type Enabled struct { + Enabled bool `json:"enabled"` +} + +// DeviceClientCertificates identifies if the zero trust zone is configured for an account. +type DeviceClientCertificates struct { + Response + Result Enabled +} + +type ServiceMode string + +const ( + oneDotOne ServiceMode = "1dot1" + warp ServiceMode = "warp" + proxy ServiceMode = "proxy" + postureOnly ServiceMode = "posture_only" + warpTunnelOnly ServiceMode = "warp_tunnel_only" + + listDeviceSettingsPoliciesDefaultPageSize = 20 +) + +type ServiceModeV2 struct { + Mode ServiceMode `json:"mode,omitempty"` + Port int `json:"port,omitempty"` +} + +type DeviceSettingsPolicy struct { + ServiceModeV2 *ServiceModeV2 `json:"service_mode_v2"` + DisableAutoFallback *bool `json:"disable_auto_fallback"` + FallbackDomains *[]FallbackDomain `json:"fallback_domains"` + Include *[]SplitTunnel `json:"include"` + Exclude *[]SplitTunnel `json:"exclude"` + GatewayUniqueID *string `json:"gateway_unique_id"` + SupportURL *string `json:"support_url"` + CaptivePortal *int `json:"captive_portal"` + AllowModeSwitch *bool `json:"allow_mode_switch"` + SwitchLocked *bool `json:"switch_locked"` + AllowUpdates *bool `json:"allow_updates"` + AutoConnect *int `json:"auto_connect"` + AllowedToLeave *bool `json:"allowed_to_leave"` + PolicyID *string `json:"policy_id"` + Enabled *bool `json:"enabled"` + Name *string `json:"name"` + Match *string `json:"match"` + Precedence *int `json:"precedence"` + Default bool `json:"default"` + ExcludeOfficeIps *bool `json:"exclude_office_ips"` + Description *string `json:"description"` + LANAllowMinutes *uint `json:"lan_allow_minutes"` + LANAllowSubnetSize *uint `json:"lan_allow_subnet_size"` +} + +type DeviceSettingsPolicyResponse struct { + Response + Result DeviceSettingsPolicy +} + +type DeleteDeviceSettingsPolicyResponse struct { + Response + Result []DeviceSettingsPolicy +} + +type CreateDeviceSettingsPolicyParams struct { + DisableAutoFallback *bool `json:"disable_auto_fallback,omitempty"` + CaptivePortal *int `json:"captive_portal,omitempty"` + AllowModeSwitch *bool `json:"allow_mode_switch,omitempty"` + SwitchLocked *bool `json:"switch_locked,omitempty"` + AllowUpdates *bool `json:"allow_updates,omitempty"` + AutoConnect *int `json:"auto_connect,omitempty"` + AllowedToLeave *bool `json:"allowed_to_leave,omitempty"` + SupportURL *string `json:"support_url,omitempty"` + ServiceModeV2 *ServiceModeV2 `json:"service_mode_v2,omitempty"` + Precedence *int `json:"precedence,omitempty"` + Name *string `json:"name,omitempty"` + Match *string `json:"match,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ExcludeOfficeIps *bool `json:"exclude_office_ips"` + Description *string `json:"description,omitempty"` + LANAllowMinutes *uint `json:"lan_allow_minutes,omitempty"` + LANAllowSubnetSize *uint `json:"lan_allow_subnet_size,omitempty"` +} + +type UpdateDefaultDeviceSettingsPolicyParams struct { + DisableAutoFallback *bool `json:"disable_auto_fallback,omitempty"` + CaptivePortal *int `json:"captive_portal,omitempty"` + AllowModeSwitch *bool `json:"allow_mode_switch,omitempty"` + SwitchLocked *bool `json:"switch_locked,omitempty"` + AllowUpdates *bool `json:"allow_updates,omitempty"` + AutoConnect *int `json:"auto_connect,omitempty"` + AllowedToLeave *bool `json:"allowed_to_leave,omitempty"` + SupportURL *string `json:"support_url,omitempty"` + ServiceModeV2 *ServiceModeV2 `json:"service_mode_v2,omitempty"` + Precedence *int `json:"precedence,omitempty"` + Name *string `json:"name,omitempty"` + Match *string `json:"match,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ExcludeOfficeIps *bool `json:"exclude_office_ips"` + Description *string `json:"description,omitempty"` + LANAllowMinutes *uint `json:"lan_allow_minutes,omitempty"` + LANAllowSubnetSize *uint `json:"lan_allow_subnet_size,omitempty"` +} + +type UpdateDeviceSettingsPolicyParams struct { + PolicyID *string `json:"-"` + DisableAutoFallback *bool `json:"disable_auto_fallback,omitempty"` + CaptivePortal *int `json:"captive_portal,omitempty"` + AllowModeSwitch *bool `json:"allow_mode_switch,omitempty"` + SwitchLocked *bool `json:"switch_locked,omitempty"` + AllowUpdates *bool `json:"allow_updates,omitempty"` + AutoConnect *int `json:"auto_connect,omitempty"` + AllowedToLeave *bool `json:"allowed_to_leave,omitempty"` + SupportURL *string `json:"support_url,omitempty"` + ServiceModeV2 *ServiceModeV2 `json:"service_mode_v2,omitempty"` + Precedence *int `json:"precedence,omitempty"` + Name *string `json:"name,omitempty"` + Match *string `json:"match,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ExcludeOfficeIps *bool `json:"exclude_office_ips"` + Description *string `json:"description,omitempty"` + LANAllowMinutes *uint `json:"lan_allow_minutes,omitempty"` + LANAllowSubnetSize *uint `json:"lan_allow_subnet_size,omitempty"` +} + +type ListDeviceSettingsPoliciesResponse struct { + Response + ResultInfo ResultInfo `json:"result_info"` + Result []DeviceSettingsPolicy `json:"result"` +} + +type UpdateDeviceClientCertificatesParams struct { + Enabled *bool `json:"enabled"` +} + +type GetDeviceClientCertificatesParams struct{} + +type GetDefaultDeviceSettingsPolicyParams struct{} + +type GetDeviceSettingsPolicyParams struct { + PolicyID *string `json:"-"` +} + +// UpdateDeviceClientCertificates controls the zero trust zone used to provision client certificates. +// +// API reference: https://api.cloudflare.com/#device-client-certificates +func (api *API) UpdateDeviceClientCertificates(ctx context.Context, rc *ResourceContainer, params UpdateDeviceClientCertificatesParams) (DeviceClientCertificates, error) { + if rc.Level != ZoneRouteLevel { + return DeviceClientCertificates{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy/certificates", rc.Level, rc.Identifier) + + result := DeviceClientCertificates{Result: Enabled{false}} + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return result, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// GetDeviceClientCertificates controls the zero trust zone used to provision +// client certificates. +// +// API reference: https://api.cloudflare.com/#device-client-certificates +func (api *API) GetDeviceClientCertificates(ctx context.Context, rc *ResourceContainer, params GetDeviceClientCertificatesParams) (DeviceClientCertificates, error) { + if rc.Level != ZoneRouteLevel { + return DeviceClientCertificates{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy/certificates", rc.Level, rc.Identifier) + + result := DeviceClientCertificates{} + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return result, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// CreateDeviceSettingsPolicy creates a settings policy against devices that +// match the policy. +// +// API reference: https://api.cloudflare.com/#devices-create-device-settings-policy +func (api *API) CreateDeviceSettingsPolicy(ctx context.Context, rc *ResourceContainer, params CreateDeviceSettingsPolicyParams) (DeviceSettingsPolicy, error) { + if rc.Level != AccountRouteLevel { + return DeviceSettingsPolicy{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy", rc.Level, rc.Identifier) + + result := DeviceSettingsPolicyResponse{} + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return DeviceSettingsPolicy{}, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return DeviceSettingsPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, err +} + +// UpdateDefaultDeviceSettingsPolicy updates the default settings policy for an account +// +// API reference: https://api.cloudflare.com/#devices-update-default-device-settings-policy +func (api *API) UpdateDefaultDeviceSettingsPolicy(ctx context.Context, rc *ResourceContainer, params UpdateDefaultDeviceSettingsPolicyParams) (DeviceSettingsPolicy, error) { + if rc.Level != AccountRouteLevel { + return DeviceSettingsPolicy{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + result := DeviceSettingsPolicyResponse{} + uri := fmt.Sprintf("/%s/%s/devices/policy", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return DeviceSettingsPolicy{}, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return DeviceSettingsPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, err +} + +// UpdateDeviceSettingsPolicy updates a settings policy +// +// API reference: https://api.cloudflare.com/#devices-update-device-settings-policy +func (api *API) UpdateDeviceSettingsPolicy(ctx context.Context, rc *ResourceContainer, params UpdateDeviceSettingsPolicyParams) (DeviceSettingsPolicy, error) { + if rc.Level != AccountRouteLevel { + return DeviceSettingsPolicy{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy/%s", rc.Level, rc.Identifier, *params.PolicyID) + + result := DeviceSettingsPolicyResponse{} + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return DeviceSettingsPolicy{}, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return DeviceSettingsPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, err +} + +// DeleteDeviceSettingsPolicy deletes a settings policy and returns a list +// of all of the other policies in the account. +// +// API reference: https://api.cloudflare.com/#devices-delete-device-settings-policy +func (api *API) DeleteDeviceSettingsPolicy(ctx context.Context, rc *ResourceContainer, policyID string) ([]DeviceSettingsPolicy, error) { + if rc.Level != AccountRouteLevel { + return []DeviceSettingsPolicy{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy/%s", rc.Level, rc.Identifier, policyID) + + result := DeleteDeviceSettingsPolicyResponse{} + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return []DeviceSettingsPolicy{}, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return []DeviceSettingsPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, err +} + +// GetDefaultDeviceSettings gets the default device settings policy. +// +// API reference: https://api.cloudflare.com/#devices-get-default-device-settings-policy +func (api *API) GetDefaultDeviceSettingsPolicy(ctx context.Context, rc *ResourceContainer, params GetDefaultDeviceSettingsPolicyParams) (DeviceSettingsPolicy, error) { + if rc.Level != AccountRouteLevel { + return DeviceSettingsPolicy{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy", rc.Level, rc.Identifier) + + result := DeviceSettingsPolicyResponse{} + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DeviceSettingsPolicy{}, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return DeviceSettingsPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, err +} + +// GetDefaultDeviceSettings gets the device settings policy by its policyID. +// +// API reference: https://api.cloudflare.com/#devices-get-device-settings-policy-by-id +func (api *API) GetDeviceSettingsPolicy(ctx context.Context, rc *ResourceContainer, params GetDeviceSettingsPolicyParams) (DeviceSettingsPolicy, error) { + if rc.Level != AccountRouteLevel { + return DeviceSettingsPolicy{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/devices/policy/%s", rc.Level, rc.Identifier, *params.PolicyID) + + result := DeviceSettingsPolicyResponse{} + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DeviceSettingsPolicy{}, err + } + + if err := json.Unmarshal(res, &result); err != nil { + return DeviceSettingsPolicy{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, err +} + +type ListDeviceSettingsPoliciesParams struct { + ResultInfo +} + +// ListDeviceSettingsPolicies returns all device settings policies for an account +// +// API reference: https://api.cloudflare.com/#devices-list-device-settings-policies +func (api *API) ListDeviceSettingsPolicies(ctx context.Context, rc *ResourceContainer, params ListDeviceSettingsPoliciesParams) ([]DeviceSettingsPolicy, *ResultInfo, error) { + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = listDeviceSettingsPoliciesDefaultPageSize + } + + var policies []DeviceSettingsPolicy + var lastResultInfo ResultInfo + for { + uri := buildURI(fmt.Sprintf("/%s/%s/devices/policies", rc.Level, rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, nil, err + } + var r ListDeviceSettingsPoliciesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + policies = append(policies, r.Result...) + lastResultInfo = r.ResultInfo + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + return policies, &lastResultInfo, nil +} diff --git a/pkg/cloudflare-go/devices_policy_test.go b/pkg/cloudflare-go/devices_policy_test.go new file mode 100644 index 000000000..c78a13838 --- /dev/null +++ b/pkg/cloudflare-go/devices_policy_test.go @@ -0,0 +1,423 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + deviceSettingsPolicyID = "a842fa8a-a583-482e-9cd9-eb43362949fd" + deviceSettingsPolicyMatch = "identity.email == \"test@example.com\"" + deviceSettingsPolicyPrecedence = 10 + + defaultDeviceSettingsPolicy = DeviceSettingsPolicy{ + ServiceModeV2: &ServiceModeV2{ + Mode: "warp", + }, + DisableAutoFallback: BoolPtr(false), + FallbackDomains: &[]FallbackDomain{ + {Suffix: "invalid"}, + {Suffix: "test"}, + }, + Exclude: &[]SplitTunnel{ + {Address: "10.0.0.0/8"}, + {Address: "100.64.0.0/10"}, + }, + GatewayUniqueID: StringPtr("t1235"), + SupportURL: StringPtr(""), + CaptivePortal: IntPtr(180), + AllowModeSwitch: BoolPtr(false), + SwitchLocked: BoolPtr(false), + AllowUpdates: BoolPtr(false), + AutoConnect: IntPtr(0), + AllowedToLeave: BoolPtr(true), + Enabled: BoolPtr(true), + PolicyID: nil, + Name: nil, + Match: nil, + Precedence: nil, + Default: true, + ExcludeOfficeIps: BoolPtr(false), + Description: nil, + LANAllowMinutes: nil, + LANAllowSubnetSize: nil, + } + + nonDefaultDeviceSettingsPolicy = DeviceSettingsPolicy{ + ServiceModeV2: &ServiceModeV2{ + Mode: "warp", + }, + DisableAutoFallback: BoolPtr(false), + FallbackDomains: &[]FallbackDomain{ + {Suffix: "invalid"}, + {Suffix: "test"}, + }, + Exclude: &[]SplitTunnel{ + {Address: "10.0.0.0/8"}, + {Address: "100.64.0.0/10"}, + }, + GatewayUniqueID: StringPtr("t1235"), + SupportURL: StringPtr(""), + CaptivePortal: IntPtr(180), + AllowModeSwitch: BoolPtr(false), + SwitchLocked: BoolPtr(false), + AllowUpdates: BoolPtr(false), + AutoConnect: IntPtr(0), + AllowedToLeave: BoolPtr(true), + PolicyID: &deviceSettingsPolicyID, + Enabled: BoolPtr(true), + Name: StringPtr("test"), + Match: &deviceSettingsPolicyMatch, + Precedence: &deviceSettingsPolicyPrecedence, + Default: false, + ExcludeOfficeIps: BoolPtr(true), + Description: StringPtr("Test Description"), + LANAllowMinutes: UintPtr(120), + LANAllowSubnetSize: UintPtr(31), + } + + defaultDeviceSettingsPolicyJson = `{ + "service_mode_v2": { + "mode": "warp" + }, + "disable_auto_fallback": false, + "fallback_domains": [ + { + "suffix": "invalid" + }, + { + "suffix": "test" + } + ], + "exclude": [ + { + "address": "10.0.0.0/8" + }, + { + "address": "100.64.0.0/10" + } + ], + "gateway_unique_id": "t1235", + "support_url": "", + "captive_portal": 180, + "allow_mode_switch": false, + "switch_locked": false, + "allow_updates": false, + "auto_connect": 0, + "allowed_to_leave": true, + "enabled": true, + "default": true, + "exclude_office_ips":false + }` + + nonDefaultDeviceSettingsPolicyJson = fmt.Sprintf(`{ + "service_mode_v2": { + "mode": "warp" + }, + "disable_auto_fallback": false, + "fallback_domains": [ + { + "suffix": "invalid" + }, + { + "suffix": "test" + } + ], + "exclude": [ + { + "address": "10.0.0.0/8" + }, + { + "address": "100.64.0.0/10" + } + ], + "gateway_unique_id": "t1235", + "support_url": "", + "captive_portal": 180, + "allow_mode_switch": false, + "switch_locked": false, + "allow_updates": false, + "auto_connect": 0, + "allowed_to_leave": true, + "policy_id": "%s", + "enabled": true, + "name": "test", + "match": %#v, + "precedence": 10, + "default": false, + "exclude_office_ips":true, + "description":"Test Description", + "lan_allow_minutes": 120, + "lan_allow_subnet_size": 31 + }`, deviceSettingsPolicyID, deviceSettingsPolicyMatch) +) + +func TestUpdateDeviceClientCertificates(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": {"enabled": true} + }`) + } + + want := DeviceClientCertificates{ + Response: Response{ + Success: true, + Errors: nil, + Messages: nil, + }, + Result: Enabled{true}, + } + + mux.HandleFunc("/zones/"+testZoneID+"/devices/policy/certificates", handler) + + actual, err := client.UpdateDeviceClientCertificates(context.Background(), ZoneIdentifier(testZoneID), UpdateDeviceClientCertificatesParams{Enabled: BoolPtr(true)}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetDeviceClientCertificates(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": {"enabled": false} + }`) + } + + want := DeviceClientCertificates{ + Response: Response{ + Success: true, + Errors: nil, + Messages: nil, + }, + Result: Enabled{false}, + } + + mux.HandleFunc("/zones/"+testZoneID+"/devices/policy/certificates", handler) + + actual, err := client.GetDeviceClientCertificates(context.Background(), ZoneIdentifier(testZoneID), GetDeviceClientCertificatesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": %s + }`, nonDefaultDeviceSettingsPolicyJson) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy", handler) + + actual, err := client.CreateDeviceSettingsPolicy(context.Background(), AccountIdentifier(testAccountID), CreateDeviceSettingsPolicyParams{ + Precedence: IntPtr(10), + Match: &deviceSettingsPolicyMatch, + Name: StringPtr("test"), + Description: StringPtr("Test Description"), + LANAllowMinutes: UintPtr(120), + LANAllowSubnetSize: UintPtr(31), + }) + + if assert.NoError(t, err) { + assert.Equal(t, nonDefaultDeviceSettingsPolicy, actual) + } +} + +func TestUpdateDefaultDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": %s + }`, defaultDeviceSettingsPolicyJson) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy", handler) + + actual, err := client.UpdateDefaultDeviceSettingsPolicy(context.Background(), AccountIdentifier(testAccountID), UpdateDefaultDeviceSettingsPolicyParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, defaultDeviceSettingsPolicy, actual) + } +} + +func TestUpdateDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": %s + }`, nonDefaultDeviceSettingsPolicyJson) + } + + precedence := 10 + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/"+deviceSettingsPolicyID, handler) + + actual, err := client.UpdateDeviceSettingsPolicy(context.Background(), AccountIdentifier(testAccountID), UpdateDeviceSettingsPolicyParams{ + PolicyID: &deviceSettingsPolicyID, + Precedence: &precedence, + }) + + if assert.NoError(t, err) { + assert.Equal(t, nonDefaultDeviceSettingsPolicy, actual) + } +} + +func TestDeleteDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": [ %s ] + }`, defaultDeviceSettingsPolicyJson) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/"+deviceSettingsPolicyID, handler) + + actual, err := client.DeleteDeviceSettingsPolicy(context.Background(), AccountIdentifier(testAccountID), deviceSettingsPolicyID) + + if assert.NoError(t, err) { + assert.Equal(t, []DeviceSettingsPolicy{defaultDeviceSettingsPolicy}, actual) + } +} + +func TestGetDefaultDeviceSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": %s + }`, defaultDeviceSettingsPolicyJson) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy", handler) + + actual, err := client.GetDefaultDeviceSettingsPolicy(context.Background(), AccountIdentifier(testAccountID), GetDefaultDeviceSettingsPolicyParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, defaultDeviceSettingsPolicy, actual) + } +} + +func TestGetDeviceSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": %s + }`, nonDefaultDeviceSettingsPolicyJson) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/"+deviceSettingsPolicyID, handler) + + actual, err := client.GetDeviceSettingsPolicy(context.Background(), AccountIdentifier(testAccountID), GetDeviceSettingsPolicyParams{PolicyID: &deviceSettingsPolicyID}) + + if assert.NoError(t, err) { + assert.Equal(t, nonDefaultDeviceSettingsPolicy, actual) + } +} + +func TestListDeviceSettingsPolicies(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": [%s], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1 + } + }`, nonDefaultDeviceSettingsPolicyJson) + } + + want := []DeviceSettingsPolicy{nonDefaultDeviceSettingsPolicy} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policies", handler) + + actual, resultInfo, err := client.ListDeviceSettingsPolicies(context.Background(), AccountIdentifier(testAccountID), ListDeviceSettingsPoliciesParams{ + ResultInfo: ResultInfo{ + Page: 1, + PerPage: 20, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + assert.Equal(t, &ResultInfo{ + Count: 1, + Page: 1, + PerPage: 20, + Total: 1, + }, resultInfo) + assert.Len(t, actual, 1) + } +} diff --git a/pkg/cloudflare-go/diagnostics.go b/pkg/cloudflare-go/diagnostics.go new file mode 100644 index 000000000..4eb8e7165 --- /dev/null +++ b/pkg/cloudflare-go/diagnostics.go @@ -0,0 +1,102 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// DiagnosticsTracerouteConfiguration is the overarching structure of the +// diagnostics traceroute requests. +type DiagnosticsTracerouteConfiguration struct { + Targets []string `json:"targets"` + Colos []string `json:"colos,omitempty"` + Options DiagnosticsTracerouteConfigurationOptions `json:"options,omitempty"` +} + +// DiagnosticsTracerouteConfigurationOptions contains the options for performing +// traceroutes. +type DiagnosticsTracerouteConfigurationOptions struct { + PacketsPerTTL int `json:"packets_per_ttl"` + PacketType string `json:"packet_type"` + MaxTTL int `json:"max_ttl"` + WaitTime int `json:"wait_time"` +} + +// DiagnosticsTracerouteResponse is the outer response of the API response. +type DiagnosticsTracerouteResponse struct { + Response + Result []DiagnosticsTracerouteResponseResult `json:"result"` +} + +// DiagnosticsTracerouteResponseResult is the inner API response for the +// traceroute request. +type DiagnosticsTracerouteResponseResult struct { + Target string `json:"target"` + Colos []DiagnosticsTracerouteResponseColos `json:"colos"` +} + +// DiagnosticsTracerouteResponseColo contains the Name and City of a colocation. +type DiagnosticsTracerouteResponseColo struct { + Name string `json:"name"` + City string `json:"city"` +} + +// DiagnosticsTracerouteResponseNodes holds a summary of nodes contacted in the +// traceroute. +type DiagnosticsTracerouteResponseNodes struct { + Asn string `json:"asn"` + IP string `json:"ip"` + Name string `json:"name"` + PacketCount int `json:"packet_count"` + MeanRttMs float64 `json:"mean_rtt_ms"` + StdDevRttMs float64 `json:"std_dev_rtt_ms"` + MinRttMs float64 `json:"min_rtt_ms"` + MaxRttMs float64 `json:"max_rtt_ms"` +} + +// DiagnosticsTracerouteResponseHops holds packet and node information of the +// hops. +type DiagnosticsTracerouteResponseHops struct { + PacketsTTL int `json:"packets_ttl"` + PacketsSent int `json:"packets_sent"` + PacketsLost int `json:"packets_lost"` + Nodes []DiagnosticsTracerouteResponseNodes `json:"nodes"` +} + +// DiagnosticsTracerouteResponseColos is the summary struct of a colocation test. +type DiagnosticsTracerouteResponseColos struct { + Error string `json:"error"` + Colo DiagnosticsTracerouteResponseColo `json:"colo"` + TracerouteTimeMs int `json:"traceroute_time_ms"` + TargetSummary DiagnosticsTracerouteResponseNodes `json:"target_summary"` + Hops []DiagnosticsTracerouteResponseHops `json:"hops"` +} + +// PerformTraceroute initiates a traceroute from the Cloudflare network to the +// requested targets. +// +// API documentation: https://api.cloudflare.com/#diagnostics-traceroute +func (api *API) PerformTraceroute(ctx context.Context, accountID string, targets, colos []string, tracerouteOptions DiagnosticsTracerouteConfigurationOptions) ([]DiagnosticsTracerouteResponseResult, error) { + uri := fmt.Sprintf("/accounts/%s/diagnostics/traceroute", accountID) + diagnosticsPayload := DiagnosticsTracerouteConfiguration{ + Targets: targets, + Colos: colos, + Options: tracerouteOptions, + } + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, diagnosticsPayload) + if err != nil { + return []DiagnosticsTracerouteResponseResult{}, err + } + + var diagnosticsResponse DiagnosticsTracerouteResponse + err = json.Unmarshal(res, &diagnosticsResponse) + if err != nil { + return []DiagnosticsTracerouteResponseResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return diagnosticsResponse.Result, nil +} diff --git a/pkg/cloudflare-go/diagnostics_test.go b/pkg/cloudflare-go/diagnostics_test.go new file mode 100644 index 000000000..0662c006b --- /dev/null +++ b/pkg/cloudflare-go/diagnostics_test.go @@ -0,0 +1,236 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" +) + +func TestDiagnosticsPerformTraceroute(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + var request DiagnosticsTracerouteConfiguration + var err error + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + err = json.NewDecoder(r.Body).Decode(&request) + assert.NoError(t, err) + assert.Equal(t, request.Colos, []string{"den01"}, "Exepected key 'colos' to be [\"den01\"], got %+v", request.Colos) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "target": "1.1.1.1", + "colos": [ + { + "error": "", + "colo": { + "name": "den01", + "city": "Denver, CO, US" + }, + "traceroute_time_ms": 969, + "target_summary": { + "asn": "", + "ip": "1.1.1.1", + "name": "1.1.1.1", + "packet_count": 3, + "mean_rtt_ms": 0.021, + "std_dev_rtt_ms": 0.011269427669584647, + "min_rtt_ms": 0.014, + "max_rtt_ms": 0.034 + }, + "hops": [ + { + "packets_ttl": 1, + "packets_sent": 3, + "packets_lost": 0, + "nodes": [ + { + "asn": "AS13335", + "ip": "1.1.1.1", + "name": "one.one.one.one", + "packet_count": 3, + "mean_rtt_ms": 0.021, + "std_dev_rtt_ms": 0.011269427669584647, + "min_rtt_ms": 0.014, + "max_rtt_ms": 0.034 + } + ] + } + ] + } + ] + } + ] +} + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/diagnostics/traceroute", handler) + + want := []DiagnosticsTracerouteResponseResult{{ + Target: "1.1.1.1", + Colos: []DiagnosticsTracerouteResponseColos{{ + Error: "", + Colo: DiagnosticsTracerouteResponseColo{ + Name: "den01", City: "Denver, CO, US", + }, + TracerouteTimeMs: 969, + TargetSummary: DiagnosticsTracerouteResponseNodes{ + Asn: "", + IP: "1.1.1.1", + Name: "1.1.1.1", + PacketCount: 3, + MeanRttMs: 0.021, + StdDevRttMs: 0.011269427669584647, + MinRttMs: 0.014, + MaxRttMs: 0.034, + }, + Hops: []DiagnosticsTracerouteResponseHops{{ + PacketsTTL: 1, + PacketsSent: 3, + PacketsLost: 0, + Nodes: []DiagnosticsTracerouteResponseNodes{{ + Asn: "AS13335", + IP: "1.1.1.1", + Name: "one.one.one.one", + PacketCount: 3, + MeanRttMs: 0.021, + StdDevRttMs: 0.011269427669584647, + MinRttMs: 0.014, + MaxRttMs: 0.034, + }}, + }}, + }}, + }, + } + + opts := DiagnosticsTracerouteConfigurationOptions{PacketsPerTTL: 1, PacketType: "imcp", MaxTTL: 1, WaitTime: 1} + trace, err := client.PerformTraceroute(context.Background(), "01a7362d577a6c3019a474fd6f485823", []string{"1.1.1.1"}, []string{"den01"}, opts) + + if assert.NoError(t, err) { + assert.Equal(t, want, trace) + } +} + +func TestDiagnosticsPerformTracerouteEmptyColos(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + var request DiagnosticsTracerouteConfiguration + var err error + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + err = json.NewDecoder(r.Body).Decode(&request) + assert.NoError(t, err) + assert.Nil(t, request.Colos, "Exepected key 'colos' to be nil, got %+v", request.Colos) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "target": "1.1.1.1", + "colos": [ + { + "error": "", + "colo": { + "name": "den01", + "city": "Denver, CO, US" + }, + "traceroute_time_ms": 969, + "target_summary": { + "asn": "", + "ip": "1.1.1.1", + "name": "1.1.1.1", + "packet_count": 3, + "mean_rtt_ms": 0.021, + "std_dev_rtt_ms": 0.011269427669584647, + "min_rtt_ms": 0.014, + "max_rtt_ms": 0.034 + }, + "hops": [ + { + "packets_ttl": 1, + "packets_sent": 3, + "packets_lost": 0, + "nodes": [ + { + "asn": "AS13335", + "ip": "1.1.1.1", + "name": "one.one.one.one", + "packet_count": 3, + "mean_rtt_ms": 0.021, + "std_dev_rtt_ms": 0.011269427669584647, + "min_rtt_ms": 0.014, + "max_rtt_ms": 0.034 + } + ] + } + ] + } + ] + } + ] +} + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/diagnostics/traceroute", handler) + + want := []DiagnosticsTracerouteResponseResult{{ + Target: "1.1.1.1", + Colos: []DiagnosticsTracerouteResponseColos{{ + Error: "", + Colo: DiagnosticsTracerouteResponseColo{ + Name: "den01", City: "Denver, CO, US", + }, + TracerouteTimeMs: 969, + TargetSummary: DiagnosticsTracerouteResponseNodes{ + Asn: "", + IP: "1.1.1.1", + Name: "1.1.1.1", + PacketCount: 3, + MeanRttMs: 0.021, + StdDevRttMs: 0.011269427669584647, + MinRttMs: 0.014, + MaxRttMs: 0.034, + }, + Hops: []DiagnosticsTracerouteResponseHops{{ + PacketsTTL: 1, + PacketsSent: 3, + PacketsLost: 0, + Nodes: []DiagnosticsTracerouteResponseNodes{{ + Asn: "AS13335", + IP: "1.1.1.1", + Name: "one.one.one.one", + PacketCount: 3, + MeanRttMs: 0.021, + StdDevRttMs: 0.011269427669584647, + MinRttMs: 0.014, + MaxRttMs: 0.034, + }}, + }}, + }}, + }, + } + + opts := DiagnosticsTracerouteConfigurationOptions{PacketsPerTTL: 1, PacketType: "imcp", MaxTTL: 1, WaitTime: 1} + trace, err := client.PerformTraceroute(context.Background(), "01a7362d577a6c3019a474fd6f485823", []string{"1.1.1.1"}, []string{}, opts) + + if assert.NoError(t, err) { + assert.Equal(t, want, trace) + } +} diff --git a/pkg/cloudflare-go/dlp_dataset.go b/pkg/cloudflare-go/dlp_dataset.go new file mode 100644 index 000000000..09cf86416 --- /dev/null +++ b/pkg/cloudflare-go/dlp_dataset.go @@ -0,0 +1,278 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingDatasetID = errors.New("missing required dataset ID") +) + +// DLPDatasetUpload represents a single upload version attached to a DLP dataset. +type DLPDatasetUpload struct { + NumCells int `json:"num_cells"` + Status string `json:"status,omitempty"` + Version int `json:"version"` +} + +// DLPDataset represents a DLP Exact Data Match dataset or Custom Word List. +type DLPDataset struct { + CreatedAt *time.Time `json:"created_at,omitempty"` + Description string `json:"description,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + NumCells int `json:"num_cells"` + Secret *bool `json:"secret,omitempty"` + Status string `json:"status,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Uploads []DLPDatasetUpload `json:"uploads"` +} + +type ListDLPDatasetsParams struct{} + +type DLPDatasetListResponse struct { + Result []DLPDataset `json:"result"` + Response +} + +// ListDLPDatasets returns all the DLP datasets associated with an account. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-read-all +func (api *API) ListDLPDatasets(ctx context.Context, rc *ResourceContainer, params ListDLPDatasetsParams) ([]DLPDataset, error) { + if rc.Identifier == "" { + return nil, nil + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets", rc.Level, rc.Identifier), nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var dlpDatasetListResponse DLPDatasetListResponse + err = json.Unmarshal(res, &dlpDatasetListResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpDatasetListResponse.Result, nil +} + +type DLPDatasetGetResponse struct { + Result DLPDataset `json:"result"` + Response +} + +// GetDLPDataset returns a DLP dataset based on the dataset ID. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-read +func (api *API) GetDLPDataset(ctx context.Context, rc *ResourceContainer, datasetID string) (DLPDataset, error) { + if rc.Identifier == "" { + return DLPDataset{}, nil + } + + if datasetID == "" { + return DLPDataset{}, ErrMissingDatasetID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets/%s", rc.Level, rc.Identifier, datasetID), nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DLPDataset{}, err + } + + var dlpDatasetGetResponse DLPDatasetGetResponse + err = json.Unmarshal(res, &dlpDatasetGetResponse) + if err != nil { + return DLPDataset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpDatasetGetResponse.Result, nil +} + +type CreateDLPDatasetParams struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Secret *bool `json:"secret,omitempty"` +} + +type CreateDLPDatasetResult struct { + MaxCells int `json:"max_cells"` + Secret string `json:"secret"` + Version int `json:"version"` + Dataset DLPDataset `json:"dataset"` +} + +type CreateDLPDatasetResponse struct { + Result CreateDLPDatasetResult `json:"result"` + Response +} + +// CreateDLPDataset creates a DLP dataset. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-create +func (api *API) CreateDLPDataset(ctx context.Context, rc *ResourceContainer, params CreateDLPDatasetParams) (CreateDLPDatasetResult, error) { + if rc.Identifier == "" { + return CreateDLPDatasetResult{}, nil + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets", rc.Level, rc.Identifier), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return CreateDLPDatasetResult{}, err + } + + var CreateDLPDatasetResponse CreateDLPDatasetResponse + err = json.Unmarshal(res, &CreateDLPDatasetResponse) + if err != nil { + return CreateDLPDatasetResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return CreateDLPDatasetResponse.Result, nil +} + +// DeleteDLPDataset deletes a DLP dataset. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-delete +func (api *API) DeleteDLPDataset(ctx context.Context, rc *ResourceContainer, datasetID string) error { + if rc.Identifier == "" { + return ErrMissingResourceIdentifier + } + + if datasetID == "" { + return ErrMissingDatasetID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets/%s", rc.Level, rc.Identifier, datasetID), nil) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + return err +} + +type UpdateDLPDatasetParams struct { + DatasetID string + Description *string `json:"description,omitempty"` // nil to leave descrption as-is + Name *string `json:"name,omitempty"` // nil to leave name as-is +} + +type UpdateDLPDatasetResponse struct { + Result DLPDataset `json:"result"` + Response +} + +// UpdateDLPDataset updates the details of a DLP dataset. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-update +func (api *API) UpdateDLPDataset(ctx context.Context, rc *ResourceContainer, params UpdateDLPDatasetParams) (DLPDataset, error) { + if rc.Identifier == "" { + return DLPDataset{}, nil + } + + if params.DatasetID == "" { + return DLPDataset{}, ErrMissingDatasetID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets/%s", rc.Level, rc.Identifier, params.DatasetID), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return DLPDataset{}, err + } + + var updateDLPDatasetResponse UpdateDLPDatasetResponse + err = json.Unmarshal(res, &updateDLPDatasetResponse) + if err != nil { + return DLPDataset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return updateDLPDatasetResponse.Result, nil +} + +type CreateDLPDatasetUploadResult struct { + MaxCells int `json:"max_cells"` + Secret string `json:"secret"` + Version int `json:"version"` +} + +type CreateDLPDatasetUploadResponse struct { + Result CreateDLPDatasetUploadResult `json:"result"` + Response +} +type CreateDLPDatasetUploadParams struct { + DatasetID string +} + +// CreateDLPDatasetUpload creates a new upload version for the specified DLP dataset. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-create-version +func (api *API) CreateDLPDatasetUpload(ctx context.Context, rc *ResourceContainer, params CreateDLPDatasetUploadParams) (CreateDLPDatasetUploadResult, error) { + if rc.Identifier == "" { + return CreateDLPDatasetUploadResult{}, nil + } + + if params.DatasetID == "" { + return CreateDLPDatasetUploadResult{}, ErrMissingDatasetID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets/%s/upload", rc.Level, rc.Identifier, params.DatasetID), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return CreateDLPDatasetUploadResult{}, err + } + + var dlpDatasetCreateUploadResponse CreateDLPDatasetUploadResponse + err = json.Unmarshal(res, &dlpDatasetCreateUploadResponse) + if err != nil { + return CreateDLPDatasetUploadResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpDatasetCreateUploadResponse.Result, nil +} + +type UploadDLPDatasetVersionParams struct { + DatasetID string + Version int + Body interface{} +} + +type UploadDLPDatasetVersionResponse struct { + Result DLPDataset `json:"result"` + Response +} + +// UploadDLPDatasetVersion uploads a new version of the specified DLP dataset. +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-datasets-upload-version +func (api *API) UploadDLPDatasetVersion(ctx context.Context, rc *ResourceContainer, params UploadDLPDatasetVersionParams) (DLPDataset, error) { + if rc.Identifier == "" { + return DLPDataset{}, nil + } + + if params.DatasetID == "" { + return DLPDataset{}, ErrMissingDatasetID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/datasets/%s/upload/%d", rc.Level, rc.Identifier, params.DatasetID, params.Version), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Body) + if err != nil { + return DLPDataset{}, err + } + + var dlpDatasetUploadVersionResponse UploadDLPDatasetVersionResponse + err = json.Unmarshal(res, &dlpDatasetUploadVersionResponse) + if err != nil { + return DLPDataset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpDatasetUploadVersionResponse.Result, nil +} diff --git a/pkg/cloudflare-go/dlp_dataset_test.go b/pkg/cloudflare-go/dlp_dataset_test.go new file mode 100644 index 000000000..363163627 --- /dev/null +++ b/pkg/cloudflare-go/dlp_dataset_test.go @@ -0,0 +1,422 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListDLPDatasets(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "num_cells": 0, + "secret": true, + "status": "empty", + "updated_at": "2019-08-24T14:15:22Z", + "uploads": [ + { + "num_cells": 0, + "status": "empty", + "version": 0 + } + ] + } + ] + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + updatedAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + + secret := true + want := []DLPDataset{ + { + CreatedAt: &createdAt, + Description: "string", + ID: "497f6eca-6276-4993-bfeb-53cbbbba6f08", + Name: "string", + NumCells: 0, + Secret: &secret, + Status: "empty", + UpdatedAt: &updatedAt, + Uploads: []DLPDatasetUpload{ + { + NumCells: 0, + Status: "empty", + Version: 0, + }, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets", handler) + + actual, err := client.ListDLPDatasets(context.Background(), AccountIdentifier(testAccountID), ListDLPDatasetsParams{}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestGetDLPDataset(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "num_cells": 0, + "secret": true, + "status": "empty", + "updated_at": "2019-08-24T14:15:22Z", + "uploads": [ + { + "num_cells": 0, + "status": "empty", + "version": 0 + } + ] + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + updatedAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + + secret := true + want := DLPDataset{ + CreatedAt: &createdAt, + Description: "string", + ID: "497f6eca-6276-4993-bfeb-53cbbbba6f08", + Name: "string", + NumCells: 0, + Secret: &secret, + Status: "empty", + UpdatedAt: &updatedAt, + Uploads: []DLPDatasetUpload{ + { + NumCells: 0, + Status: "empty", + Version: 0, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets/497f6eca-6276-4993-bfeb-53cbbbba6f08", handler) + + actual, err := client.GetDLPDataset(context.Background(), AccountIdentifier(testAccountID), "497f6eca-6276-4993-bfeb-53cbbbba6f08") + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestCreateDLPDataset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var reqBody CreateDLPDatasetParams + err := json.NewDecoder(r.Body).Decode(&reqBody) + require.Nil(t, err) + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "max_cells": 0, + "secret": "1234", + "version": 0, + "dataset": { + "created_at": "2019-08-24T14:15:22Z", + "description": "`+reqBody.Description+`", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "`+reqBody.Name+`", + "num_cells": 0, + "secret": `+fmt.Sprintf("%t", *reqBody.Secret)+`, + "status": "empty", + "updated_at": "2019-08-24T14:15:22Z", + "uploads": [ + { + "num_cells": 0, + "status": "empty", + "version": 0 + } + ] + } + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + updatedAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + + secret := true + want := CreateDLPDatasetResult{ + MaxCells: 0, + Secret: "1234", + Version: 0, + Dataset: DLPDataset{ + CreatedAt: &createdAt, + Description: "string", + ID: "497f6eca-6276-4993-bfeb-53cbbbba6f08", + Name: "string", + NumCells: 0, + Secret: &secret, + Status: "empty", + UpdatedAt: &updatedAt, + Uploads: []DLPDatasetUpload{ + { + NumCells: 0, + Status: "empty", + Version: 0, + }, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets", handler) + + actual, err := client.CreateDLPDataset(context.Background(), AccountIdentifier(testAccountID), CreateDLPDatasetParams{Description: "string", Name: "string", Secret: &secret}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestDeleteDLPDataset(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets/497f6eca-6276-4993-bfeb-53cbbbba6f08", handler) + + err := client.DeleteDLPDataset(context.Background(), AccountIdentifier(testAccountID), "497f6eca-6276-4993-bfeb-53cbbbba6f08") + require.NoError(t, err) +} + +func TestUpdateDLPDataset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var reqBody UpdateDLPDatasetParams + err := json.NewDecoder(r.Body).Decode(&reqBody) + require.Nil(t, err) + + var description string + if reqBody.Description == nil { + description = "string" + } else { + description = *reqBody.Description + } + + var name string + if reqBody.Name == nil { + name = "string" + } else { + name = *reqBody.Name + } + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2019-08-24T14:15:22Z", + "description": "`+description+`", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "`+name+`", + "num_cells": 0, + "secret": true, + "status": "empty", + "updated_at": "2019-08-24T14:15:22Z", + "uploads": [ + { + "num_cells": 0, + "status": "empty", + "version": 0 + } + ] + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + updatedAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + + secret := true + want := DLPDataset{ + CreatedAt: &createdAt, + Description: "new_desc", + ID: "497f6eca-6276-4993-bfeb-53cbbbba6f08", + Name: "string", + NumCells: 0, + Secret: &secret, + Status: "empty", + UpdatedAt: &updatedAt, + Uploads: []DLPDatasetUpload{ + { + NumCells: 0, + Status: "empty", + Version: 0, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets/497f6eca-6276-4993-bfeb-53cbbbba6f08", handler) + + description := "new_desc" + actual, err := client.UpdateDLPDataset(context.Background(), AccountIdentifier(testAccountID), UpdateDLPDatasetParams{Description: &description, Name: nil, DatasetID: "497f6eca-6276-4993-bfeb-53cbbbba6f08"}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestCreateDLPDatasetUpload(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "max_cells": 0, + "secret": "1234", + "version": 1 + } + }`) + } + + want := CreateDLPDatasetUploadResult{ + MaxCells: 0, + Secret: "1234", + Version: 1, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets/497f6eca-6276-4993-bfeb-53cbbbba6f08/upload", handler) + + actual, err := client.CreateDLPDatasetUpload(context.Background(), AccountIdentifier(testAccountID), CreateDLPDatasetUploadParams{DatasetID: "497f6eca-6276-4993-bfeb-53cbbbba6f08"}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestUploadDLPDatasetVersion(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Equal(t, []byte{1, 2, 3, 4}, body) + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "created_at": "2019-08-24T14:15:22Z", + "description": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "num_cells": 5, + "secret": true, + "status": "complete", + "updated_at": "2019-08-24T14:15:22Z", + "uploads": [ + { + "num_cells": 0, + "status": "empty", + "version": 0 + }, + { + "num_cells": 5, + "status": "complete", + "version": 1 + } + ] + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + updatedAt, _ := time.Parse(time.RFC3339, "2019-08-24T14:15:22Z") + + secret := true + want := DLPDataset{ + CreatedAt: &createdAt, + Description: "string", + ID: "497f6eca-6276-4993-bfeb-53cbbbba6f08", + Name: "string", + NumCells: 5, + Secret: &secret, + Status: "complete", + UpdatedAt: &updatedAt, + Uploads: []DLPDatasetUpload{ + { + NumCells: 0, + Status: "empty", + Version: 0, + }, + { + NumCells: 5, + Status: "complete", + Version: 1, + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/datasets/497f6eca-6276-4993-bfeb-53cbbbba6f08/upload/1", handler) + + actual, err := client.UploadDLPDatasetVersion(context.Background(), AccountIdentifier(testAccountID), UploadDLPDatasetVersionParams{DatasetID: "497f6eca-6276-4993-bfeb-53cbbbba6f08", Version: 1, Body: []byte{1, 2, 3, 4}}) + require.NoError(t, err) + require.Equal(t, want, actual) +} diff --git a/pkg/cloudflare-go/dlp_payload_log.go b/pkg/cloudflare-go/dlp_payload_log.go new file mode 100644 index 000000000..80123b459 --- /dev/null +++ b/pkg/cloudflare-go/dlp_payload_log.go @@ -0,0 +1,72 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type DLPPayloadLogSettings struct { + PublicKey string `json:"public_key,omitempty"` + + // Only present in responses + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type GetDLPPayloadLogSettingsParams struct{} + +type DLPPayloadLogSettingsResponse struct { + Response + Result DLPPayloadLogSettings `json:"result"` +} + +// GetDLPPayloadLogSettings gets the current DLP payload logging settings. +// +// API reference: https://api.cloudflare.com/#dlp-payload-log-settings-get-settings +func (api *API) GetDLPPayloadLogSettings(ctx context.Context, rc *ResourceContainer, params GetDLPPayloadLogSettingsParams) (DLPPayloadLogSettings, error) { + if rc.Identifier == "" { + return DLPPayloadLogSettings{}, ErrMissingResourceIdentifier + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/payload_log", rc.Level, rc.Identifier), nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DLPPayloadLogSettings{}, err + } + + var dlpPayloadLogSettingsResponse DLPPayloadLogSettingsResponse + err = json.Unmarshal(res, &dlpPayloadLogSettingsResponse) + if err != nil { + return DLPPayloadLogSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpPayloadLogSettingsResponse.Result, nil +} + +// UpdateDLPPayloadLogSettings sets the current DLP payload logging settings to new values. +// +// API reference: https://api.cloudflare.com/#dlp-payload-log-settings-update-settings +func (api *API) UpdateDLPPayloadLogSettings(ctx context.Context, rc *ResourceContainer, settings DLPPayloadLogSettings) (DLPPayloadLogSettings, error) { + if rc.Identifier == "" { + return DLPPayloadLogSettings{}, ErrMissingResourceIdentifier + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/payload_log", rc.Level, rc.Identifier), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, settings) + if err != nil { + return DLPPayloadLogSettings{}, err + } + + var dlpPayloadLogSettingsResponse DLPPayloadLogSettingsResponse + err = json.Unmarshal(res, &dlpPayloadLogSettingsResponse) + if err != nil { + return DLPPayloadLogSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpPayloadLogSettingsResponse.Result, nil +} diff --git a/pkg/cloudflare-go/dlp_payload_log_test.go b/pkg/cloudflare-go/dlp_payload_log_test.go new file mode 100644 index 000000000..6cb86b54a --- /dev/null +++ b/pkg/cloudflare-go/dlp_payload_log_test.go @@ -0,0 +1,85 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDLPPayloadLogSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "public_key": "3NP5MGKjzBLLceVxNZrF+LyithbWX+AVFBMRAA0Xl2A=", + "updated_at": "2022-12-22T21:02:39Z" + } + }`) + } + + updatedAt, _ := time.Parse(time.RFC3339, "2022-12-22T21:02:39Z") + + want := DLPPayloadLogSettings{ + PublicKey: "3NP5MGKjzBLLceVxNZrF+LyithbWX+AVFBMRAA0Xl2A=", + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/payload_log", handler) + + actual, err := client.GetDLPPayloadLogSettings(context.Background(), AccountIdentifier(testAccountID), GetDLPPayloadLogSettingsParams{}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestPutDLPPayloadLogSettings(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var requestSettings DLPPayloadLogSettings + err := json.NewDecoder(r.Body).Decode(&requestSettings) + require.Nil(t, err) + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "public_key": "`+requestSettings.PublicKey+`", + "updated_at": "2022-12-22T21:02:39Z" + } + }`) + } + + updatedAt, _ := time.Parse(time.RFC3339, "2022-12-22T21:02:39Z") + + want := DLPPayloadLogSettings{ + PublicKey: "3NP5MGKjzBLLceVxNZrF+LyithbWX+AVFBMRAA0Xl2A=", + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/payload_log", handler) + + actual, err := client.UpdateDLPPayloadLogSettings(context.Background(), AccountIdentifier(testAccountID), DLPPayloadLogSettings{ + PublicKey: "3NP5MGKjzBLLceVxNZrF+LyithbWX+AVFBMRAA0Xl2A=", + }) + require.NoError(t, err) + require.Equal(t, want, actual) +} diff --git a/pkg/cloudflare-go/dlp_profile.go b/pkg/cloudflare-go/dlp_profile.go new file mode 100644 index 000000000..25e78e9c4 --- /dev/null +++ b/pkg/cloudflare-go/dlp_profile.go @@ -0,0 +1,234 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingProfileID = errors.New("missing required profile ID") +) + +// DLPPattern represents a DLP Pattern that matches an entry. +type DLPPattern struct { + Regex string `json:"regex,omitempty"` + Validation string `json:"validation,omitempty"` +} + +// DLPEntry represents a DLP Entry, which can be matched in HTTP bodies or files. +type DLPEntry struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + ProfileID string `json:"profile_id,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Type string `json:"type,omitempty"` + + // The following fields are only present for custom entries. + + Pattern *DLPPattern `json:"pattern,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// Content types to exclude from context analysis and return all matches. +type DLPContextAwarenessSkip struct { + // Return all matches, regardless of context analysis result, if the data is a file. + Files *bool `json:"files,omitempty"` +} + +// Scan the context of predefined entries to only return matches surrounded by keywords. +type DLPContextAwareness struct { + Enabled *bool `json:"enabled,omitempty"` + Skip DLPContextAwarenessSkip `json:"skip"` +} + +// DLPProfile represents a DLP Profile, which contains a set +// of entries. +type DLPProfile struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + AllowedMatchCount int `json:"allowed_match_count"` + OCREnabled *bool `json:"ocr_enabled,omitempty"` + + ContextAwareness *DLPContextAwareness `json:"context_awareness,omitempty"` + + // The following fields are omitted for predefined DLP + // profiles. + Entries []DLPEntry `json:"entries,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// DLPProfilesCreateRequest represents a request to create a +// set of profiles. +type DLPProfilesCreateRequest struct { + Profiles []DLPProfile `json:"profiles"` +} + +// DLPProfileListResponse represents the response from the list +// dlp profiles endpoint. +type DLPProfileListResponse struct { + Result []DLPProfile `json:"result"` + Response +} + +// DLPProfileResponse is the API response, containing a single +// access application. +type DLPProfileResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result DLPProfile `json:"result"` +} + +type ListDLPProfilesParams struct{} + +type CreateDLPProfilesParams struct { + Profiles []DLPProfile `json:"profiles"` + Type string +} + +type UpdateDLPProfileParams struct { + ProfileID string + Profile DLPProfile + Type string +} + +// ListDLPProfiles returns all DLP profiles within an account. +// +// API reference: https://api.cloudflare.com/#dlp-profiles-list-all-profiles +func (api *API) ListDLPProfiles(ctx context.Context, rc *ResourceContainer, params ListDLPProfilesParams) ([]DLPProfile, error) { + if rc.Identifier == "" { + return []DLPProfile{}, ErrMissingResourceIdentifier + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/profiles", rc.Level, rc.Identifier), nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DLPProfile{}, err + } + + var dlpProfilesListResponse DLPProfileListResponse + err = json.Unmarshal(res, &dlpProfilesListResponse) + if err != nil { + return []DLPProfile{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpProfilesListResponse.Result, nil +} + +// GetDLPProfile returns a single DLP profile (custom or predefined) based on +// the profile ID. +// +// API reference: https://api.cloudflare.com/#dlp-profiles-get-dlp-profile +func (api *API) GetDLPProfile(ctx context.Context, rc *ResourceContainer, profileID string) (DLPProfile, error) { + if rc.Identifier == "" { + return DLPProfile{}, ErrMissingResourceIdentifier + } + + if profileID == "" { + return DLPProfile{}, ErrMissingProfileID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/profiles/%s", rc.Level, rc.Identifier, profileID), nil) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DLPProfile{}, err + } + + var dlpProfileResponse DLPProfileResponse + err = json.Unmarshal(res, &dlpProfileResponse) + if err != nil { + return DLPProfile{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpProfileResponse.Result, nil +} + +// CreateDLPProfiles creates a set of DLP Profile. +// +// API reference: https://api.cloudflare.com/#dlp-profiles-create-custom-profiles +func (api *API) CreateDLPProfiles(ctx context.Context, rc *ResourceContainer, params CreateDLPProfilesParams) ([]DLPProfile, error) { + if rc.Identifier == "" { + return []DLPProfile{}, ErrMissingResourceIdentifier + } + + if params.Type == "" || params.Type != "custom" { + return []DLPProfile{}, fmt.Errorf("unsupported DLP profile type: %q", params.Type) + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/profiles/%s", rc.Level, rc.Identifier, params.Type), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return []DLPProfile{}, err + } + + var dLPCustomProfilesResponse DLPProfileListResponse + err = json.Unmarshal(res, &dLPCustomProfilesResponse) + if err != nil { + return []DLPProfile{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dLPCustomProfilesResponse.Result, nil +} + +// DeleteDLPProfile deletes a DLP profile. Only custom profiles can be deleted. +// +// API reference: https://api.cloudflare.com/#dlp-profiles-delete-custom-profile +func (api *API) DeleteDLPProfile(ctx context.Context, rc *ResourceContainer, profileID string) error { + if rc.Identifier == "" { + return ErrMissingResourceIdentifier + } + + if profileID == "" { + return ErrMissingProfileID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/profiles/custom/%s", rc.Level, rc.Identifier, profileID), nil) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + return err +} + +// UpdateDLPProfile updates a DLP profile. +// +// API reference: https://api.cloudflare.com/#dlp-profiles-update-custom-profile +// API reference: https://api.cloudflare.com/#dlp-profiles-update-predefined-profile +func (api *API) UpdateDLPProfile(ctx context.Context, rc *ResourceContainer, params UpdateDLPProfileParams) (DLPProfile, error) { + if rc.Identifier == "" { + return DLPProfile{}, ErrMissingResourceIdentifier + } + + if params.Type == "" { + params.Type = "custom" + } + + if params.ProfileID == "" { + return DLPProfile{}, ErrMissingProfileID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/dlp/profiles/%s/%s", rc.Level, rc.Identifier, params.Type, params.ProfileID), nil) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Profile) + if err != nil { + return DLPProfile{}, err + } + + var dlpProfileResponse DLPProfileResponse + err = json.Unmarshal(res, &dlpProfileResponse) + if err != nil { + return DLPProfile{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return dlpProfileResponse.Result, nil +} diff --git a/pkg/cloudflare-go/dlp_profile_test.go b/pkg/cloudflare-go/dlp_profile_test.go new file mode 100644 index 000000000..3345c3dd3 --- /dev/null +++ b/pkg/cloudflare-go/dlp_profile_test.go @@ -0,0 +1,636 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDLPProfiles(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "d658f520-6ecb-4a34-a725-ba37243c2d28", + "name": "U.S. Social Security Numbers", + "entries": [ + { + "id": "111b9d4b-a5c6-40f0-957d-9d53b25dd84a", + "name": "SSN Numeric Detection", + "profile_id": "d658f520-6ecb-4a34-a725-ba37243c2d28", + "enabled": false, + "type": "predefined" + }, + { + "id": "aec08712-ee49-4109-9d9f-3b229c5b3dcd", + "name": "SSN Text", + "profile_id": "d658f520-6ecb-4a34-a725-ba37243c2d28", + "enabled": false, + "type": "predefined" + } + ], + "type": "predefined", + "allowed_match_count": 0, + "context_awareness": { + "enabled": true, + "skip": { + "files": true + } + }, + "ocr_enabled": false + }, + { + "id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "name": "Example Custom Profile", + "entries": [ + { + "id": "ef79b054-12d4-4067-bb30-b85f6267b91c", + "name": "matches credit card regex", + "profile_id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "pattern": { + "regex": "^4[0-9]$", + "validation": "luhn" + }, + "enabled": true, + "type": "custom" + } + ], + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "type": "custom", + "description": "just a custom profile example", + "allowed_match_count": 1, + "ocr_enabled": true + } + ] + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:56Z") + updatedAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:57Z") + + want := []DLPProfile{ + { + ID: "d658f520-6ecb-4a34-a725-ba37243c2d28", + Name: "U.S. Social Security Numbers", + Type: "predefined", + Description: "", + AllowedMatchCount: 0, + ContextAwareness: &DLPContextAwareness{ + Enabled: BoolPtr(true), + Skip: DLPContextAwarenessSkip{ + Files: BoolPtr(true), + }, + }, + OCREnabled: BoolPtr(false), + Entries: []DLPEntry{ + { + ID: "111b9d4b-a5c6-40f0-957d-9d53b25dd84a", + Name: "SSN Numeric Detection", + ProfileID: "d658f520-6ecb-4a34-a725-ba37243c2d28", + Type: "predefined", + Enabled: BoolPtr(false), + }, + { + ID: "aec08712-ee49-4109-9d9f-3b229c5b3dcd", + Name: "SSN Text", ProfileID: "d658f520-6ecb-4a34-a725-ba37243c2d28", + Type: "predefined", + Enabled: BoolPtr(false), + }, + }, + }, + { + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Name: "Example Custom Profile", + Type: "custom", + Description: "just a custom profile example", + AllowedMatchCount: 1, + // Omit ContextAwareness to test ContextAwareness optionality + OCREnabled: BoolPtr(true), + Entries: []DLPEntry{ + { + ID: "ef79b054-12d4-4067-bb30-b85f6267b91c", + Name: "matches credit card regex", + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Enabled: BoolPtr(true), + Type: "custom", + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles", handler) + + actual, err := client.ListDLPProfiles(context.Background(), AccountIdentifier(testAccountID), ListDLPProfilesParams{}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestGetDLPProfile(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "name": "Example Custom Profile", + "entries": [ + { + "id": "ef79b054-12d4-4067-bb30-b85f6267b91c", + "name": "matches credit card regex", + "profile_id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "pattern": { + "regex": "^4[0-9]$", + "validation": "luhn" + }, + "enabled": true, + "type": "custom" + } + ], + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "type": "custom", + "description": "just a custom profile example", + "allowed_match_count": 42, + "context_awareness": { + "enabled": false, + "skip": { + "files": false + } + } + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:56Z") + updatedAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:57Z") + + want := DLPProfile{ + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Name: "Example Custom Profile", + Type: "custom", + Description: "just a custom profile example", + AllowedMatchCount: 42, + ContextAwareness: &DLPContextAwareness{ + Enabled: BoolPtr(false), + Skip: DLPContextAwarenessSkip{ + Files: BoolPtr(false), + }, + }, + Entries: []DLPEntry{ + { + ID: "ef79b054-12d4-4067-bb30-b85f6267b91c", + Name: "matches credit card regex", + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Enabled: BoolPtr(true), + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + Type: "custom", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles/29678c26-a191-428d-9f63-6e20a4a636a4", handler) + + actual, err := client.GetDLPProfile(context.Background(), AccountIdentifier(testAccountID), "29678c26-a191-428d-9f63-6e20a4a636a4") + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestCreateDLPCustomProfiles(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var reqBody DLPProfilesCreateRequest + err := json.NewDecoder(r.Body).Decode(&reqBody) + require.Nil(t, err) + requestProfile := reqBody.Profiles[0] + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [{ + "id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "name": "`+requestProfile.Name+`", + "entries": [ + { + "id": "ef79b054-12d4-4067-bb30-b85f6267b91c", + "name": "`+requestProfile.Entries[0].Name+`", + "profile_id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "pattern": { + "regex": "`+requestProfile.Entries[0].Pattern.Regex+`", + "validation": "`+requestProfile.Entries[0].Pattern.Validation+`" + }, + "enabled": `+fmt.Sprintf("%t", Bool(requestProfile.Entries[0].Enabled))+`, + "type": "custom" + } + ], + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "type": "custom", + "description": "`+requestProfile.Description+`", + "allowed_match_count": 0, + "ocr_enabled": true + }] + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:56Z") + updatedAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:57Z") + + want := []DLPProfile{ + { + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Name: "Example Custom Profile", + Type: "custom", + Description: "just a custom profile example", + Entries: []DLPEntry{ + { + ID: "ef79b054-12d4-4067-bb30-b85f6267b91c", + Name: "matches credit card regex", + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Enabled: BoolPtr(true), + Type: "custom", + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AllowedMatchCount: 0, + OCREnabled: BoolPtr(true), + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles/custom", handler) + + profiles := []DLPProfile{ + { + Name: "Example Custom Profile", + Description: "just a custom profile example", + Type: "custom", + Entries: []DLPEntry{ + { + Name: "matches credit card regex", + Enabled: BoolPtr(true), + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + }, + }, + AllowedMatchCount: 0, + }, + } + actual, err := client.CreateDLPProfiles(context.Background(), AccountIdentifier(testAccountID), CreateDLPProfilesParams{Profiles: profiles, Type: "custom"}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestCreateDLPCustomProfile(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var reqBody DLPProfilesCreateRequest + err := json.NewDecoder(r.Body).Decode(&reqBody) + require.Nil(t, err) + requestProfile := reqBody.Profiles[0] + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [{ + "id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "name": "`+requestProfile.Name+`", + "entries": [ + { + "id": "ef79b054-12d4-4067-bb30-b85f6267b91c", + "name": "`+requestProfile.Entries[0].Name+`", + "profile_id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "pattern": { + "regex": "`+requestProfile.Entries[0].Pattern.Regex+`", + "validation": "`+requestProfile.Entries[0].Pattern.Validation+`" + }, + "enabled": `+fmt.Sprintf("%t", Bool(requestProfile.Entries[0].Enabled))+`, + "type": "custom" + } + ], + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "type": "custom", + "description": "`+requestProfile.Description+`", + "allowed_match_count": 0 + }] + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:56Z") + updatedAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:57Z") + + want := []DLPProfile{{ + + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Name: "Example Custom Profile", + Type: "custom", + Description: "just a custom profile example", + Entries: []DLPEntry{ + { + ID: "ef79b054-12d4-4067-bb30-b85f6267b91c", + Name: "matches credit card regex", + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Type: "custom", + Enabled: BoolPtr(true), + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AllowedMatchCount: 0, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles/custom", handler) + + profiles := []DLPProfile{{ + Name: "Example Custom Profile", + Description: "just a custom profile example", + Entries: []DLPEntry{ + { + Name: "matches credit card regex", + Enabled: BoolPtr(true), + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + }, + }, + AllowedMatchCount: 0, + }} + + actual, err := client.CreateDLPProfiles(context.Background(), AccountIdentifier(testAccountID), CreateDLPProfilesParams{ + Profiles: profiles, + Type: "custom", + }) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestUpdateDLPCustomProfile(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var requestProfile DLPProfile + err := json.NewDecoder(r.Body).Decode(&requestProfile) + require.Nil(t, err) + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "name": "`+requestProfile.Name+`", + "entries": [ + { + "id": "ef79b054-12d4-4067-bb30-b85f6267b91c", + "name": "`+requestProfile.Entries[0].Name+`", + "profile_id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "pattern": { + "regex": "`+requestProfile.Entries[0].Pattern.Regex+`", + "validation": "`+requestProfile.Entries[0].Pattern.Validation+`" + }, + "enabled": `+fmt.Sprintf("%t", Bool(requestProfile.Entries[0].Enabled))+`, + "type": "custom" + } + ], + "created_at": "2022-10-18T08:00:56Z", + "updated_at": "2022-10-18T08:00:57Z", + "type": "custom", + "description": "`+requestProfile.Description+`", + "allowed_match_count": 0 + } + }`) + } + + createdAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:56Z") + updatedAt, _ := time.Parse(time.RFC3339, "2022-10-18T08:00:57Z") + + want := DLPProfile{ + + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Name: "Example Custom Profile", + Type: "custom", + Description: "just a custom profile example", + Entries: []DLPEntry{ + { + ID: "ef79b054-12d4-4067-bb30-b85f6267b91c", + Name: "matches credit card regex", + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Enabled: BoolPtr(true), + Type: "custom", + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + AllowedMatchCount: 0, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles/custom/29678c26-a191-428d-9f63-6e20a4a636a4", handler) + + customProfile := DLPProfile{ + Name: "Example Custom Profile", + Description: "just a custom profile example", + Entries: []DLPEntry{ + { + Name: "matches credit card regex", + Enabled: BoolPtr(true), + Pattern: &DLPPattern{ + Regex: "^4[0-9]$", + Validation: "luhn", + }, + }, + }, + AllowedMatchCount: 0, + } + actual, err := client.UpdateDLPProfile(context.Background(), AccountIdentifier(testAccountID), UpdateDLPProfileParams{ + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Type: "custom", + Profile: customProfile, + }) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestUpdateDLPPredefinedProfile(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var requestProfile DLPProfile + err := json.NewDecoder(r.Body).Decode(&requestProfile) + require.Nil(t, err) + + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "name": "Example predefined profile", + "entries": [ + { + "id": "ef79b054-12d4-4067-bb30-b85f6267b91c", + "name": "Example predefined entry", + "profile_id": "29678c26-a191-428d-9f63-6e20a4a636a4", + "enabled": `+fmt.Sprintf("%t", Bool(requestProfile.Entries[0].Enabled))+`, + "type": "predefined" + } + ], + "type": "predefined", + "description": "example predefined profile", + "allowed_match_count": 0, + "context_awareness": { + "enabled": true, + "skip": { + "files": true + } + } + } + }`) + } + + want := DLPProfile{ + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Name: "Example predefined profile", + Type: "predefined", + Description: "example predefined profile", + AllowedMatchCount: 0, + ContextAwareness: &DLPContextAwareness{ + Enabled: BoolPtr(true), + Skip: DLPContextAwarenessSkip{ + Files: BoolPtr(true), + }, + }, + Entries: []DLPEntry{ + { + ID: "ef79b054-12d4-4067-bb30-b85f6267b91c", + Name: "Example predefined entry", + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Type: "predefined", + Enabled: BoolPtr(true), + }, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles/predefined/29678c26-a191-428d-9f63-6e20a4a636a4", handler) + + actual, err := client.UpdateDLPProfile(context.Background(), AccountIdentifier(testAccountID), UpdateDLPProfileParams{ + ProfileID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Type: "predefined", + Profile: DLPProfile{ + Entries: []DLPEntry{ + { + ID: "29678c26-a191-428d-9f63-6e20a4a636a4", + Enabled: BoolPtr(true), + }, + }, + }}) + require.NoError(t, err) + require.Equal(t, want, actual) +} + +func TestDeleteDLPCustomProfile(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dlp/profiles/custom/29678c26-a191-428d-9f63-6e20a4a636a4", handler) + + err := client.DeleteDLPProfile(context.Background(), AccountIdentifier(testAccountID), "29678c26-a191-428d-9f63-6e20a4a636a4") + require.NoError(t, err) +} diff --git a/pkg/cloudflare-go/dns.go b/pkg/cloudflare-go/dns.go new file mode 100644 index 000000000..14dbe2d4b --- /dev/null +++ b/pkg/cloudflare-go/dns.go @@ -0,0 +1,423 @@ +package cloudflare + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/goccy/go-json" + "golang.org/x/net/idna" +) + +// ErrMissingBINDContents is for when the BIND file contents is required but not set. +var ErrMissingBINDContents = errors.New("required BIND config contents missing") + +// DNSRecord represents a DNS record in a zone. +type DNSRecord struct { + CreatedOn time.Time `json:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + Meta interface{} `json:"meta,omitempty"` + Data interface{} `json:"data,omitempty"` // data returned by: SRV, LOC + ID string `json:"id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + Priority *uint16 `json:"priority,omitempty"` + TTL int `json:"ttl,omitempty"` + Proxied *bool `json:"proxied,omitempty"` + Proxiable bool `json:"proxiable,omitempty"` + Comment string `json:"comment,omitempty"` // the server will omit the comment field when the comment is empty + Tags []string `json:"tags,omitempty"` +} + +// DNSRecordResponse represents the response from the DNS endpoint. +type DNSRecordResponse struct { + Result DNSRecord `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type ListDirection string + +const ( + ListDirectionAsc ListDirection = "asc" + ListDirectionDesc ListDirection = "desc" +) + +type ListDNSRecordsParams struct { + Type string `url:"type,omitempty"` + Name string `url:"name,omitempty"` + Content string `url:"content,omitempty"` + Proxied *bool `url:"proxied,omitempty"` + Comment string `url:"comment,omitempty"` // currently, the server does not support searching for records with an empty comment + Tags []string `url:"tag,omitempty"` // potentially multiple `tag=` + TagMatch string `url:"tag-match,omitempty"` + Order string `url:"order,omitempty"` + Direction ListDirection `url:"direction,omitempty"` + Match string `url:"match,omitempty"` + Priority *uint16 `url:"-"` + + ResultInfo +} + +type UpdateDNSRecordParams struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Content string `json:"content,omitempty"` + Data interface{} `json:"data,omitempty"` // data for: SRV, LOC + ID string `json:"-"` + Priority *uint16 `json:"priority,omitempty"` + TTL int `json:"ttl,omitempty"` + Proxied *bool `json:"proxied,omitempty"` + Comment *string `json:"comment,omitempty"` // nil will keep the current comment, while StringPtr("") will empty it + Tags []string `json:"tags"` +} + +// DNSListResponse represents the response from the list DNS records endpoint. +type DNSListResponse struct { + Result []DNSRecord `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// listDNSRecordsDefaultPageSize represents the default per_page size of the API. +var listDNSRecordsDefaultPageSize int = 100 + +// nontransitionalLookup implements the nontransitional processing as specified in +// Unicode Technical Standard 46 with almost all checkings off to maximize user freedom. +var nontransitionalLookup = idna.New( + idna.MapForLookup(), + idna.StrictDomainName(false), + idna.ValidateLabels(false), +) + +// toUTS46ASCII tries to convert IDNs (international domain names) +// from Unicode form to Punycode, using non-transitional process specified +// in UTS 46. +// +// Note: conversion errors are silently discarded and partial conversion +// results are used. +func toUTS46ASCII(name string) string { + name, _ = nontransitionalLookup.ToASCII(name) + return name +} + +// proxiedRecordsRe is the regular expression for determining if a DNS record +// is proxied or not. +var proxiedRecordsRe = regexp.MustCompile(`(?m)^.*\.\s+1\s+IN\s+CNAME.*$`) + +// proxiedRecordImportTemplate is the multipart template for importing *only* +// proxied records. See `nonProxiedRecordImportTemplate` for importing records +// that are not proxied. +var proxiedRecordImportTemplate = `--------------------------BOUNDARY +Content-Disposition: form-data; name="file"; filename="bind.txt" + +%s +--------------------------BOUNDARY +Content-Disposition: form-data; name="proxied" + +true +--------------------------BOUNDARY--` + +// nonProxiedRecordImportTemplate is the multipart template for importing DNS +// records that are not proxed. For importing proxied records, use +// `proxiedRecordImportTemplate`. +var nonProxiedRecordImportTemplate = `--------------------------BOUNDARY +Content-Disposition: form-data; name="file"; filename="bind.txt" + +%s +--------------------------BOUNDARY--` + +// sanitiseBINDFileInput accepts the BIND file as a string and removes parts +// that are not required for importing or would break the import (like SOA +// records). +func sanitiseBINDFileInput(s string) string { + // Remove SOA records. + soaRe := regexp.MustCompile(`(?m)[\r\n]+^.*IN\s+SOA.*$`) + s = soaRe.ReplaceAllString(s, "") + + // Remove all comments. + commentRe := regexp.MustCompile(`(?m)[\r\n]+^.*;;.*$`) + s = commentRe.ReplaceAllString(s, "") + + // Swap all the tabs to spaces. + r := strings.NewReplacer( + "\t", " ", + "\n\n", "\n", + ) + s = r.Replace(s) + s = strings.TrimSpace(s) + + return s +} + +// extractProxiedRecords accepts a BIND file (as a string) and returns only the +// proxied DNS records. +func extractProxiedRecords(s string) string { + proxiedOnlyRecords := proxiedRecordsRe.FindAllString(s, -1) + return strings.Join(proxiedOnlyRecords, "\n") +} + +// removeProxiedRecords accepts a BIND file (as a string) and returns the file +// contents without any proxied records included. +func removeProxiedRecords(s string) string { + return proxiedRecordsRe.ReplaceAllString(s, "") +} + +type ExportDNSRecordsParams struct{} +type ImportDNSRecordsParams struct { + BINDContents string +} + +type CreateDNSRecordParams struct { + CreatedOn time.Time `json:"created_on,omitempty" url:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty" url:"modified_on,omitempty"` + Type string `json:"type,omitempty" url:"type,omitempty"` + Name string `json:"name,omitempty" url:"name,omitempty"` + Content string `json:"content,omitempty" url:"content,omitempty"` + Meta interface{} `json:"meta,omitempty"` + Data interface{} `json:"data,omitempty"` // data returned by: SRV, LOC + ID string `json:"id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + Priority *uint16 `json:"priority,omitempty"` + TTL int `json:"ttl,omitempty"` + Proxied *bool `json:"proxied,omitempty" url:"proxied,omitempty"` + Proxiable bool `json:"proxiable,omitempty"` + Comment string `json:"comment,omitempty" url:"comment,omitempty"` // to the server, there's no difference between "no comment" and "empty comment" + Tags []string `json:"tags,omitempty"` +} + +// CreateDNSRecord creates a DNS record for the zone identifier. +// +// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record +func (api *API) CreateDNSRecord(ctx context.Context, rc *ResourceContainer, params CreateDNSRecordParams) (DNSRecord, error) { + if rc.Identifier == "" { + return DNSRecord{}, ErrMissingZoneID + } + params.Name = toUTS46ASCII(params.Name) + + uri := fmt.Sprintf("/zones/%s/dns_records", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return DNSRecord{}, err + } + + var recordResp *DNSRecordResponse + err = json.Unmarshal(res, &recordResp) + if err != nil { + return DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return recordResp.Result, nil +} + +// ListDNSRecords returns a slice of DNS records for the given zone identifier. +// +// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records +func (api *API) ListDNSRecords(ctx context.Context, rc *ResourceContainer, params ListDNSRecordsParams) ([]DNSRecord, *ResultInfo, error) { + if rc.Identifier == "" { + return nil, nil, ErrMissingZoneID + } + + params.Name = toUTS46ASCII(params.Name) + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = listDNSRecordsDefaultPageSize + } + + if params.Page < 1 { + params.Page = 1 + } + + var records []DNSRecord + var lastResultInfo ResultInfo + + for { + uri := buildURI(fmt.Sprintf("/zones/%s/dns_records", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DNSRecord{}, &ResultInfo{}, err + } + var listResponse DNSListResponse + err = json.Unmarshal(res, &listResponse) + if err != nil { + return []DNSRecord{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + records = append(records, listResponse.Result...) + lastResultInfo = listResponse.ResultInfo + params.ResultInfo = listResponse.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + return records, &lastResultInfo, nil +} + +// ErrMissingDNSRecordID is for when DNS record ID is needed but not given. +var ErrMissingDNSRecordID = errors.New("required DNS record ID missing") + +// GetDNSRecord returns a single DNS record for the given zone & record +// identifiers. +// +// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-dns-record-details +func (api *API) GetDNSRecord(ctx context.Context, rc *ResourceContainer, recordID string) (DNSRecord, error) { + if rc.Identifier == "" { + return DNSRecord{}, ErrMissingZoneID + } + if recordID == "" { + return DNSRecord{}, ErrMissingDNSRecordID + } + + uri := fmt.Sprintf("/zones/%s/dns_records/%s", rc.Identifier, recordID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DNSRecord{}, err + } + var r DNSRecordResponse + err = json.Unmarshal(res, &r) + if err != nil { + return DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateDNSRecord updates a single DNS record for the given zone & record +// identifiers. +// +// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record +func (api *API) UpdateDNSRecord(ctx context.Context, rc *ResourceContainer, params UpdateDNSRecordParams) (DNSRecord, error) { + if rc.Identifier == "" { + return DNSRecord{}, ErrMissingZoneID + } + + if params.ID == "" { + return DNSRecord{}, ErrMissingDNSRecordID + } + + params.Name = toUTS46ASCII(params.Name) + + uri := fmt.Sprintf("/zones/%s/dns_records/%s", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return DNSRecord{}, err + } + + var recordResp *DNSRecordResponse + err = json.Unmarshal(res, &recordResp) + if err != nil { + return DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return recordResp.Result, nil +} + +// DeleteDNSRecord deletes a single DNS record for the given zone & record +// identifiers. +// +// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record +func (api *API) DeleteDNSRecord(ctx context.Context, rc *ResourceContainer, recordID string) error { + if rc.Identifier == "" { + return ErrMissingZoneID + } + if recordID == "" { + return ErrMissingDNSRecordID + } + + uri := fmt.Sprintf("/zones/%s/dns_records/%s", rc.Identifier, recordID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r DNSRecordResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// ExportDNSRecords returns all DNS records for a zone in the BIND format. +// +// API reference: https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-export-dns-records +func (api *API) ExportDNSRecords(ctx context.Context, rc *ResourceContainer, params ExportDNSRecordsParams) (string, error) { + if rc.Level != ZoneRouteLevel { + return "", ErrRequiredZoneLevelResourceContainer + } + + if rc.Identifier == "" { + return "", ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/dns_records/export", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return "", err + } + + return string(res), nil +} + +// ImportDNSRecords takes the contents of a BIND configuration file and imports +// all records at once. +// +// The current state of the API doesn't allow the proxying field to be +// automatically set on records where the TTL is 1. Instead you need to +// explicitly tell the endpoint which records are proxied in the form data. To +// achieve a simpler abstraction, we do the legwork in the method of making the +// two separate API calls (one for proxied and one for non-proxied) instead of +// making the end user know about this detail. +// +// API reference: https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-import-dns-records +func (api *API) ImportDNSRecords(ctx context.Context, rc *ResourceContainer, params ImportDNSRecordsParams) error { + if rc.Level != ZoneRouteLevel { + return ErrRequiredZoneLevelResourceContainer + } + + if rc.Identifier == "" { + return ErrMissingZoneID + } + + if params.BINDContents == "" { + return ErrMissingBINDContents + } + + sanitisedBindData := sanitiseBINDFileInput(params.BINDContents) + nonProxiedRecords := removeProxiedRecords(sanitisedBindData) + proxiedOnlyRecords := extractProxiedRecords(sanitisedBindData) + + nonProxiedRecordPayload := []byte(fmt.Sprintf(nonProxiedRecordImportTemplate, nonProxiedRecords)) + nonProxiedReqBody := bytes.NewReader(nonProxiedRecordPayload) + + uri := fmt.Sprintf("/zones/%s/dns_records/import", rc.Identifier) + multipartUploadHeaders := http.Header{ + "Content-Type": {"multipart/form-data; boundary=------------------------BOUNDARY"}, + } + + _, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, nonProxiedReqBody, multipartUploadHeaders) + if err != nil { + return err + } + + proxiedRecordPayload := []byte(fmt.Sprintf(proxiedRecordImportTemplate, proxiedOnlyRecords)) + proxiedReqBody := bytes.NewReader(proxiedRecordPayload) + + _, err = api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, proxiedReqBody, multipartUploadHeaders) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/dns_example_test.go b/pkg/cloudflare-go/dns_example_test.go new file mode 100644 index 000000000..e864a16fe --- /dev/null +++ b/pkg/cloudflare-go/dns_example_test.go @@ -0,0 +1,94 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ListDNSRecords_all() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch all records for a zone + recs, _, err := api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{}) + if err != nil { + log.Fatal(err) + } + + for _, r := range recs { + fmt.Printf("%s: %s\n", r.Name, r.Content) + } +} + +func ExampleAPI_ListDNSRecords_filterByContent() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + recs, _, err := api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{Content: "198.51.100.1"}) + if err != nil { + log.Fatal(err) + } + + for _, r := range recs { + fmt.Printf("%s: %s\n", r.Name, r.Content) + } +} + +func ExampleAPI_ListDNSRecords_filterByName() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + recs, _, err := api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{Name: "foo.example.com"}) + if err != nil { + log.Fatal(err) + } + + for _, r := range recs { + fmt.Printf("%s: %s\n", r.Name, r.Content) + } +} + +func ExampleAPI_ListDNSRecords_filterByType() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + recs, _, err := api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{Type: "AAAA"}) + if err != nil { + log.Fatal(err) + } + + for _, r := range recs { + fmt.Printf("%s: %s\n", r.Name, r.Content) + } +} diff --git a/pkg/cloudflare-go/dns_firewall.go b/pkg/cloudflare-go/dns_firewall.go new file mode 100644 index 000000000..090b24c98 --- /dev/null +++ b/pkg/cloudflare-go/dns_firewall.go @@ -0,0 +1,220 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ErrMissingClusterID = errors.New("missing required cluster ID") + +// DNSFirewallCluster represents a DNS Firewall configuration. +type DNSFirewallCluster struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + UpstreamIPs []string `json:"upstream_ips"` + DNSFirewallIPs []string `json:"dns_firewall_ips,omitempty"` + MinimumCacheTTL uint `json:"minimum_cache_ttl,omitempty"` + MaximumCacheTTL uint `json:"maximum_cache_ttl,omitempty"` + DeprecateAnyRequests bool `json:"deprecate_any_requests"` + ModifiedOn string `json:"modified_on,omitempty"` +} + +// DNSFirewallAnalyticsMetrics represents a group of aggregated DNS Firewall metrics. +type DNSFirewallAnalyticsMetrics struct { + QueryCount *int64 `json:"queryCount"` + UncachedCount *int64 `json:"uncachedCount"` + StaleCount *int64 `json:"staleCount"` + ResponseTimeAvg *float64 `json:"responseTimeAvg"` + ResponseTimeMedian *float64 `json:"responseTimeMedian"` + ResponseTime90th *float64 `json:"responseTime90th"` + ResponseTime99th *float64 `json:"responseTime99th"` +} + +// DNSFirewallAnalytics represents a set of aggregated DNS Firewall metrics. +// TODO: Add the queried data and not only the aggregated values. +type DNSFirewallAnalytics struct { + Totals DNSFirewallAnalyticsMetrics `json:"totals"` + Min DNSFirewallAnalyticsMetrics `json:"min"` + Max DNSFirewallAnalyticsMetrics `json:"max"` +} + +// DNSFirewallUserAnalyticsOptions represents range and dimension selection on analytics endpoint. +type DNSFirewallUserAnalyticsOptions struct { + Metrics []string `url:"metrics,omitempty" del:","` + Since *time.Time `url:"since,omitempty"` + Until *time.Time `url:"until,omitempty"` +} + +// dnsFirewallResponse represents a DNS Firewall response. +type dnsFirewallResponse struct { + Response + Result *DNSFirewallCluster `json:"result"` +} + +// dnsFirewallListResponse represents an array of DNS Firewall responses. +type dnsFirewallListResponse struct { + Response + Result []*DNSFirewallCluster `json:"result"` +} + +// dnsFirewallAnalyticsResponse represents a DNS Firewall analytics response. +type dnsFirewallAnalyticsResponse struct { + Response + Result DNSFirewallAnalytics `json:"result"` +} + +type CreateDNSFirewallClusterParams struct { + Name string `json:"name"` + UpstreamIPs []string `json:"upstream_ips"` + DNSFirewallIPs []string `json:"dns_firewall_ips,omitempty"` + MinimumCacheTTL uint `json:"minimum_cache_ttl,omitempty"` + MaximumCacheTTL uint `json:"maximum_cache_ttl,omitempty"` + DeprecateAnyRequests bool `json:"deprecate_any_requests"` +} + +type GetDNSFirewallClusterParams struct { + ClusterID string `json:"-"` +} + +type UpdateDNSFirewallClusterParams struct { + ClusterID string `json:"-"` + Name string `json:"name"` + UpstreamIPs []string `json:"upstream_ips"` + DNSFirewallIPs []string `json:"dns_firewall_ips,omitempty"` + MinimumCacheTTL uint `json:"minimum_cache_ttl,omitempty"` + MaximumCacheTTL uint `json:"maximum_cache_ttl,omitempty"` + DeprecateAnyRequests bool `json:"deprecate_any_requests"` +} + +type ListDNSFirewallClustersParams struct{} + +type GetDNSFirewallUserAnalyticsParams struct { + ClusterID string `json:"-"` + DNSFirewallUserAnalyticsOptions +} + +// CreateDNSFirewallCluster creates a new DNS Firewall cluster. +// +// API reference: https://api.cloudflare.com/#dns-firewall-create-dns-firewall-cluster +func (api *API) CreateDNSFirewallCluster(ctx context.Context, rc *ResourceContainer, params CreateDNSFirewallClusterParams) (*DNSFirewallCluster, error) { + uri := fmt.Sprintf("/%s/dns_firewall", rc.URLFragment()) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return nil, err + } + + response := &dnsFirewallResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// GetDNSFirewallCluster fetches a single DNS Firewall cluster. +// +// API reference: https://api.cloudflare.com/#dns-firewall-dns-firewall-cluster-details +func (api *API) GetDNSFirewallCluster(ctx context.Context, rc *ResourceContainer, params GetDNSFirewallClusterParams) (*DNSFirewallCluster, error) { + if params.ClusterID == "" { + return &DNSFirewallCluster{}, ErrMissingClusterID + } + + uri := fmt.Sprintf("/%s/dns_firewall/%s", rc.URLFragment(), params.ClusterID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + response := &dnsFirewallResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// ListDNSFirewallClusters lists the DNS Firewall clusters associated with an account. +// +// API reference: https://api.cloudflare.com/#dns-firewall-list-dns-firewall-clusters +func (api *API) ListDNSFirewallClusters(ctx context.Context, rc *ResourceContainer, params ListDNSFirewallClustersParams) ([]*DNSFirewallCluster, error) { + uri := fmt.Sprintf("/%s/dns_firewall", rc.URLFragment()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + response := &dnsFirewallListResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// UpdateDNSFirewallCluster updates a DNS Firewall cluster. +// +// API reference: https://api.cloudflare.com/#dns-firewall-update-dns-firewall-cluster +func (api *API) UpdateDNSFirewallCluster(ctx context.Context, rc *ResourceContainer, params UpdateDNSFirewallClusterParams) error { + if params.ClusterID == "" { + return ErrMissingClusterID + } + + uri := fmt.Sprintf("/%s/dns_firewall/%s", rc.URLFragment(), params.ClusterID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return err + } + + response := &dnsFirewallResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// DeleteDNSFirewallCluster deletes a DNS Firewall cluster. Note that this cannot be +// undone, and will stop all traffic to that cluster. +// +// API reference: https://api.cloudflare.com/#dns-firewall-delete-dns-firewall-cluster +func (api *API) DeleteDNSFirewallCluster(ctx context.Context, rc *ResourceContainer, clusterID string) error { + uri := fmt.Sprintf("/%s/dns_firewall/%s", rc.URLFragment(), clusterID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + response := &dnsFirewallResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// GetDNSFirewallUserAnalytics retrieves analytics report for a specified dimension and time range. +func (api *API) GetDNSFirewallUserAnalytics(ctx context.Context, rc *ResourceContainer, params GetDNSFirewallUserAnalyticsParams) (DNSFirewallAnalytics, error) { + uri := buildURI(fmt.Sprintf("/%s/dns_firewall/%s/dns_analytics/report", rc.URLFragment(), params.ClusterID), params.DNSFirewallUserAnalyticsOptions) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DNSFirewallAnalytics{}, err + } + + response := dnsFirewallAnalyticsResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return DNSFirewallAnalytics{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} diff --git a/pkg/cloudflare-go/dns_firewall_test.go b/pkg/cloudflare-go/dns_firewall_test.go new file mode 100644 index 000000000..5c85320cb --- /dev/null +++ b/pkg/cloudflare-go/dns_firewall_test.go @@ -0,0 +1,145 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDNSFirewallUserAnalytics_UserLevel(t *testing.T) { + setup() + defer teardown() + + now := time.Now().UTC() + since := now.Add(-1 * time.Hour) + until := now + + handler := func(w http.ResponseWriter, r *http.Request) { + expectedMetrics := "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedia,responseTime90th,responseTime99th" + + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET'") + assert.Equal(t, expectedMetrics, r.URL.Query().Get("metrics"), "Expected many metrics in URL parameter") + assert.Equal(t, since.Format(time.RFC3339), r.URL.Query().Get("since"), "Expected since parameter in URL") + assert.Equal(t, until.Format(time.RFC3339), r.URL.Query().Get("until"), "Expected until parameter in URL") + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "totals":{ + "queryCount": 5, + "uncachedCount":6, + "staleCount":7, + "responseTimeAvg":1.0, + "responseTimeMedian":2.0, + "responseTime90th":3.0, + "responseTime99th":4.0 + } + }, + "success": true, + "errors": null, + "messages": null + }`) + } + + mux.HandleFunc("/user/dns_firewall/12345/dns_analytics/report", handler) + want := DNSFirewallAnalytics{ + Totals: DNSFirewallAnalyticsMetrics{ + QueryCount: Int64Ptr(5), + UncachedCount: Int64Ptr(6), + StaleCount: Int64Ptr(7), + ResponseTimeAvg: Float64Ptr(1.0), + ResponseTimeMedian: Float64Ptr(2.0), + ResponseTime90th: Float64Ptr(3.0), + ResponseTime99th: Float64Ptr(4.0), + }, + } + + actual, err := client.GetDNSFirewallUserAnalytics(context.Background(), UserIdentifier("foo"), GetDNSFirewallUserAnalyticsParams{ClusterID: "12345", DNSFirewallUserAnalyticsOptions: DNSFirewallUserAnalyticsOptions{ + Metrics: []string{ + "queryCount", + "uncachedCount", + "staleCount", + "responseTimeAvg", + "responseTimeMedia", + "responseTime90th", + "responseTime99th", + }, + Since: &since, + Until: &until, + }}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDNSFirewallUserAnalytics_AccountLevel(t *testing.T) { + setup() + defer teardown() + + now := time.Now().UTC() + since := now.Add(-1 * time.Hour) + until := now + + handler := func(w http.ResponseWriter, r *http.Request) { + expectedMetrics := "queryCount,uncachedCount,staleCount,responseTimeAvg,responseTimeMedia,responseTime90th,responseTime99th" + + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET'") + assert.Equal(t, expectedMetrics, r.URL.Query().Get("metrics"), "Expected many metrics in URL parameter") + assert.Equal(t, since.Format(time.RFC3339), r.URL.Query().Get("since"), "Expected since parameter in URL") + assert.Equal(t, until.Format(time.RFC3339), r.URL.Query().Get("until"), "Expected until parameter in URL") + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "totals":{ + "queryCount": 5, + "uncachedCount":6, + "staleCount":7, + "responseTimeAvg":1.0, + "responseTimeMedian":2.0, + "responseTime90th":3.0, + "responseTime99th":4.0 + } + }, + "success": true, + "errors": null, + "messages": null + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/dns_firewall/12345/dns_analytics/report", handler) + want := DNSFirewallAnalytics{ + Totals: DNSFirewallAnalyticsMetrics{ + QueryCount: Int64Ptr(5), + UncachedCount: Int64Ptr(6), + StaleCount: Int64Ptr(7), + ResponseTimeAvg: Float64Ptr(1.0), + ResponseTimeMedian: Float64Ptr(2.0), + ResponseTime90th: Float64Ptr(3.0), + ResponseTime99th: Float64Ptr(4.0), + }, + } + + actual, err := client.GetDNSFirewallUserAnalytics(context.Background(), AccountIdentifier(testAccountID), GetDNSFirewallUserAnalyticsParams{ClusterID: "12345", DNSFirewallUserAnalyticsOptions: DNSFirewallUserAnalyticsOptions{ + Metrics: []string{ + "queryCount", + "uncachedCount", + "staleCount", + "responseTimeAvg", + "responseTimeMedia", + "responseTime90th", + "responseTime99th", + }, + Since: &since, + Until: &until, + }}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/dns_test.go b/pkg/cloudflare-go/dns_test.go new file mode 100644 index 000000000..43546dcc9 --- /dev/null +++ b/pkg/cloudflare-go/dns_test.go @@ -0,0 +1,700 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_toUTS46ASCII(t *testing.T) { + tests := map[string]struct { + domain string + expected string + }{ + "empty stays empty": { + domain: "", + expected: "", + }, + "unicode gets encoded": { + domain: "😺.com", + expected: "xn--138h.com", + }, + "unicode gets mapped and encoded": { + domain: "ÖBB.at", + expected: "xn--bb-eka.at", + }, + "punycode stays punycode": { + domain: "xn--138h.com", + expected: "xn--138h.com", + }, + "hyphens are not checked": { + domain: "s3--s4.com", + expected: "s3--s4.com", + }, + "STD3 rules are not enforced": { + domain: "℀.com", + expected: "a/c.com", + }, + "bidi check is disabled": { + domain: "englishﻋﺮﺑﻲ.com", + expected: "xn--english-gqjzfwd1j.com", + }, + "invalid joiners are allowed": { + domain: "a\u200cb.com", + expected: "xn--ab-j1t.com", + }, + "partial results are used despite errors": { + domain: "xn--:D.xn--.😺.com", + expected: "xn--:d..xn--138h.com", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + actual := toUTS46ASCII(tt.domain) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestCreateDNSRecord(t *testing.T) { + setup() + defer teardown() + + priority := uint16(10) + proxied := false + asciiInput := DNSRecord{ + Type: "A", + Name: "xn--138h.example.com", + Content: "198.51.100.4", + TTL: 120, + Priority: &priority, + Proxied: &proxied, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + var v DNSRecord + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, asciiInput, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "xn--138h.example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + } + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := DNSRecord{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Type: asciiInput.Type, + Name: asciiInput.Name, + Content: asciiInput.Content, + Proxiable: true, + Proxied: asciiInput.Proxied, + TTL: asciiInput.TTL, + ZoneID: testZoneID, + ZoneName: "example.com", + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Data: map[string]interface{}{}, + Meta: map[string]interface{}{ + "auto_added": true, + "source": "primary", + }, + } + + _, err := client.CreateDNSRecord(context.Background(), ZoneIdentifier(""), CreateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingZoneID) + + actual, err := client.CreateDNSRecord(context.Background(), ZoneIdentifier(testZoneID), CreateDNSRecordParams{ + Type: "A", + Name: "😺.example.com", + Content: "198.51.100.4", + TTL: 120, + Priority: &priority, + Proxied: &proxied}) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestListDNSRecords(t *testing.T) { + setup() + defer teardown() + + asciiInput := DNSRecord{ + Name: "xn--138h.example.com", + Type: "A", + Content: "198.51.100.4", + } + + 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, asciiInput.Name, r.URL.Query().Get("name")) + assert.Equal(t, asciiInput.Type, r.URL.Query().Get("type")) + assert.Equal(t, asciiInput.Content, r.URL.Query().Get("content")) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "xn--138h.example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + } + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records", handler) + + proxied := false + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := []DNSRecord{{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Type: "A", + Name: asciiInput.Name, + Content: asciiInput.Content, + Proxiable: true, + Proxied: &proxied, + TTL: 120, + ZoneID: testZoneID, + ZoneName: "example.com", + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Data: map[string]interface{}{}, + Meta: map[string]interface{}{ + "auto_added": true, + "source": "primary", + }, + }} + + _, _, err := client.ListDNSRecords(context.Background(), ZoneIdentifier(""), ListDNSRecordsParams{}) + assert.ErrorIs(t, err, ErrMissingZoneID) + + actual, _, err := client.ListDNSRecords(context.Background(), ZoneIdentifier(testZoneID), ListDNSRecordsParams{ + Name: "😺.example.com", + Type: "A", + Content: "198.51.100.4", + }) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestListDNSRecordsSearch(t *testing.T) { + setup() + defer teardown() + + recordInput := DNSRecord{ + Name: "example.com", + Type: "A", + Content: "198.51.100.4", + } + + 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, recordInput.Name, r.URL.Query().Get("name")) + assert.Equal(t, recordInput.Type, r.URL.Query().Get("type")) + assert.Equal(t, recordInput.Content, r.URL.Query().Get("content")) + assert.Equal(t, "all", r.URL.Query().Get("match")) + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "type", r.URL.Query().Get("order")) + assert.Equal(t, "asc", r.URL.Query().Get("direction")) + assert.Equal(t, "any", r.URL.Query().Get("tag-match")) + assert.ElementsMatch(t, []string{"tag1", "tag2"}, r.URL.Query()["tag"]) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": true, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "tags": ["tag1", "tag2extended"] + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records", handler) + + proxied := true + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := []DNSRecord{{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Type: "A", + Name: recordInput.Name, + Content: recordInput.Content, + Proxiable: true, + Proxied: &proxied, + TTL: 120, + ZoneID: testZoneID, + ZoneName: "example.com", + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Data: map[string]interface{}{}, + Meta: map[string]interface{}{ + "auto_added": true, + "source": "primary", + }, + Tags: []string{"tag1", "tag2extended"}, + }} + + actual, resultInfo, err := client.ListDNSRecords(context.Background(), ZoneIdentifier(testZoneID), ListDNSRecordsParams{ + ResultInfo: ResultInfo{ + Page: 1, + }, + Match: "all", + Order: "type", + Direction: ListDirectionAsc, + Name: "example.com", + Type: "A", + Content: "198.51.100.4", + TagMatch: "any", + Tags: []string{"tag1", "tag2"}, + }) + require.NoError(t, err) + assert.Equal(t, 1, resultInfo.Total) + + assert.Equal(t, want, actual) +} + +func TestListDNSRecordsPagination(t *testing.T) { + // change listDNSRecordsDefaultPageSize value to 1 to force pagination + listDNSRecordsDefaultPageSize = 3 + + setup() + defer teardown() + + var page1Called, page2Called bool + handler := func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + w.Header().Set("content-type", "application/json") + + var response string + switch page { + case "1": + response = loadFixture("dns", "list_page_1") + page1Called = true + case "2": + response = loadFixture("dns", "list_page_2") + page2Called = true + default: + assert.Failf(t, "Unexpeted page requested: %s", page) + return + } + fmt.Fprint(w, response) + } + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records", handler) + + actual, _, err := client.ListDNSRecords(context.Background(), ZoneIdentifier(testZoneID), ListDNSRecordsParams{}) + require.NoError(t, err) + assert.True(t, page1Called) + assert.True(t, page2Called) + assert.Len(t, actual, 5) + + type ls struct { + Results []map[string]interface{} `json:"result"` + } + + expectedRecords := make(map[string]map[string]interface{}) + + response1 := loadFixture("dns", "list_page_1") + var fixtureDataPage1 ls + err = json.Unmarshal([]byte(response1), &fixtureDataPage1) + assert.NoError(t, err) + for _, record := range fixtureDataPage1.Results { + expectedRecords[record["id"].(string)] = record + } + + response2 := loadFixture("dns", "list_page_2") + var fixtureDataPage2 ls + err = json.Unmarshal([]byte(response2), &fixtureDataPage2) + assert.NoError(t, err) + for _, record := range fixtureDataPage2.Results { + expectedRecords[record["id"].(string)] = record + } + + for _, actualRecord := range actual { + expected, exist := expectedRecords[actualRecord.ID] + assert.True(t, exist, "DNS record doesn't exist in fixtures") + assert.Equal(t, expected["type"].(string), actualRecord.Type) + assert.Equal(t, expected["name"].(string), actualRecord.Name) + assert.Equal(t, expected["content"].(string), actualRecord.Content) + assert.Equal(t, expected["proxiable"].(bool), actualRecord.Proxiable) + assert.Equal(t, expected["proxied"].(bool), *actualRecord.Proxied) + assert.Equal(t, int(expected["ttl"].(float64)), actualRecord.TTL) + assert.Equal(t, expected["zone_id"].(string), actualRecord.ZoneID) + assert.Equal(t, expected["zone_name"].(string), actualRecord.ZoneName) + assert.Equal(t, expected["data"], actualRecord.Data) + assert.Equal(t, expected["meta"], actualRecord.Meta) + } +} + +func TestGetDNSRecord(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "comment": "This is a comment", + "tags": ["tag1", "tag2"] + } + }`) + } + + dnsRecordID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records/"+dnsRecordID, handler) + + proxied := false + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := DNSRecord{ + ID: dnsRecordID, + Type: "A", + Name: "example.com", + Content: "198.51.100.4", + Proxiable: true, + Proxied: &proxied, + TTL: 120, + ZoneID: testZoneID, + ZoneName: "example.com", + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Data: map[string]interface{}{}, + Meta: map[string]interface{}{ + "auto_added": true, + "source": "primary", + }, + Comment: "This is a comment", + Tags: []string{"tag1", "tag2"}, + } + + _, err := client.GetDNSRecord(context.Background(), ZoneIdentifier(""), dnsRecordID) + assert.ErrorIs(t, err, ErrMissingZoneID) + + _, err = client.GetDNSRecord(context.Background(), ZoneIdentifier(testZoneID), "") + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + actual, err := client.GetDNSRecord(context.Background(), ZoneIdentifier(testZoneID), dnsRecordID) + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestUpdateDNSRecord(t *testing.T) { + setup() + defer teardown() + + proxied := false + input := DNSRecord{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Type: "A", + Name: "xn--138h.example.com", + Content: "198.51.100.4", + TTL: 120, + Proxied: &proxied, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + var v DNSRecord + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + v.ID = "372e67954025e0ba6aaa6d586b9e0b59" + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + } + } + }`) + } + + dnsRecordID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records/"+dnsRecordID, handler) + + _, err := client.UpdateDNSRecord(context.Background(), ZoneIdentifier(""), UpdateDNSRecordParams{ID: dnsRecordID}) + assert.ErrorIs(t, err, ErrMissingZoneID) + + _, err = client.UpdateDNSRecord(context.Background(), ZoneIdentifier(testZoneID), UpdateDNSRecordParams{}) + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + _, err = client.UpdateDNSRecord(context.Background(), ZoneIdentifier(testZoneID), UpdateDNSRecordParams{ + ID: dnsRecordID, + Type: "A", + Name: "😺.example.com", + Content: "198.51.100.4", + TTL: 120, + Proxied: &proxied, + }) + require.NoError(t, err) +} + +func TestUpdateDNSRecord_ClearComment(t *testing.T) { + setup() + defer teardown() + + input := DNSRecord{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Comment: "", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + var v DNSRecord + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + v.ID = "372e67954025e0ba6aaa6d586b9e0b59" + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "comment":null, + "tags":[], + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + } + } + }`) + } + + dnsRecordID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records/"+dnsRecordID, handler) + + _, err := client.UpdateDNSRecord(context.Background(), ZoneIdentifier(testZoneID), UpdateDNSRecordParams{ + ID: dnsRecordID, + Comment: StringPtr(""), + }) + require.NoError(t, err) +} + +func TestUpdateDNSRecord_KeepComment(t *testing.T) { + setup() + defer teardown() + + input := DNSRecord{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + var v DNSRecord + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + v.ID = "372e67954025e0ba6aaa6d586b9e0b59" + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "comment":null, + "tags":[], + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + } + } + }`) + } + + dnsRecordID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records/"+dnsRecordID, handler) + + _, err := client.UpdateDNSRecord(context.Background(), ZoneIdentifier(testZoneID), UpdateDNSRecordParams{ + ID: dnsRecordID, + }) + require.NoError(t, err) +} + +func TestDeleteDNSRecord(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59" + } + }`) + } + + dnsRecordID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/dns_records/"+dnsRecordID, handler) + + err := client.DeleteDNSRecord(context.Background(), ZoneIdentifier(""), dnsRecordID) + assert.ErrorIs(t, err, ErrMissingZoneID) + + err = client.DeleteDNSRecord(context.Background(), ZoneIdentifier(testZoneID), "") + assert.ErrorIs(t, err, ErrMissingDNSRecordID) + + err = client.DeleteDNSRecord(context.Background(), ZoneIdentifier(testZoneID), dnsRecordID) + require.NoError(t, err) +} diff --git a/pkg/cloudflare-go/docs/changelog-process.md b/pkg/cloudflare-go/docs/changelog-process.md new file mode 100644 index 000000000..33bc59add --- /dev/null +++ b/pkg/cloudflare-go/docs/changelog-process.md @@ -0,0 +1,96 @@ +## Changelog Process + +We use the [go-changelog](https://github.com/hashicorp/go-changelog) to generate and update the changelog from files created in the `.changelog/` directory. It is important that when you raise your Pull Request, there is a changelog entry which describes the changes your contribution makes. Not all changes require an entry in the CHANGELOG, guidance follows on what changes do. + +### Changelog Format + +The changelog format requires an entry in the following format, where HEADER corresponds to the changelog category, and the entry is the changelog entry itself. The entry should be included in a file in the `.changelog` directory with the naming convention `{PR-NUMBER}.txt`. For example, to create a changelog entry for pull request 1234, there should be a file named `.changelog/1234.txt`. + +````markdown +```release-note:{HEADER} +{ENTRY} +``` +```` + +If a pull request should contain multiple changelog entries, then multiple blocks can be added to the same changelog file. For example: + +````markdown +```release-note:note +foo: The `broken` attribute has been deprecated. All configurations using `broken` should be updated to use the new `not_broken` attribute instead. +``` + +```release-note:enhancement +foo: Add `not_broken` attribute +``` +```` + +### Skipping changelog entries + +In order to skip/pass the automated checks where a CHANGELOG entry is not required, apply the `workflow/skip-changelog-entry` label. + +### Pull Request Types to CHANGELOG + +The CHANGELOG is intended to show operator-impacting changes to the codebase for a particular version. If every change or commit to the code resulted in an entry, the CHANGELOG would become less useful for operators. The lists below are general guidelines and examples for when a decision needs to be made to decide whether a change should have an entry. + +#### Changes that should have a CHANGELOG entry + +##### Resource and provider bug fixes + +A new bug entry should use the `release-note:bug` header and have a prefix indicating the file/service it corresponds to, a colon, then followed by a brief summary. + +````markdown +```release-note:bug +foo: Fix 'thing' being optional +``` +```` + +##### Resource and provider enhancements + +A new enhancement entry should use the `release-note:enhancement` header and have a prefix indicating the file/service it corresponds to, a colon, then followed by a brief summary. + +````markdown +```release-note:enhancement +foo: Add new capability +``` +```` + +##### Deprecations + +A breaking-change entry should use the `release-note:note` header and have a prefix indicating the file/service it corresponds to, a colon, then followed by a brief summary. + +````markdown +```release-note:note +foo: X attribute is being deprecated in favor of the new Y attribute +``` +```` + +##### Breaking Changes and Removals + +A breaking-change entry should use the `release-note:breaking-change` header and have a prefix indicating the file/service it corresponds to, a colon, then followed by a brief summary. + +````markdown +```release-note:breaking-change +foo: Resource no longer works for 'EXAMPLE' parameters +``` +```` + +#### Changes that may have a CHANGELOG entry + +Dependency updates: If the update contains relevant bug fixes or enhancements that affect operators, those should be called out. +Any changes which do not fit into the above categories but warrant highlighting. + +````markdown +```release-note:note +foo: Example resource now does X slightly differently +``` + +```release-note:dependency +`foo` v0.1.0 => v0.1.1 +``` +```` + +#### Changes that should _not_ have a CHANGELOG entry + +- Resource and provider documentation updates +- Testing updates +- Code refactoring (context dependant) diff --git a/pkg/cloudflare-go/docs/conventions.md b/pkg/cloudflare-go/docs/conventions.md new file mode 100644 index 000000000..acbe2fabb --- /dev/null +++ b/pkg/cloudflare-go/docs/conventions.md @@ -0,0 +1,40 @@ +# Conventions + +This document aims to cover the conventions and guidance to consider when +making changes the Go SDK. + +## Methods + +- All methods should take a maximum of 3 parameter. See examples in [experimental](./experimental.md) +- The first parameter is always `context.Context`. +- The second is a `*ResourceContainer`. +- The final is a struct of available parameters for the method. + - The parameter naming convention should be `Params`. Example: + method name of `GetDNSRecords` has a struct parameter name of + `GetDNSRecordsParams`. + - Do not share parameter structs between methods. Each should have a dedicated + one. + - Even if you don't intend to have parameter configurations, you should add + the third parameter to your method signature for future flexibility. + +## Types + +### Booleans + +- Should always be represented as pointers in structs with an `omitempty` + marshaling tag (most commonly as JSON). This ensures you can determine unset, + false and truthy values. + +### `time.Time` + +- Should always be represented as pointers in structs. + +### Ports (0-65535) + +- Should use `uint16` unless you have a reason to restrict the port range in + which case, you should also provide a validator on the type. + +## Marshaling/unmarshaling + +- Avoid custom marshal/unmarshal handlers unless absolutely necessary. They can + be difficult to debug in a larger codebase. diff --git a/pkg/cloudflare-go/docs/experimental.md b/pkg/cloudflare-go/docs/experimental.md new file mode 100644 index 000000000..5ed9b308c --- /dev/null +++ b/pkg/cloudflare-go/docs/experimental.md @@ -0,0 +1,119 @@ +# README (experimental) + +An experimental and incremental update of the library focusing on a more modern +and consistent experience. + +## Improvements + +### Automatically paginate `List` operations by default + +`List()` methods will automatically paginate all resources **unless** +`PerPage` or `Page` is supplied as a part of the `$entityListParams`. + +This allows us the best of both worlds where if you need to explicitly +override the inbuilt pagination, you have the ability to. + +## Nested methods and services + +Not all methods are defined at the top level. Instead, they are nested under +service objects. + +```golang +// old +client.ListZones(...) +client.ZoneLevelAccessServiceTokens(...) + +// new +client.Zones.List() +client.Access.ServiceTokens(...) +``` + +This avoids polluting the global namespace and having more specific methods +for services. + +### Consistent CRUD method signatures + +Majority of methods on an entity will follow a standard method signature. + +| Signature | Purpose | Return value | +| ------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `Get(ctx, *ResourceContainer, $entityID) ($entity, error)` | Fetches a single entity by a `$entityID`. | Returns the entity and `error` based on the listing parameters. | +| `List(ctx, *ResourceContainer, params) ([]$entity, ResultInfo, error)` | Fetches all entities. | Returns the list of matching entities, the result information (pagination, fail/success and additional metadata), and `error`. | +| `New(ctx, *ResourceContainer, params) ($entity, error)` | Creates a new entity with the provided parameters. | Returns the newly created entity and `error`. | +| `Update(ctx, *ResourceContainer, params) ($entity, error)` | Updates an existing entity. | Returns the updated entity and `error`. | +| `Delete(ctx, *ResourceContainer, $entityID) (error)` | Deletes a single entity by a `$entityID`. | Returns `error`. | + +- `*ResourceContainer` determines the "level" of the resource and where it will + operate at. Operated using `UserIdentifier`, `ZoneIdentifier`, and + `AccountIdentifier` respectively. +- `$entityID` is the resource identifier. +- `params` is a complex structure that allows filtering/finding resources + matching the struct fields. By providing a structure as the third argument + in all the methods that require it, we can add/remove fields without the + need for a breaking change and instead can issue deprecation notices when + specific fields are used. +- `$entity` the resource being operated on. + +Exceptions to this convention will be: + +- Methods outside of CRUD operations +- Top level level concepts such as `Accounts` and `Zones` + +#### Examples + +`DNSRecord` is used below for the examples however, all entites will implement the +same methods and interfaces. + +```go +params := cloudflare.ClientParams{ + Key: "3bc3be114fb6323adc5b0ad7422d193a", + Email: "someone@example.com", + HTTPClient: myCustomHTTPClient, + // ... +} +c, err := cloudflare.NewExperimental(params) +``` + +**Create a new DNS record** + +```go +dParams := &cloudflare.DNSRecordParams{ + Name: "@", + Content: "foo.example.com", + TTL: 300, +} +r, _ := c.DNSRecord.New(context.TODO(), cloudflare.ZoneIdentifier("b026324c6904b2a9cb4b88d6d61c81d1"), dParams) +``` + +**Fetching a known DNS record by ID** + +```go +r, _ := c.DNSRecord.Get(context.TODO(), cloudflare.ZoneIdentifier("b026324c6904b2a9cb4b88d6d61c81d1"), "3e7705498e8be60520841409ebc69bc1") +``` + +**Listing all records matching a single account ID (filter option)** + +```go +dParams := &cloudflare.DNSRecordListParams{ + AccountID: "d8e8fca2dc0f896fd7cb4cb0031ba249" +} +r, _, _ := c.DNSRecord.List(context.TODO(), dParams) +``` + +**Update an existing DNS record** + +```go +dParams := &cloudflare.DNSRecordParams{ + ID: "b5163cf270a3fbac34827c4a2713eef4", + Name: "@", + Content: "bar.example.com", + TTL: 300, +} +r, _ := c.DNSRecord.Update(context.TODO(), cloudflare.ZoneIdentifier("b026324c6904b2a9cb4b88d6d61c81d1"), dParams) +``` + +**Delete a DNS Record** + +```go +r, _ := c.DNSRecord.Delete(context.TODO(), cloudflare.ZoneIdentifier("b026324c6904b2a9cb4b88d6d61c81d1"), "b5163cf270a3fbac34827c4a2713eef4") +``` diff --git a/pkg/cloudflare-go/docs/public-api-documentation.md b/pkg/cloudflare-go/docs/public-api-documentation.md new file mode 100644 index 000000000..a2b2c4b35 --- /dev/null +++ b/pkg/cloudflare-go/docs/public-api-documentation.md @@ -0,0 +1,28 @@ +## Why is my PR labelled with `workflow/pending-public-api`? + +To ensure the public SDKs (including the [Cloudflare Terraform Provider]) are +only using stable and intentionally exposed endpoints, we require that all API +functionality is backed by public documentation. This can either be +api.cloudflare.com or developers.cloudflare.com depending on the delivery medium. + +## But, wrangler/cloudflared/other project doesn't require public documentation? + +On occasion, Cloudflare teams release functionality or tooling specific to the +systems they are responsible for. [Wrangler] and [cloudflared] are two prominent +examples of this. In these situations, the teams may choose to use unstable or +undocumented endpoints as they are able to maintain both internal and external +compatibility for these tools should something need to change without notice or +a deprecation period. + +Unfortunately, the SDKs are not in the same position and cannot make the same +guarantees externally due to being an interface for external integrations; not +an abstraction of the functionality. By only accepting documented API endpoints +into the SDKs, we establish an API contract with the service teams that ensures +consumers have a reliable and consistent experience when using them. Should an +API contract be broken, or need fixing, the service team will be responsible to +maintain it in such a time that a deprecation notice is issued and integrations +have a migration period. + +[cloudflare terraform provider]: https://github.com/cloudflare/terraform-provider-cloudflare/ +[wrangler]: https://github.com/cloudflare/wrangler2 +[cloudflared]: https://github.com/cloudflare/cloudflared diff --git a/pkg/cloudflare-go/docs/release-process.md b/pkg/cloudflare-go/docs/release-process.md new file mode 100644 index 000000000..d2ae372d8 --- /dev/null +++ b/pkg/cloudflare-go/docs/release-process.md @@ -0,0 +1,40 @@ +## Release Process + +We aim to release on a fortnightly cadence, alternating weeks with the [terraform-provider-cloudflare](https://github.com/cloudflare/terraform-provider-cloudflare). + +This is to accommodate downstream tools and allow changes from this library to +be used in the other systems without a month long delay. + +To determine when the next release is due, you can either: + +- Review the latest [releases](https://github.com/cloudflare/cloudflare-go/releases); or +- Review the [current milestones](https://github.com/cloudflare/cloudflare-go/milestones). + +If a hotfix is needed, the same process outlined below is used however only the +semantic versioning patch version is bumped. + +- Ensure CI is passing for [`master` branch](https://github.com/cloudflare/cloudflare-go/actions?query=branch%3Amaster). +- Remove "(Unreleased)" portion from the header for the version you are intending + to release (here, 2.27.0). Create a new H2 above for the next unreleased + version (here 2.28.0). Example diff: + + ```diff + + ## 2.28.0 (Unreleased) + + + ## 2.27.0 + - ## 2.27.0 (Unreleased) + + NOTES: + + * dependency: Update foo to v0.0.2 ([#1184](https://github.com/cloudflare/cloudflare-go/issues/123)) + ``` + + Bumping the minor version is usually fine here unless you are intending on + releasing a major version bump. + +- Create a new GitHub release with the release title exactly matching the tag + (e.g. `v2.27.0`) and copy the entries from the CHANGELOG to the release notes. +- A GitHub Action will now build the binaries, documentation and create the release. +- Once this is completed, close off the milestone for the current release and + open the next that matches the CHANGELOG additions from earlier. Example: close + v2.27.0 but open a v2.28.0. diff --git a/pkg/cloudflare-go/duration.go b/pkg/cloudflare-go/duration.go new file mode 100644 index 000000000..2e39d5ed4 --- /dev/null +++ b/pkg/cloudflare-go/duration.go @@ -0,0 +1,41 @@ +package cloudflare + +import ( + "time" + + "github.com/goccy/go-json" +) + +// Duration implements json.Marshaler and json.Unmarshaler for time.Duration +// using the fmt.Stringer interface of time.Duration and time.ParseDuration. +type Duration struct { + time.Duration +} + +// MarshalJSON encodes a Duration as a JSON string formatted using String. +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration.String()) +} + +// UnmarshalJSON decodes a Duration from a JSON string parsed using time.ParseDuration. +func (d *Duration) UnmarshalJSON(buf []byte) error { + var str string + + err := json.Unmarshal(buf, &str) + if err != nil { + return err + } + + dur, err := time.ParseDuration(str) + if err != nil { + return err + } + + d.Duration = dur + return nil +} + +var ( + _ = json.Marshaler((*Duration)(nil)) + _ = json.Unmarshaler((*Duration)(nil)) +) diff --git a/pkg/cloudflare-go/duration_test.go b/pkg/cloudflare-go/duration_test.go new file mode 100644 index 000000000..c18695588 --- /dev/null +++ b/pkg/cloudflare-go/duration_test.go @@ -0,0 +1,28 @@ +package cloudflare + +import ( + "fmt" + "time" + + "github.com/goccy/go-json" +) + +func ExampleDuration() { + d := Duration{1 * time.Second} + fmt.Println(d) + + buf, err := json.Marshal(d) + fmt.Println(string(buf), err) + + err = json.Unmarshal([]byte(`"5s"`), &d) + fmt.Println(d, err) + + d.Duration += time.Second + fmt.Println(d, err) + + // Output: + // 1s + // "1s" + // 5s + // 6s +} diff --git a/pkg/cloudflare-go/email_routing_destination.go b/pkg/cloudflare-go/email_routing_destination.go new file mode 100644 index 000000000..3ad0284dc --- /dev/null +++ b/pkg/cloudflare-go/email_routing_destination.go @@ -0,0 +1,156 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type EmailRoutingDestinationAddress struct { + Tag string `json:"tag,omitempty"` + Email string `json:"email,omitempty"` + Verified *time.Time `json:"verified,omitempty"` + Created *time.Time `json:"created,omitempty"` + Modified *time.Time `json:"modified,omitempty"` +} + +type ListEmailRoutingAddressParameters struct { + ResultInfo + Direction string `url:"direction,omitempty"` + Verified *bool `url:"verified,omitempty"` +} + +type ListEmailRoutingAddressResponse struct { + Result []EmailRoutingDestinationAddress `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +type CreateEmailRoutingAddressParameters struct { + Email string `json:"email,omitempty"` +} + +type CreateEmailRoutingAddressResponse struct { + Result EmailRoutingDestinationAddress `json:"result,omitempty"` + Response +} + +// ListEmailRoutingDestinationAddresses Lists existing destination addresses. +// +// API reference: https://api.cloudflare.com/#email-routing-destination-addresses-list-destination-addresses +func (api *API) ListEmailRoutingDestinationAddresses(ctx context.Context, rc *ResourceContainer, params ListEmailRoutingAddressParameters) ([]EmailRoutingDestinationAddress, *ResultInfo, error) { + if rc.Identifier == "" { + return []EmailRoutingDestinationAddress{}, &ResultInfo{}, ErrMissingAccountID + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 50 + } + + if params.Page < 1 { + params.Page = 1 + } + + var addresses []EmailRoutingDestinationAddress + var eResponse ListEmailRoutingAddressResponse + for { + eResponse = ListEmailRoutingAddressResponse{} + uri := buildURI(fmt.Sprintf("/accounts/%s/email/routing/addresses", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []EmailRoutingDestinationAddress{}, &ResultInfo{}, err + } + err = json.Unmarshal(res, &eResponse) + if err != nil { + return []EmailRoutingDestinationAddress{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + addresses = append(addresses, eResponse.Result...) + params.ResultInfo = eResponse.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return addresses, &eResponse.ResultInfo, nil +} + +// CreateEmailRoutingDestinationAddress Create a destination address to forward your emails to. +// Destination addresses need to be verified before they become active. +// +// API reference: https://api.cloudflare.com/#email-routing-destination-addresses-create-a-destination-address +func (api *API) CreateEmailRoutingDestinationAddress(ctx context.Context, rc *ResourceContainer, params CreateEmailRoutingAddressParameters) (EmailRoutingDestinationAddress, error) { + if rc.Identifier == "" { + return EmailRoutingDestinationAddress{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/email/routing/addresses", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return EmailRoutingDestinationAddress{}, err + } + + var r CreateEmailRoutingAddressResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingDestinationAddress{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// GetEmailRoutingDestinationAddress Gets information for a specific destination email already created. +// +// API reference: https://api.cloudflare.com/#email-routing-destination-addresses-get-a-destination-address +func (api *API) GetEmailRoutingDestinationAddress(ctx context.Context, rc *ResourceContainer, addressID string) (EmailRoutingDestinationAddress, error) { + if rc.Identifier == "" { + return EmailRoutingDestinationAddress{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/email/routing/addresses/%s", rc.Identifier, addressID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return EmailRoutingDestinationAddress{}, err + } + + var r CreateEmailRoutingAddressResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingDestinationAddress{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteEmailRoutingDestinationAddress Deletes a specific destination address. +// +// API reference: https://api.cloudflare.com/#email-routing-destination-addresses-delete-destination-address +func (api *API) DeleteEmailRoutingDestinationAddress(ctx context.Context, rc *ResourceContainer, addressID string) (EmailRoutingDestinationAddress, error) { + if rc.Identifier == "" { + return EmailRoutingDestinationAddress{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/email/routing/addresses/%s", rc.Identifier, addressID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return EmailRoutingDestinationAddress{}, err + } + + var r CreateEmailRoutingAddressResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingDestinationAddress{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} diff --git a/pkg/cloudflare-go/email_routing_destination_test.go b/pkg/cloudflare-go/email_routing_destination_test.go new file mode 100644 index 000000000..b4451d366 --- /dev/null +++ b/pkg/cloudflare-go/email_routing_destination_test.go @@ -0,0 +1,171 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testEmailID = "ea95132c15732412d22c1476fa83f27a" + +func createTestDestinationAddress() EmailRoutingDestinationAddress { + verified, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + created, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + modified, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + return EmailRoutingDestinationAddress{ + Tag: testEmailID, + Email: "user@example.com", + Verified: &verified, + Created: &created, + Modified: &modified, + } +} + +func TestEmailRouting_ListDestinationAddress(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/email/routing/addresses", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "tag": "ea95132c15732412d22c1476fa83f27a", + "email": "user@example.com", + "verified": "2014-01-02T02:20:00Z", + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } +}`) + }) + + _, _, err := client.ListEmailRoutingDestinationAddresses(context.Background(), AccountIdentifier(""), ListEmailRoutingAddressParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := createTestDestinationAddress() + + res, resInfo, err := client.ListEmailRoutingDestinationAddresses(context.Background(), AccountIdentifier(testAccountID), ListEmailRoutingAddressParameters{}) + if assert.NoError(t, err) { + assert.Equal(t, resInfo.Page, 1) + assert.Equal(t, want, res[0]) + } +} + +func TestEmailRouting_CreateDestinationAddress(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/email/routing/addresses", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "ea95132c15732412d22c1476fa83f27a", + "email": "user@example.com", + "verified": "2014-01-02T02:20:00Z", + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z" + } +}`) + }) + + _, err := client.CreateEmailRoutingDestinationAddress(context.Background(), AccountIdentifier(""), CreateEmailRoutingAddressParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := createTestDestinationAddress() + + res, err := client.CreateEmailRoutingDestinationAddress(context.Background(), AccountIdentifier(testAccountID), CreateEmailRoutingAddressParameters{Email: "user@example.com"}) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestEmailRouting_GetDestinationAddress(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/email/routing/addresses/"+testEmailID, 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "ea95132c15732412d22c1476fa83f27a", + "email": "user@example.com", + "verified": "2014-01-02T02:20:00Z", + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z" + } +}`) + }) + + _, err := client.GetEmailRoutingDestinationAddress(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := createTestDestinationAddress() + + res, err := client.GetEmailRoutingDestinationAddress(context.Background(), AccountIdentifier(testAccountID), testEmailID) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestEmailRouting_DeleteDestinationAddress(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/email/routing/addresses/"+testEmailID, 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "ea95132c15732412d22c1476fa83f27a", + "email": "user@example.com", + "verified": "2014-01-02T02:20:00Z", + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z" + } +}`) + }) + + _, err := client.DeleteEmailRoutingDestinationAddress(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := createTestDestinationAddress() + + res, err := client.DeleteEmailRoutingDestinationAddress(context.Background(), AccountIdentifier(testAccountID), testEmailID) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} diff --git a/pkg/cloudflare-go/email_routing_rules.go b/pkg/cloudflare-go/email_routing_rules.go new file mode 100644 index 000000000..736200d04 --- /dev/null +++ b/pkg/cloudflare-go/email_routing_rules.go @@ -0,0 +1,274 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +var ErrMissingRuleID = errors.New("required rule id missing") + +type EmailRoutingRuleMatcher struct { + Type string `json:"type,omitempty"` + Field string `json:"field,omitempty"` + Value string `json:"value,omitempty"` +} + +type EmailRoutingRuleAction struct { + Type string `json:"type,omitempty"` + Value []string `json:"value,omitempty"` +} + +type EmailRoutingRule struct { + Tag string `json:"tag,omitempty"` + Name string `json:"name,omitempty"` + Priority int `json:"priority,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Matchers []EmailRoutingRuleMatcher `json:"matchers,omitempty"` + Actions []EmailRoutingRuleAction `json:"actions,omitempty"` +} + +type ListEmailRoutingRulesParameters struct { + Enabled *bool `url:"enabled,omitempty"` + ResultInfo +} + +type ListEmailRoutingRuleResponse struct { + Result []EmailRoutingRule `json:"result"` + ResultInfo `json:"result_info,omitempty"` + Response +} + +type CreateEmailRoutingRuleParameters struct { + Matchers []EmailRoutingRuleMatcher `json:"matchers,omitempty"` + Actions []EmailRoutingRuleAction `json:"actions,omitempty"` + Name string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Priority int `json:"priority,omitempty"` +} + +type CreateEmailRoutingRuleResponse struct { + Result EmailRoutingRule `json:"result"` + Response +} + +type GetEmailRoutingRuleResponse struct { + Result EmailRoutingRule `json:"result"` + Response +} + +type UpdateEmailRoutingRuleParameters struct { + Matchers []EmailRoutingRuleMatcher `json:"matchers,omitempty"` + Actions []EmailRoutingRuleAction `json:"actions,omitempty"` + Name string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Priority int `json:"priority,omitempty"` + RuleID string +} + +type EmailRoutingCatchAllRule struct { + Tag string `json:"tag,omitempty"` + Name string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Matchers []EmailRoutingRuleMatcher `json:"matchers,omitempty"` + Actions []EmailRoutingRuleAction `json:"actions,omitempty"` +} + +type EmailRoutingCatchAllRuleResponse struct { + Result EmailRoutingCatchAllRule `json:"result"` + Response +} + +// ListEmailRoutingRules Lists existing routing rules. +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-list-routing-rules +func (api *API) ListEmailRoutingRules(ctx context.Context, rc *ResourceContainer, params ListEmailRoutingRulesParameters) ([]EmailRoutingRule, *ResultInfo, error) { + if rc.Identifier == "" { + return []EmailRoutingRule{}, &ResultInfo{}, ErrMissingZoneID + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var rules []EmailRoutingRule + var rResponse ListEmailRoutingRuleResponse + for { + rResponse = ListEmailRoutingRuleResponse{} + uri := buildURI(fmt.Sprintf("/zones/%s/email/routing/rules", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []EmailRoutingRule{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &rResponse) + if err != nil { + return []EmailRoutingRule{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + rules = append(rules, rResponse.Result...) + params.ResultInfo = rResponse.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return rules, &rResponse.ResultInfo, nil +} + +// CreateEmailRoutingRule Rules consist of a set of criteria for matching emails (such as an email being sent to a specific custom email address) plus a set of actions to take on the email (like forwarding it to a specific destination address). +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-create-routing-rule +func (api *API) CreateEmailRoutingRule(ctx context.Context, rc *ResourceContainer, params CreateEmailRoutingRuleParameters) (EmailRoutingRule, error) { + if rc.Identifier == "" { + return EmailRoutingRule{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/email/routing/rules", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return EmailRoutingRule{}, err + } + + var r CreateEmailRoutingRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// GetEmailRoutingRule Get information for a specific routing rule already created. +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-get-routing-rule +func (api *API) GetEmailRoutingRule(ctx context.Context, rc *ResourceContainer, ruleID string) (EmailRoutingRule, error) { + if rc.Identifier == "" { + return EmailRoutingRule{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/email/routing/rules/%s", rc.Identifier, ruleID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return EmailRoutingRule{}, err + } + + var r GetEmailRoutingRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateEmailRoutingRule Update actions, matches, or enable/disable specific routing rules +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-update-routing-rule +func (api *API) UpdateEmailRoutingRule(ctx context.Context, rc *ResourceContainer, params UpdateEmailRoutingRuleParameters) (EmailRoutingRule, error) { + if rc.Identifier == "" { + return EmailRoutingRule{}, ErrMissingZoneID + } + + if params.RuleID == "" { + return EmailRoutingRule{}, ErrMissingRuleID + } + + uri := fmt.Sprintf("/zones/%s/email/routing/rules/%s", rc.Identifier, params.RuleID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return EmailRoutingRule{}, err + } + + var r GetEmailRoutingRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteEmailRoutingRule Delete a specific routing rule. +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-delete-routing-rule +func (api *API) DeleteEmailRoutingRule(ctx context.Context, rc *ResourceContainer, ruleID string) (EmailRoutingRule, error) { + if rc.Identifier == "" { + return EmailRoutingRule{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/email/routing/rules/%s", rc.Identifier, ruleID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return EmailRoutingRule{}, err + } + + var r GetEmailRoutingRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// GetEmailRoutingCatchAllRule Get information on the default catch-all routing rule. +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-get-catch-all-rule +func (api *API) GetEmailRoutingCatchAllRule(ctx context.Context, rc *ResourceContainer) (EmailRoutingCatchAllRule, error) { + if rc.Identifier == "" { + return EmailRoutingCatchAllRule{}, ErrMissingZoneID + } + uri := fmt.Sprintf("/zones/%s/email/routing/rules/catch_all", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return EmailRoutingCatchAllRule{}, err + } + + var r EmailRoutingCatchAllRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingCatchAllRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateEmailRoutingCatchAllRule Enable or disable catch-all routing rule, or change action to forward to specific destination address. +// +// API reference: https://api.cloudflare.com/#email-routing-routing-rules-update-catch-all-rule +func (api *API) UpdateEmailRoutingCatchAllRule(ctx context.Context, rc *ResourceContainer, params EmailRoutingCatchAllRule) (EmailRoutingCatchAllRule, error) { + if rc.Identifier == "" { + return EmailRoutingCatchAllRule{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/email/routing/rules/catch_all", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return EmailRoutingCatchAllRule{}, err + } + + var r EmailRoutingCatchAllRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingCatchAllRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} diff --git a/pkg/cloudflare-go/email_routing_rules_test.go b/pkg/cloudflare-go/email_routing_rules_test.go new file mode 100644 index 000000000..5a3cdb4d7 --- /dev/null +++ b/pkg/cloudflare-go/email_routing_rules_test.go @@ -0,0 +1,395 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var testEmailRoutingRule = EmailRoutingRule{ + Tag: "a7e6fb77503c41d8a7f3113c6918f10c", + Name: "Rule send to user@example.net", + Priority: 0, + Enabled: BoolPtr(true), + Matchers: []EmailRoutingRuleMatcher{ + { + Type: "literal", + Field: "to", + Value: "test@example.com", + }, + }, + Actions: []EmailRoutingRuleAction{ + { + Type: "forward", + Value: []string{"destinationaddress@example.net"}, + }, + }, +} + +func TestEmailRouting_ListRoutingRules(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Rule send to user@example.net", + "priority": 0, + "enabled": true, + "matchers": [ + { + "type": "literal", + "field": "to", + "value": "test@example.com" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } +}`) + }) + + _, _, err := client.ListEmailRoutingRules(context.Background(), AccountIdentifier(""), ListEmailRoutingRulesParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + res, resInfo, err := client.ListEmailRoutingRules(context.Background(), AccountIdentifier(testZoneID), ListEmailRoutingRulesParameters{Enabled: BoolPtr(true)}) + if assert.NoError(t, err) { + assert.Equal(t, resInfo.Page, 1) + assert.Equal(t, testEmailRoutingRule, res[0]) + } +} + +func TestEmailRouting_CreateRoutingRule(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Rule send to user@example.net", + "priority": 0, + "enabled": true, + "matchers": [ + { + "type": "literal", + "field": "to", + "value": "test@example.com" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ] + } +}`) + }) + + _, err := client.CreateEmailRoutingRule(context.Background(), AccountIdentifier(""), CreateEmailRoutingRuleParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + res, err := client.CreateEmailRoutingRule(context.Background(), AccountIdentifier(testZoneID), CreateEmailRoutingRuleParameters{Enabled: BoolPtr(true)}) + if assert.NoError(t, err) { + assert.Equal(t, testEmailRoutingRule, res) + } +} + +func TestEmailRouting_GetRoutingRule(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules/a7e6fb77503c41d8a7f3113c6918f10c", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Rule send to user@example.net", + "priority": 0, + "enabled": true, + "matchers": [ + { + "type": "literal", + "field": "to", + "value": "test@example.com" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ] + } +}`) + }) + + _, err := client.GetEmailRoutingRule(context.Background(), ZoneIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + res, err := client.GetEmailRoutingRule(context.Background(), AccountIdentifier(testZoneID), "a7e6fb77503c41d8a7f3113c6918f10c") + if assert.NoError(t, err) { + assert.Equal(t, testEmailRoutingRule, res) + } +} + +func TestEmailRouting_UpdateRoutingRule(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules/a7e6fb77503c41d8a7f3113c6918f10c", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Rule send to user@example.net", + "priority": 0, + "enabled": true, + "matchers": [ + { + "type": "literal", + "field": "to", + "value": "test@example.com" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ] + } +}`) + }) + + _, err := client.UpdateEmailRoutingRule(context.Background(), ZoneIdentifier(""), UpdateEmailRoutingRuleParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + _, err = client.UpdateEmailRoutingRule(context.Background(), ZoneIdentifier(testZoneID), UpdateEmailRoutingRuleParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingRuleID, err) + } + + res, err := client.UpdateEmailRoutingRule(context.Background(), AccountIdentifier(testZoneID), UpdateEmailRoutingRuleParameters{RuleID: "a7e6fb77503c41d8a7f3113c6918f10c"}) + if assert.NoError(t, err) { + assert.Equal(t, testEmailRoutingRule, res) + } +} + +func TestEmailRouting_DeleteRoutingRule(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules/a7e6fb77503c41d8a7f3113c6918f10c", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Rule send to user@example.net", + "priority": 0, + "enabled": true, + "matchers": [ + { + "type": "literal", + "field": "to", + "value": "test@example.com" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ] + } +}`) + }) + + _, err := client.DeleteEmailRoutingRule(context.Background(), ZoneIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + res, err := client.DeleteEmailRoutingRule(context.Background(), AccountIdentifier(testZoneID), "a7e6fb77503c41d8a7f3113c6918f10c") + if assert.NoError(t, err) { + assert.Equal(t, testEmailRoutingRule, res) + } +} + +func TestEmailRouting_GetAllRule(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules/catch_all", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Send to user@example.net rule.", + "matchers": [ + { + "type": "all" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ], + "enabled": true, + "priority": 2147483647 + }, + "success": true, + "errors": [], + "messages": [] +}`) + }) + + _, err := client.GetEmailRoutingCatchAllRule(context.Background(), ZoneIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + want := EmailRoutingCatchAllRule{ + Tag: "a7e6fb77503c41d8a7f3113c6918f10c", + Name: "Send to user@example.net rule.", + Enabled: BoolPtr(true), + Matchers: []EmailRoutingRuleMatcher{ + { + Type: "all", + }, + }, + Actions: []EmailRoutingRuleAction{ + { + Type: "forward", + Value: []string{"destinationaddress@example.net"}, + }, + }, + } + + res, err := client.GetEmailRoutingCatchAllRule(context.Background(), AccountIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestEmailRouting_UpdateAllRule(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/rules/catch_all", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "tag": "a7e6fb77503c41d8a7f3113c6918f10c", + "name": "Send to user@example.net rule.", + "matchers": [ + { + "type": "all" + } + ], + "actions": [ + { + "type": "forward", + "value": [ + "destinationaddress@example.net" + ] + } + ], + "enabled": true, + "priority": 2147483647 + }, + "success": true, + "errors": [], + "messages": [] +}`) + }) + + _, err := client.UpdateEmailRoutingCatchAllRule(context.Background(), ZoneIdentifier(""), EmailRoutingCatchAllRule{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + want := EmailRoutingCatchAllRule{ + Tag: "a7e6fb77503c41d8a7f3113c6918f10c", + Name: "Send to user@example.net rule.", + Enabled: BoolPtr(true), + Matchers: []EmailRoutingRuleMatcher{ + { + Type: "all", + }, + }, + Actions: []EmailRoutingRuleAction{ + { + Type: "forward", + Value: []string{"destinationaddress@example.net"}, + }, + }, + } + + res, err := client.UpdateEmailRoutingCatchAllRule(context.Background(), AccountIdentifier(testZoneID), EmailRoutingCatchAllRule{}) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} diff --git a/pkg/cloudflare-go/email_routing_settings.go b/pkg/cloudflare-go/email_routing_settings.go new file mode 100644 index 000000000..20fc3eaf8 --- /dev/null +++ b/pkg/cloudflare-go/email_routing_settings.go @@ -0,0 +1,118 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type EmailRoutingSettings struct { + Tag string `json:"tag,omitempty"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Created *time.Time `json:"created,omitempty"` + Modified *time.Time `json:"modified,omitempty"` + SkipWizard *bool `json:"skip_wizard,omitempty"` + Status string `json:"status,omitempty"` +} + +type EmailRoutingSettingsResponse struct { + Result EmailRoutingSettings `json:"result,omitempty"` + Response +} + +type EmailRoutingDNSSettingsResponse struct { + Result []DNSRecord `json:"result,omitempty"` + Response +} + +// GetEmailRoutingSettings Get information about the settings for your Email Routing zone. +// +// API reference: https://api.cloudflare.com/#email-routing-settings-get-email-routing-settings +func (api *API) GetEmailRoutingSettings(ctx context.Context, rc *ResourceContainer) (EmailRoutingSettings, error) { + if rc.Identifier == "" { + return EmailRoutingSettings{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/email/routing", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return EmailRoutingSettings{}, err + } + + var r EmailRoutingSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// EnableEmailRouting Enable you Email Routing zone. Add and lock the necessary MX and SPF records. +// +// API reference: https://api.cloudflare.com/#email-routing-settings-enable-email-routing +func (api *API) EnableEmailRouting(ctx context.Context, rc *ResourceContainer) (EmailRoutingSettings, error) { + if rc.Identifier == "" { + return EmailRoutingSettings{}, ErrMissingZoneID + } + uri := fmt.Sprintf("/zones/%s/email/routing/enable", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return EmailRoutingSettings{}, err + } + + var r EmailRoutingSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DisableEmailRouting Disable your Email Routing zone. Also removes additional MX records previously required for Email Routing to work. +// +// API reference: https://api.cloudflare.com/#email-routing-settings-disable-email-routing +func (api *API) DisableEmailRouting(ctx context.Context, rc *ResourceContainer) (EmailRoutingSettings, error) { + if rc.Identifier == "" { + return EmailRoutingSettings{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/email/routing/disable", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return EmailRoutingSettings{}, err + } + + var r EmailRoutingSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return EmailRoutingSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetEmailRoutingDNSSettings Show the DNS records needed to configure your Email Routing zone. +// +// API reference: https://api.cloudflare.com/#email-routing-settings-email-routing---dns-settings +func (api *API) GetEmailRoutingDNSSettings(ctx context.Context, rc *ResourceContainer) ([]DNSRecord, error) { + if rc.Identifier == "" { + return []DNSRecord{}, ErrMissingZoneID + } + uri := fmt.Sprintf("/zones/%s/email/routing/dns", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DNSRecord{}, err + } + + var r EmailRoutingDNSSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []DNSRecord{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/email_routing_settings_test.go b/pkg/cloudflare-go/email_routing_settings_test.go new file mode 100644 index 000000000..975e93980 --- /dev/null +++ b/pkg/cloudflare-go/email_routing_settings_test.go @@ -0,0 +1,178 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func createTestEmailRoutingSettings() EmailRoutingSettings { + created, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + modified, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + return EmailRoutingSettings{ + Tag: "75610dab9e69410a82cf7e400a09ecec", + Name: "example.net", + Enabled: true, + Created: &created, + Modified: &modified, + SkipWizard: BoolPtr(true), + Status: "read", + } +} + +func TestEmailRouting_GetSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "75610dab9e69410a82cf7e400a09ecec", + "name": "example.net", + "enabled": true, + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z", + "skip_wizard": true, + "status": "read" + } +}`) + }) + + _, err := client.GetEmailRoutingSettings(context.Background(), AccountIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + want := createTestEmailRoutingSettings() + + res, err := client.GetEmailRoutingSettings(context.Background(), AccountIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestEmailRouting_Enable(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/enable", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "75610dab9e69410a82cf7e400a09ecec", + "name": "example.net", + "enabled": true, + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z", + "skip_wizard": true, + "status": "read" + } +}`) + }) + + _, err := client.EnableEmailRouting(context.Background(), AccountIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + want := createTestEmailRoutingSettings() + + res, err := client.EnableEmailRouting(context.Background(), AccountIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestEmailRouting_Disabled(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/disable", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tag": "75610dab9e69410a82cf7e400a09ecec", + "name": "example.net", + "enabled": true, + "created": "2014-01-02T02:20:00Z", + "modified": "2014-01-02T02:20:00Z", + "skip_wizard": true, + "status": "read" + } +}`) + }) + + _, err := client.DisableEmailRouting(context.Background(), AccountIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + want := createTestEmailRoutingSettings() + + res, err := client.DisableEmailRouting(context.Background(), AccountIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestEmailRouting_DNSSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/email/routing/dns", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'DELETE', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "type": "A", + "name": "example.com", + "content": "192.0.2.1", + "ttl": 3600, + "priority": 10 + } + ] +}`) + }) + + _, err := client.GetEmailRoutingDNSSettings(context.Background(), AccountIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + want := []DNSRecord{ + { + Type: "A", + Name: "example.com", + Content: "192.0.2.1", + TTL: 3600, + Priority: Uint16Ptr(10), + }, + } + + res, err := client.GetEmailRoutingDNSSettings(context.Background(), AccountIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Len(t, res, 1) + assert.Equal(t, want, res) + } +} diff --git a/pkg/cloudflare-go/errors.go b/pkg/cloudflare-go/errors.go new file mode 100644 index 000000000..85fdc3efa --- /dev/null +++ b/pkg/cloudflare-go/errors.go @@ -0,0 +1,391 @@ +package cloudflare + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +const ( + errEmptyCredentials = "invalid credentials: key & email must not be empty" //nolint:gosec,unused + errEmptyAPIToken = "invalid credentials: API Token must not be empty" //nolint:gosec,unused + errInternalServiceError = "internal service error" + errMakeRequestError = "error from makeRequest" + errUnmarshalError = "error unmarshalling the JSON response" + errUnmarshalErrorBody = "error unmarshalling the JSON response error body" + errRequestNotSuccessful = "error reported by API" + errMissingAccountID = "required missing account ID" + errMissingZoneID = "required missing zone ID" + errMissingAccountOrZoneID = "either account ID or zone ID must be provided" + errAccountIDAndZoneIDAreMutuallyExclusive = "account ID and zone ID are mutually exclusive" + errMissingResourceIdentifier = "required missing resource identifier" + errOperationStillRunning = "bulk operation did not finish before timeout" + errOperationUnexpectedStatus = "bulk operation returned an unexpected status" + errResultInfo = "incorrect pagination info (result_info) in responses" + errManualPagination = "unexpected pagination options passed to functions that handle pagination automatically" + errInvalidResourceIdentifer = "invalid resource identifier: %s" + errInvalidZoneIdentifer = "invalid zone identifier: %s" + errAPIKeysAndTokensAreMutuallyExclusive = "API keys and tokens are mutually exclusive" //nolint:gosec + errMissingCredentials = "no credentials provided" + + errInvalidResourceContainerAccess = "requested resource container (%q) is not supported for this endpoint" + errRequiredAccountLevelResourceContainer = "this endpoint requires using an account level resource container and identifiers" + errRequiredZoneLevelResourceContainer = "this endpoint requires using a zone level resource container and identifiers" +) + +var ( + ErrAPIKeysAndTokensAreMutuallyExclusive = errors.New(errAPIKeysAndTokensAreMutuallyExclusive) + ErrMissingCredentials = errors.New(errMissingCredentials) + ErrMissingAccountID = errors.New(errMissingAccountID) + ErrMissingZoneID = errors.New(errMissingZoneID) + ErrAccountIDOrZoneIDAreRequired = errors.New(errMissingAccountOrZoneID) + ErrAccountIDAndZoneIDAreMutuallyExclusive = errors.New(errAccountIDAndZoneIDAreMutuallyExclusive) + ErrMissingResourceIdentifier = errors.New(errMissingResourceIdentifier) + + ErrRequiredAccountLevelResourceContainer = errors.New(errRequiredAccountLevelResourceContainer) + ErrRequiredZoneLevelResourceContainer = errors.New(errRequiredZoneLevelResourceContainer) +) + +type ErrorType string + +const ( + ErrorTypeRequest ErrorType = "request" + ErrorTypeAuthentication ErrorType = "authentication" + ErrorTypeAuthorization ErrorType = "authorization" + ErrorTypeNotFound ErrorType = "not_found" + ErrorTypeRateLimit ErrorType = "rate_limit" + ErrorTypeService ErrorType = "service" +) + +type Error struct { + // The classification of error encountered. + Type ErrorType + + // StatusCode is the HTTP status code from the response. + StatusCode int + + // Errors is all of the error messages and codes, combined. + Errors []ResponseInfo + + // ErrorCodes is a list of all the error codes. + ErrorCodes []int + + // ErrorMessages is a list of all the error codes. + ErrorMessages []string + + // Messages is a list of informational messages provided by the endpoint. + Messages []ResponseInfo + + // RayID is the internal identifier for the request that was made. + RayID string +} + +func (e Error) Error() string { + var errString string + errMessages := []string{} + for _, err := range e.Errors { + m := "" + if err.Message != "" { + m += err.Message + } + + if err.Code != 0 { + m += fmt.Sprintf(" (%d)", err.Code) + } + + errMessages = append(errMessages, m) + } + + msgs := []string{} + for _, m := range e.Messages { + msgs = append(msgs, m.Message) + } + + errString += strings.Join(errMessages, ", ") + + // `Messages` is primarily used for additional validation failure notes in + // page rules. This shouldn't be used going forward but instead, use the + // error fields appropriately. + if len(msgs) > 0 { + errString += "\n" + strings.Join(msgs, " \n") + } + + return errString +} + +// RequestError is for 4xx errors that we encounter not covered elsewhere +// (generally bad payloads). +type RequestError struct { + cloudflareError *Error +} + +func (e RequestError) Error() string { + return e.cloudflareError.Error() +} + +func (e RequestError) Errors() []ResponseInfo { + return e.cloudflareError.Errors +} + +func (e RequestError) ErrorCodes() []int { + return e.cloudflareError.ErrorCodes +} + +func (e RequestError) ErrorMessages() []string { + return e.cloudflareError.ErrorMessages +} + +func (e RequestError) InternalErrorCodeIs(code int) bool { + return e.cloudflareError.InternalErrorCodeIs(code) +} + +func (e RequestError) Messages() []ResponseInfo { + return e.cloudflareError.Messages +} + +func (e RequestError) RayID() string { + return e.cloudflareError.RayID +} + +func (e RequestError) Type() ErrorType { + return e.cloudflareError.Type +} + +func NewRequestError(e *Error) RequestError { + return RequestError{ + cloudflareError: e, + } +} + +// RatelimitError is for HTTP 429s where the service is telling the client to +// slow down. +type RatelimitError struct { + cloudflareError *Error +} + +func (e RatelimitError) Error() string { + return e.cloudflareError.Error() +} + +func (e RatelimitError) Errors() []ResponseInfo { + return e.cloudflareError.Errors +} + +func (e RatelimitError) ErrorCodes() []int { + return e.cloudflareError.ErrorCodes +} + +func (e RatelimitError) ErrorMessages() []string { + return e.cloudflareError.ErrorMessages +} + +func (e RatelimitError) InternalErrorCodeIs(code int) bool { + return e.cloudflareError.InternalErrorCodeIs(code) +} + +func (e RatelimitError) RayID() string { + return e.cloudflareError.RayID +} + +func (e RatelimitError) Type() ErrorType { + return e.cloudflareError.Type +} + +func NewRatelimitError(e *Error) RatelimitError { + return RatelimitError{ + cloudflareError: e, + } +} + +// ServiceError is a handler for 5xx errors returned to the client. +type ServiceError struct { + cloudflareError *Error +} + +func (e ServiceError) Error() string { + return e.cloudflareError.Error() +} + +func (e ServiceError) Errors() []ResponseInfo { + return e.cloudflareError.Errors +} + +func (e ServiceError) ErrorCodes() []int { + return e.cloudflareError.ErrorCodes +} + +func (e ServiceError) ErrorMessages() []string { + return e.cloudflareError.ErrorMessages +} + +func (e ServiceError) InternalErrorCodeIs(code int) bool { + return e.cloudflareError.InternalErrorCodeIs(code) +} + +func (e ServiceError) RayID() string { + return e.cloudflareError.RayID +} + +func (e ServiceError) Type() ErrorType { + return e.cloudflareError.Type +} + +func NewServiceError(e *Error) ServiceError { + return ServiceError{ + cloudflareError: e, + } +} + +// AuthenticationError is for HTTP 401 responses. +type AuthenticationError struct { + cloudflareError *Error +} + +func (e AuthenticationError) Error() string { + return e.cloudflareError.Error() +} + +func (e AuthenticationError) Errors() []ResponseInfo { + return e.cloudflareError.Errors +} + +func (e AuthenticationError) ErrorCodes() []int { + return e.cloudflareError.ErrorCodes +} + +func (e AuthenticationError) ErrorMessages() []string { + return e.cloudflareError.ErrorMessages +} + +func (e AuthenticationError) InternalErrorCodeIs(code int) bool { + return e.cloudflareError.InternalErrorCodeIs(code) +} + +func (e AuthenticationError) RayID() string { + return e.cloudflareError.RayID +} + +func (e AuthenticationError) Type() ErrorType { + return e.cloudflareError.Type +} + +func NewAuthenticationError(e *Error) AuthenticationError { + return AuthenticationError{ + cloudflareError: e, + } +} + +// AuthorizationError is for HTTP 403 responses. +type AuthorizationError struct { + cloudflareError *Error +} + +func (e AuthorizationError) Error() string { + return e.cloudflareError.Error() +} + +func (e AuthorizationError) Errors() []ResponseInfo { + return e.cloudflareError.Errors +} + +func (e AuthorizationError) ErrorCodes() []int { + return e.cloudflareError.ErrorCodes +} + +func (e AuthorizationError) ErrorMessages() []string { + return e.cloudflareError.ErrorMessages +} + +func (e AuthorizationError) InternalErrorCodeIs(code int) bool { + return e.cloudflareError.InternalErrorCodeIs(code) +} + +func (e AuthorizationError) RayID() string { + return e.cloudflareError.RayID +} + +func (e AuthorizationError) Type() ErrorType { + return e.cloudflareError.Type +} + +func NewAuthorizationError(e *Error) AuthorizationError { + return AuthorizationError{ + cloudflareError: e, + } +} + +// NotFoundError is for HTTP 404 responses. +type NotFoundError struct { + cloudflareError *Error +} + +func (e NotFoundError) Error() string { + return e.cloudflareError.Error() +} + +func (e NotFoundError) Errors() []ResponseInfo { + return e.cloudflareError.Errors +} + +func (e NotFoundError) ErrorCodes() []int { + return e.cloudflareError.ErrorCodes +} + +func (e NotFoundError) ErrorMessages() []string { + return e.cloudflareError.ErrorMessages +} + +func (e NotFoundError) InternalErrorCodeIs(code int) bool { + return e.cloudflareError.InternalErrorCodeIs(code) +} + +func (e NotFoundError) RayID() string { + return e.cloudflareError.RayID +} + +func (e NotFoundError) Type() ErrorType { + return e.cloudflareError.Type +} + +func NewNotFoundError(e *Error) NotFoundError { + return NotFoundError{ + cloudflareError: e, + } +} + +// ClientError returns a boolean whether or not the raised error was caused by +// something client side. +func (e *Error) ClientError() bool { + return e.StatusCode >= http.StatusBadRequest && + e.StatusCode < http.StatusInternalServerError +} + +// ClientRateLimited returns a boolean whether or not the raised error was +// caused by too many requests from the client. +func (e *Error) ClientRateLimited() bool { + return e.Type == ErrorTypeRateLimit +} + +// InternalErrorCodeIs returns a boolean whether or not the desired internal +// error code is present in `e.InternalErrorCodes`. +func (e *Error) InternalErrorCodeIs(code int) bool { + for _, errCode := range e.ErrorCodes { + if errCode == code { + return true + } + } + + return false +} + +// ErrorMessageContains returns a boolean whether or not a substring exists in +// any of the `e.ErrorMessages` slice entries. +func (e *Error) ErrorMessageContains(s string) bool { + for _, errMsg := range e.ErrorMessages { + if strings.Contains(errMsg, s) { + return true + } + } + return false +} diff --git a/pkg/cloudflare-go/errors_external_test.go b/pkg/cloudflare-go/errors_external_test.go new file mode 100644 index 000000000..c9874106c --- /dev/null +++ b/pkg/cloudflare-go/errors_external_test.go @@ -0,0 +1,28 @@ +package cloudflare_test + +import ( + "testing" + + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/stretchr/testify/assert" +) + +func TestError_CreateErrors(t *testing.T) { + baseErr := &cloudflare.Error{ + StatusCode: 400, + ErrorCodes: []int{10000}, + } + + requestErr := cloudflare.NewRequestError(baseErr) + assert.True(t, requestErr.InternalErrorCodeIs(10000)) + limitError := cloudflare.NewRatelimitError(baseErr) + assert.True(t, limitError.InternalErrorCodeIs(10000)) + svcErr := cloudflare.NewServiceError(baseErr) + assert.True(t, svcErr.InternalErrorCodeIs(10000)) + authErr := cloudflare.NewAuthenticationError(baseErr) + assert.True(t, authErr.InternalErrorCodeIs(10000)) + authzErr := cloudflare.NewAuthorizationError(baseErr) + assert.True(t, authzErr.InternalErrorCodeIs(10000)) + notFoundErr := cloudflare.NewNotFoundError(baseErr) + assert.True(t, notFoundErr.InternalErrorCodeIs(10000)) +} diff --git a/pkg/cloudflare-go/errors_test.go b/pkg/cloudflare-go/errors_test.go new file mode 100644 index 000000000..f66199077 --- /dev/null +++ b/pkg/cloudflare-go/errors_test.go @@ -0,0 +1,52 @@ +package cloudflare + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestError_Error(t *testing.T) { + tests := map[string]struct { + response []ResponseInfo + want string + }{ + "basic complete response": { + response: []ResponseInfo{{ + Code: 10000, + Message: "Authentication error", + }}, + want: "Authentication error (10000)", + }, + "multiple complete response": { + response: []ResponseInfo{ + { + Code: 10000, + Message: "Authentication error", + }, + { + Code: 10001, + Message: "Not authentication error", + }, + }, + want: "Authentication error (10000), Not authentication error (10001)", + }, + "missing internal error code": { + response: []ResponseInfo{{ + Message: "something is broke", + }}, + want: "something is broke", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := &RequestError{cloudflareError: &Error{ + StatusCode: 400, + Errors: tc.response, + }} + + assert.Equal(t, tc.want, got.Error()) + }) + } +} diff --git a/pkg/cloudflare-go/example_test.go b/pkg/cloudflare-go/example_test.go new file mode 100644 index 000000000..5c6cc83e4 --- /dev/null +++ b/pkg/cloudflare-go/example_test.go @@ -0,0 +1,40 @@ +package cloudflare_test + +import ( + "context" + "fmt" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +const ( + user = "cloudflare@example.org" + domain = "example.com" + apiKey = "deadbeef" +) + +func Example() { + api, err := cloudflare.New("deadbeef", "cloudflare@example.org") + if err != nil { + fmt.Println(err) + return + } + + // Fetch the zone ID for zone example.org + zoneID, err := api.ZoneIDByName("example.org") + if err != nil { + fmt.Println(err) + return + } + + // Fetch all DNS records for example.org + records, _, err := api.ListDNSRecords(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListDNSRecordsParams{}) + if err != nil { + fmt.Println(err) + return + } + + for _, r := range records { + fmt.Printf("%s: %s\n", r.Name, r.Content) + } +} diff --git a/pkg/cloudflare-go/fallback_domain.go b/pkg/cloudflare-go/fallback_domain.go new file mode 100644 index 000000000..e8bdfc937 --- /dev/null +++ b/pkg/cloudflare-go/fallback_domain.go @@ -0,0 +1,133 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// FallbackDomainResponse represents the response from the get fallback +// domain endpoints. +type FallbackDomainResponse struct { + Response + Result []FallbackDomain `json:"result"` +} + +// FallbackDomain represents the individual domain struct. +type FallbackDomain struct { + Suffix string `json:"suffix,omitempty"` + Description string `json:"description,omitempty"` + DNSServer []string `json:"dns_server,omitempty"` +} + +// ListFallbackDomains returns all fallback domains within an account. +// +// API reference: https://api.cloudflare.com/#devices-get-local-domain-fallback-list +func (api *API) ListFallbackDomains(ctx context.Context, accountID string) ([]FallbackDomain, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/fallback_domains", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []FallbackDomain{}, err + } + + var fallbackDomainResponse FallbackDomainResponse + err = json.Unmarshal(res, &fallbackDomainResponse) + if err != nil { + return []FallbackDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return fallbackDomainResponse.Result, nil +} + +// ListFallbackDomainsDeviceSettingsPolicy returns all fallback domains within an account for a specific device settings policy. +// +// API reference: https://api.cloudflare.com/#devices-get-local-domain-fallback-list +func (api *API) ListFallbackDomainsDeviceSettingsPolicy(ctx context.Context, accountID, policyID string) ([]FallbackDomain, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s/fallback_domains", AccountRouteRoot, accountID, policyID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []FallbackDomain{}, err + } + + var fallbackDomainResponse FallbackDomainResponse + err = json.Unmarshal(res, &fallbackDomainResponse) + if err != nil { + return []FallbackDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return fallbackDomainResponse.Result, nil +} + +// UpdateFallbackDomain updates the existing fallback domain policy. +// +// API reference: https://api.cloudflare.com/#devices-set-local-domain-fallback-list +func (api *API) UpdateFallbackDomain(ctx context.Context, accountID string, domains []FallbackDomain) ([]FallbackDomain, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/fallback_domains", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, domains) + if err != nil { + return []FallbackDomain{}, err + } + + var fallbackDomainResponse FallbackDomainResponse + err = json.Unmarshal(res, &fallbackDomainResponse) + if err != nil { + return []FallbackDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return fallbackDomainResponse.Result, nil +} + +// UpdateFallbackDomainDeviceSettingsPolicy updates the existing fallback domain policy for a specific device settings policy. +// +// API reference: https://api.cloudflare.com/#devices-set-local-domain-fallback-list +func (api *API) UpdateFallbackDomainDeviceSettingsPolicy(ctx context.Context, accountID, policyID string, domains []FallbackDomain) ([]FallbackDomain, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s/fallback_domains", AccountRouteRoot, accountID, policyID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, domains) + if err != nil { + return []FallbackDomain{}, err + } + + var fallbackDomainResponse FallbackDomainResponse + err = json.Unmarshal(res, &fallbackDomainResponse) + if err != nil { + return []FallbackDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return fallbackDomainResponse.Result, nil +} + +// RestoreFallbackDomainDefaultsDeviceSettingsPolicy resets the domain fallback values to the default +// list for a specific device settings policy. +// +// API reference: TBA. +func (api *API) RestoreFallbackDomainDefaults(ctx context.Context, accountID string) error { + uri := fmt.Sprintf("/%s/%s/devices/policy/fallback_domains?reset_defaults=true", AccountRouteRoot, accountID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, []string{}) + if err != nil { + return err + } + + return nil +} + +// RestoreFallbackDomainDefaults resets the domain fallback values to the default +// list. +// +// API reference: TBA. +func (api *API) RestoreFallbackDomainDefaultsDeviceSettingsPolicy(ctx context.Context, accountID, policyID string) error { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s/fallback_domains?reset_defaults=true", AccountRouteRoot, accountID, policyID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, []string{}) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/fallback_domain_test.go b/pkg/cloudflare-go/fallback_domain_test.go new file mode 100644 index 000000000..159e909e6 --- /dev/null +++ b/pkg/cloudflare-go/fallback_domain_test.go @@ -0,0 +1,236 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListFallbackDomain(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "suffix": "example.com", + "description": "Domain bypass for local development" + } + ] + } + `) + } + + want := []FallbackDomain{{ + Suffix: "example.com", + Description: "Domain bypass for local development", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/fallback_domains", handler) + + actual, err := client.ListFallbackDomains(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListFallbackDomainsDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "suffix": "example.com", + "description": "Domain bypass for local development" + } + ] + } + `) + } + + want := []FallbackDomain{{ + Suffix: "example.com", + Description: "Domain bypass for local development", + }} + + policyID := "a842fa8a-a583-482e-9cd9-eb43362949fd" + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/"+policyID+"/fallback_domains", handler) + + actual, err := client.ListFallbackDomainsDeviceSettingsPolicy(context.Background(), testAccountID, policyID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestFallbackDomainDNSServer(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "suffix": "example.com", + "description": "Domain bypass for local development", + "dns_server": ["192.168.0.1", "10.1.1.1"] + } + ] + } + `) + } + + want := []FallbackDomain{{ + Suffix: "example.com", + Description: "Domain bypass for local development", + DNSServer: []string{"192.168.0.1", "10.1.1.1"}, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/fallback_domains", handler) + + actual, err := client.ListFallbackDomains(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateFallbackDomain(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "suffix": "example_one.com", + "description": "example one", + "dns_server": ["192.168.0.1", "10.1.1.1"] + }, + { + "suffix": "example_two.com", + "description": "example two" + }, + { + "suffix": "example_three.com", + "description": "example three" + } + ] + } + `) + } + + domains := []FallbackDomain{ + { + Suffix: "example_one.com", + Description: "example one", + DNSServer: []string{"192.168.0.1", "10.1.1.1"}, + }, + { + Suffix: "example_two.com", + Description: "example two", + }, + { + Suffix: "example_three.com", + Description: "example three", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/fallback_domains", handler) + + actual, err := client.UpdateFallbackDomain(context.Background(), testAccountID, domains) + + if assert.NoError(t, err) { + assert.Equal(t, domains, actual) + } +} + +func TestUpdateFallbackDomainDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "suffix": "example_one.com", + "description": "example one", + "dns_server": ["192.168.0.1", "10.1.1.1"] + }, + { + "suffix": "example_two.com", + "description": "example two" + }, + { + "suffix": "example_three.com", + "description": "example three" + } + ] + } + `) + } + + domains := []FallbackDomain{ + { + Suffix: "example_one.com", + Description: "example one", + DNSServer: []string{"192.168.0.1", "10.1.1.1"}, + }, + { + Suffix: "example_two.com", + Description: "example two", + }, + { + Suffix: "example_three.com", + Description: "example three", + }, + } + + policyID := "a842fa8a-a583-482e-9cd9-eb43362949fd" + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/"+policyID+"/fallback_domains", handler) + + actual, err := client.UpdateFallbackDomainDeviceSettingsPolicy(context.Background(), testAccountID, policyID, domains) + + if assert.NoError(t, err) { + assert.Equal(t, domains, actual) + } +} diff --git a/pkg/cloudflare-go/filter.go b/pkg/cloudflare-go/filter.go new file mode 100644 index 000000000..107cab544 --- /dev/null +++ b/pkg/cloudflare-go/filter.go @@ -0,0 +1,290 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/goccy/go-json" +) + +var ErrNotEnoughFilterIDsProvided = errors.New("at least one filter ID must be provided.") + +// Filter holds the structure of the filter type. +type Filter struct { + ID string `json:"id,omitempty"` + Expression string `json:"expression"` + Paused bool `json:"paused"` + Description string `json:"description"` + + // Property is mentioned in documentation however isn't populated in + // any of the API requests. For now, let's just omit it unless it's + // provided. + Ref string `json:"ref,omitempty"` +} + +// FiltersDetailResponse is the API response that is returned +// for requesting all filters on a zone. +type FiltersDetailResponse struct { + Result []Filter `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// FilterDetailResponse is the API response that is returned +// for requesting a single filter on a zone. +type FilterDetailResponse struct { + Result Filter `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// FilterValidateExpression represents the JSON payload for checking +// an expression. +type FilterValidateExpression struct { + Expression string `json:"expression"` +} + +// FilterValidateExpressionResponse represents the API response for +// checking the expression. It conforms to the JSON API approach however +// we don't need all of the fields exposed. +type FilterValidateExpressionResponse struct { + Success bool `json:"success"` + Errors []FilterValidationExpressionMessage `json:"errors"` +} + +// FilterValidationExpressionMessage represents the API error message. +type FilterValidationExpressionMessage struct { + Message string `json:"message"` +} + +// FilterCreateParams contains required and optional params +// for creating a filter. +type FilterCreateParams struct { + ID string `json:"id,omitempty"` + Expression string `json:"expression"` + Paused bool `json:"paused"` + Description string `json:"description"` + Ref string `json:"ref,omitempty"` +} + +// FilterUpdateParams contains required and optional params +// for updating a filter. +type FilterUpdateParams struct { + ID string `json:"id"` + Expression string `json:"expression"` + Paused bool `json:"paused"` + Description string `json:"description"` + Ref string `json:"ref,omitempty"` +} + +type FilterListParams struct { + ResultInfo +} + +// Filter returns a single filter in a zone based on the filter ID. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/get/#get-by-filter-id +func (api *API) Filter(ctx context.Context, rc *ResourceContainer, filterID string) (Filter, error) { + uri := fmt.Sprintf("/zones/%s/filters/%s", rc.Identifier, filterID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Filter{}, err + } + + var filterResponse FilterDetailResponse + err = json.Unmarshal(res, &filterResponse) + if err != nil { + return Filter{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return filterResponse.Result, nil +} + +// Filters returns filters for a zone. +// +// Automatically paginates all results unless `params.PerPage` and `params.Page` +// is set. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/get/#get-all-filters +func (api *API) Filters(ctx context.Context, rc *ResourceContainer, params FilterListParams) ([]Filter, *ResultInfo, error) { + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var filters []Filter + var fResponse FiltersDetailResponse + for { + fResponse = FiltersDetailResponse{} + uri := buildURI(fmt.Sprintf("/zones/%s/filters", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Filter{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &fResponse) + if err != nil { + return []Filter{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal filters JSON data: %w", err) + } + + filters = append(filters, fResponse.Result...) + params.ResultInfo = fResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return filters, &fResponse.ResultInfo, nil +} + +// CreateFilters creates new filters. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/post/ +func (api *API) CreateFilters(ctx context.Context, rc *ResourceContainer, params []FilterCreateParams) ([]Filter, error) { + uri := fmt.Sprintf("/zones/%s/filters", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return []Filter{}, err + } + + var filtersResponse FiltersDetailResponse + err = json.Unmarshal(res, &filtersResponse) + if err != nil { + return []Filter{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return filtersResponse.Result, nil +} + +// UpdateFilter updates a single filter. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/put/#update-a-single-filter +func (api *API) UpdateFilter(ctx context.Context, rc *ResourceContainer, params FilterUpdateParams) (Filter, error) { + if params.ID == "" { + return Filter{}, fmt.Errorf("filter ID cannot be empty") + } + + uri := fmt.Sprintf("/zones/%s/filters/%s", rc.Identifier, params.ID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return Filter{}, err + } + + var filterResponse FilterDetailResponse + err = json.Unmarshal(res, &filterResponse) + if err != nil { + return Filter{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return filterResponse.Result, nil +} + +// UpdateFilters updates many filters at once. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/put/#update-multiple-filters +func (api *API) UpdateFilters(ctx context.Context, rc *ResourceContainer, params []FilterUpdateParams) ([]Filter, error) { + for _, filter := range params { + if filter.ID == "" { + return []Filter{}, fmt.Errorf("filter ID cannot be empty") + } + } + + uri := fmt.Sprintf("/zones/%s/filters", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return []Filter{}, err + } + + var filtersResponse FiltersDetailResponse + err = json.Unmarshal(res, &filtersResponse) + if err != nil { + return []Filter{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return filtersResponse.Result, nil +} + +// DeleteFilter deletes a single filter. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/delete/#delete-a-single-filter +func (api *API) DeleteFilter(ctx context.Context, rc *ResourceContainer, filterID string) error { + if filterID == "" { + return fmt.Errorf("filter ID cannot be empty") + } + + uri := fmt.Sprintf("/zones/%s/filters/%s", rc.Identifier, filterID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// DeleteFilters deletes multiple filters. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/delete/#delete-multiple-filters +func (api *API) DeleteFilters(ctx context.Context, rc *ResourceContainer, filterIDs []string) error { + if len(filterIDs) == 0 { + return ErrNotEnoughFilterIDsProvided + } + + // Swap this to a typed struct and passing in all parameters together. + q := url.Values{} + for _, id := range filterIDs { + q.Add("id", id) + } + + uri := (&url.URL{ + Path: fmt.Sprintf("/zones/%s/filters", rc.Identifier), + RawQuery: q.Encode(), + }).String() + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// ValidateFilterExpression checks correctness of a filter expression. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-filters/validation/ +func (api *API) ValidateFilterExpression(ctx context.Context, expression string) error { + expressionPayload := FilterValidateExpression{Expression: expression} + + _, err := api.makeRequestContext(ctx, http.MethodPost, "/filters/validate-expr", expressionPayload) + if err != nil { + var filterValidationResponse FilterValidateExpressionResponse + + jsonErr := json.Unmarshal([]byte(err.Error()), &filterValidationResponse) + if jsonErr != nil { + return fmt.Errorf(errUnmarshalError+": %w", jsonErr) + } + + if !filterValidationResponse.Success { + // Unsure why but the API returns `errors` as an array but it only + // ever shows the issue with one problem at a time ¯\_(ツ)_/¯ + return fmt.Errorf(filterValidationResponse.Errors[0].Message) + } + } + + return nil +} diff --git a/pkg/cloudflare-go/filter_test.go b/pkg/cloudflare-go/filter_test.go new file mode 100644 index 000000000..b36c7cc8e --- /dev/null +++ b/pkg/cloudflare-go/filter_test.go @@ -0,0 +1,391 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilter(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "id": "b7ff25282d394be7b945e23c7106ce8a", + "paused": false, + "description": "Login from office", + "expression": "ip.src eq 198.51.100.1" + }, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters/b7ff25282d394be7b945e23c7106ce8a", handler) + want := Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from office", + Expression: "ip.src eq 198.51.100.1", + } + + actual, err := client.Filter(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), "b7ff25282d394be7b945e23c7106ce8a") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestFilters(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + { + "id": "b7ff25282d394be7b945e23c7106ce8a", + "paused": false, + "description": "Login from office", + "expression": "ip.src eq 93.184.216.0 and (http.request.uri.path ~ \"^.*/wp-login.php$\" or http.request.uri.path ~ \"^.*/xmlrpc.php$\")" + }, + { + "id": "c218c536b2bd406f958f278cf0fa8c0f", + "paused": false, + "description": "Login", + "expression": "(http.request.uri.path ~ \"^.*/wp-login.php$\" or http.request.uri.path ~ \"^.*/xmlrpc.php$\")" + }, + { + "id": "f2a64520581a4209aab12187a0081364", + "paused": false, + "description": "not /api", + "expression": "not http.request.uri.path matches \"^/api/.*$\"" + }, { + "id": "14217d7bd5ab435e84b1bd468bf4fb9f", + "paused": false, + "description": "/api", + "expression": "http.request.uri.path matches \"^/api/.*$\"" + }, { + "id": "60ee852f9cbb4802978d15600c7f3110", + "paused": false, + "expression": "ip.src eq 93.184.216.0" + } ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 1, + "per_page": 25, + "count": 5, + "total_count": 5, + "total_pages": 1 + } } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters", handler) + want := []Filter{ + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from office", + Expression: "ip.src eq 93.184.216.0 and (http.request.uri.path ~ \"^.*/wp-login.php$\" or http.request.uri.path ~ \"^.*/xmlrpc.php$\")", + }, + { + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Paused: false, + Description: "Login", + Expression: "(http.request.uri.path ~ \"^.*/wp-login.php$\" or http.request.uri.path ~ \"^.*/xmlrpc.php$\")", + }, + { + ID: "f2a64520581a4209aab12187a0081364", + Paused: false, + Description: "not /api", + Expression: "not http.request.uri.path matches \"^/api/.*$\"", + }, + { + ID: "14217d7bd5ab435e84b1bd468bf4fb9f", + Paused: false, + Description: "/api", + Expression: "http.request.uri.path matches \"^/api/.*$\"", + }, { + ID: "60ee852f9cbb4802978d15600c7f3110", + Paused: false, + Expression: "ip.src eq 93.184.216.0", + }, + } + + actual, _, err := client.Filters(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), FilterListParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSingleFilter(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": [ + { + "id": "b7ff25282d394be7b945e23c7106ce8a", + "paused": false, + "description": "Login from office", + "expression": "ip.src eq 198.51.100.1" + } + ], + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters", handler) + params := []FilterCreateParams{ + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from office", + Expression: "ip.src eq 198.51.100.1", + }, + } + + want := []Filter{ + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from office", + Expression: "ip.src eq 198.51.100.1", + }, + } + + actual, err := client.CreateFilters(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMultipleFilters(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": [ + { + "id": "b7ff25282d394be7b945e23c7106ce8a", + "paused": false, + "description": "Login from office", + "expression": "ip.src eq 198.51.100.1" + }, + { + "id": "b7ff25282d394be7b945e23c7106ce8a", + "paused": false, + "description": "Login from second office", + "expression": "ip.src eq 10.0.0.1" + } + ], + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters", handler) + params := []FilterCreateParams{ + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from office", + Expression: "ip.src eq 198.51.100.1", + }, + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from second office", + Expression: "ip.src eq 10.0.0.1", + }, + } + + want := []Filter{ + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from office", + Expression: "ip.src eq 198.51.100.1", + }, + { + ID: "b7ff25282d394be7b945e23c7106ce8a", + Paused: false, + Description: "Login from second office", + Expression: "ip.src eq 10.0.0.1", + }, + } + + actual, err := client.CreateFilters(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSingleFilter(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "id": "60ee852f9cbb4802978d15600c7f3110", + "paused": false, + "description": "IP of example.org", + "expression": "ip.src eq 93.184.216.0" + }, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters/60ee852f9cbb4802978d15600c7f3110", handler) + params := FilterUpdateParams{ + ID: "60ee852f9cbb4802978d15600c7f3110", + Paused: false, + Description: "IP of example.org", + Expression: "ip.src eq 93.184.216.0", + } + + want := Filter{ + ID: "60ee852f9cbb4802978d15600c7f3110", + Paused: false, + Description: "IP of example.org", + Expression: "ip.src eq 93.184.216.0", + } + + actual, err := client.UpdateFilter(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateMultipleFilters(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": [ + { + "id": "60ee852f9cbb4802978d15600c7f3110", + "paused": false, + "description": "IP of example.org", + "expression": "ip.src eq 93.184.216.0" + }, + { + "id": "c218c536b2bd406f958f278cf0fa8c0f", + "paused": false, + "description": "IP of example.com", + "expression": "ip.src ne 198.51.100.1" + } + ], + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters", handler) + params := []FilterUpdateParams{ + { + ID: "60ee852f9cbb4802978d15600c7f3110", + Paused: false, + Description: "IP of example.org", + Expression: "ip.src eq 93.184.216.0", + }, + { + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Paused: false, + Description: "IP of example.com", + Expression: "ip.src ne 198.51.100.1", + }, + } + + want := []Filter{ + { + ID: "60ee852f9cbb4802978d15600c7f3110", + Paused: false, + Description: "IP of example.org", + Expression: "ip.src eq 93.184.216.0", + }, + { + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Paused: false, + Description: "IP of example.com", + Expression: "ip.src ne 198.51.100.1", + }, + } + + actual, err := client.UpdateFilters(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteFilter(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [], + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/filters/60ee852f9cbb4802978d15600c7f3110", handler) + + err := client.DeleteFilter(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), "60ee852f9cbb4802978d15600c7f3110") + assert.Nil(t, err) + assert.NoError(t, err) +} + +func TestDeleteFilterWithMissingID(t *testing.T) { + setup() + defer teardown() + + err := client.DeleteFilter(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), "") + assert.EqualError(t, err, "filter ID cannot be empty") +} diff --git a/pkg/cloudflare-go/firewall.go b/pkg/cloudflare-go/firewall.go new file mode 100644 index 000000000..893165c57 --- /dev/null +++ b/pkg/cloudflare-go/firewall.go @@ -0,0 +1,281 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/goccy/go-json" +) + +// AccessRule represents a firewall access rule. +type AccessRule struct { + ID string `json:"id,omitempty"` + Notes string `json:"notes,omitempty"` + AllowedModes []string `json:"allowed_modes,omitempty"` + Mode string `json:"mode,omitempty"` + Configuration AccessRuleConfiguration `json:"configuration,omitempty"` + Scope AccessRuleScope `json:"scope,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` +} + +// AccessRuleConfiguration represents the configuration of a firewall +// access rule. +type AccessRuleConfiguration struct { + Target string `json:"target,omitempty"` + Value string `json:"value,omitempty"` +} + +// AccessRuleScope represents the scope of a firewall access rule. +type AccessRuleScope struct { + ID string `json:"id,omitempty"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` +} + +// AccessRuleResponse represents the response from the firewall access +// rule endpoint. +type AccessRuleResponse struct { + Result AccessRule `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// AccessRuleListResponse represents the response from the list access rules +// endpoint. +type AccessRuleListResponse struct { + Result []AccessRule `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// ListUserAccessRules returns a slice of access rules for the logged-in user. +// +// This takes an AccessRule to allow filtering of the results returned. +// +// API reference: https://api.cloudflare.com/#user-level-firewall-access-rule-list-access-rules +func (api *API) ListUserAccessRules(ctx context.Context, accessRule AccessRule, page int) (*AccessRuleListResponse, error) { + return api.listAccessRules(ctx, "/user", accessRule, page) +} + +// CreateUserAccessRule creates a firewall access rule for the logged-in user. +// +// API reference: https://api.cloudflare.com/#user-level-firewall-access-rule-create-access-rule +func (api *API) CreateUserAccessRule(ctx context.Context, accessRule AccessRule) (*AccessRuleResponse, error) { + return api.createAccessRule(ctx, "/user", accessRule) +} + +// UserAccessRule returns the details of a user's account access rule. +// +// API reference: https://api.cloudflare.com/#user-level-firewall-access-rule-list-access-rules +func (api *API) UserAccessRule(ctx context.Context, accessRuleID string) (*AccessRuleResponse, error) { + return api.retrieveAccessRule(ctx, "/user", accessRuleID) +} + +// UpdateUserAccessRule updates a single access rule for the logged-in user & +// given access rule identifier. +// +// API reference: https://api.cloudflare.com/#user-level-firewall-access-rule-update-access-rule +func (api *API) UpdateUserAccessRule(ctx context.Context, accessRuleID string, accessRule AccessRule) (*AccessRuleResponse, error) { + return api.updateAccessRule(ctx, "/user", accessRuleID, accessRule) +} + +// DeleteUserAccessRule deletes a single access rule for the logged-in user and +// access rule identifiers. +// +// API reference: https://api.cloudflare.com/#user-level-firewall-access-rule-update-access-rule +func (api *API) DeleteUserAccessRule(ctx context.Context, accessRuleID string) (*AccessRuleResponse, error) { + return api.deleteAccessRule(ctx, "/user", accessRuleID) +} + +// ListZoneAccessRules returns a slice of access rules for the given zone +// identifier. +// +// This takes an AccessRule to allow filtering of the results returned. +// +// API reference: https://api.cloudflare.com/#firewall-access-rule-for-a-zone-list-access-rules +func (api *API) ListZoneAccessRules(ctx context.Context, zoneID string, accessRule AccessRule, page int) (*AccessRuleListResponse, error) { + return api.listAccessRules(ctx, fmt.Sprintf("/zones/%s", zoneID), accessRule, page) +} + +// CreateZoneAccessRule creates a firewall access rule for the given zone +// identifier. +// +// API reference: https://api.cloudflare.com/#firewall-access-rule-for-a-zone-create-access-rule +func (api *API) CreateZoneAccessRule(ctx context.Context, zoneID string, accessRule AccessRule) (*AccessRuleResponse, error) { + return api.createAccessRule(ctx, fmt.Sprintf("/zones/%s", zoneID), accessRule) +} + +// ZoneAccessRule returns the details of a zone's access rule. +// +// API reference: https://api.cloudflare.com/#firewall-access-rule-for-a-zone-list-access-rules +func (api *API) ZoneAccessRule(ctx context.Context, zoneID string, accessRuleID string) (*AccessRuleResponse, error) { + return api.retrieveAccessRule(ctx, fmt.Sprintf("/zones/%s", zoneID), accessRuleID) +} + +// UpdateZoneAccessRule updates a single access rule for the given zone & +// access rule identifiers. +// +// API reference: https://api.cloudflare.com/#firewall-access-rule-for-a-zone-update-access-rule +func (api *API) UpdateZoneAccessRule(ctx context.Context, zoneID, accessRuleID string, accessRule AccessRule) (*AccessRuleResponse, error) { + return api.updateAccessRule(ctx, fmt.Sprintf("/zones/%s", zoneID), accessRuleID, accessRule) +} + +// DeleteZoneAccessRule deletes a single access rule for the given zone and +// access rule identifiers. +// +// API reference: https://api.cloudflare.com/#firewall-access-rule-for-a-zone-delete-access-rule +func (api *API) DeleteZoneAccessRule(ctx context.Context, zoneID, accessRuleID string) (*AccessRuleResponse, error) { + return api.deleteAccessRule(ctx, fmt.Sprintf("/zones/%s", zoneID), accessRuleID) +} + +// ListAccountAccessRules returns a slice of access rules for the given +// account identifier. +// +// This takes an AccessRule to allow filtering of the results returned. +// +// API reference: https://api.cloudflare.com/#account-level-firewall-access-rule-list-access-rules +func (api *API) ListAccountAccessRules(ctx context.Context, accountID string, accessRule AccessRule, page int) (*AccessRuleListResponse, error) { + return api.listAccessRules(ctx, fmt.Sprintf("/accounts/%s", accountID), accessRule, page) +} + +// CreateAccountAccessRule creates a firewall access rule for the given +// account identifier. +// +// API reference: https://api.cloudflare.com/#account-level-firewall-access-rule-create-access-rule +func (api *API) CreateAccountAccessRule(ctx context.Context, accountID string, accessRule AccessRule) (*AccessRuleResponse, error) { + return api.createAccessRule(ctx, fmt.Sprintf("/accounts/%s", accountID), accessRule) +} + +// AccountAccessRule returns the details of an account's access rule. +// +// API reference: https://api.cloudflare.com/#account-level-firewall-access-rule-access-rule-details +func (api *API) AccountAccessRule(ctx context.Context, accountID string, accessRuleID string) (*AccessRuleResponse, error) { + return api.retrieveAccessRule(ctx, fmt.Sprintf("/accounts/%s", accountID), accessRuleID) +} + +// UpdateAccountAccessRule updates a single access rule for the given +// account & access rule identifiers. +// +// API reference: https://api.cloudflare.com/#account-level-firewall-access-rule-update-access-rule +func (api *API) UpdateAccountAccessRule(ctx context.Context, accountID, accessRuleID string, accessRule AccessRule) (*AccessRuleResponse, error) { + return api.updateAccessRule(ctx, fmt.Sprintf("/accounts/%s", accountID), accessRuleID, accessRule) +} + +// DeleteAccountAccessRule deletes a single access rule for the given +// account and access rule identifiers. +// +// API reference: https://api.cloudflare.com/#account-level-firewall-access-rule-delete-access-rule +func (api *API) DeleteAccountAccessRule(ctx context.Context, accountID, accessRuleID string) (*AccessRuleResponse, error) { + return api.deleteAccessRule(ctx, fmt.Sprintf("/accounts/%s", accountID), accessRuleID) +} + +func (api *API) listAccessRules(ctx context.Context, prefix string, accessRule AccessRule, page int) (*AccessRuleListResponse, error) { + // Construct a query string + v := url.Values{} + if page <= 0 { + page = 1 + } + v.Set("page", strconv.Itoa(page)) + // Request as many rules as possible per page - API max is 100 + v.Set("per_page", "100") + if accessRule.Notes != "" { + v.Set("notes", accessRule.Notes) + } + if accessRule.Mode != "" { + v.Set("mode", accessRule.Mode) + } + if accessRule.Scope.Type != "" { + v.Set("scope_type", accessRule.Scope.Type) + } + if accessRule.Configuration.Value != "" { + v.Set("configuration_value", accessRule.Configuration.Value) + } + if accessRule.Configuration.Target != "" { + v.Set("configuration_target", accessRule.Configuration.Target) + } + v.Set("page", strconv.Itoa(page)) + + uri := fmt.Sprintf("%s/firewall/access_rules/rules?%s", prefix, v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + response := &AccessRuleListResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response, nil +} + +func (api *API) createAccessRule(ctx context.Context, prefix string, accessRule AccessRule) (*AccessRuleResponse, error) { + uri := fmt.Sprintf("%s/firewall/access_rules/rules", prefix) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, accessRule) + if err != nil { + return nil, err + } + + response := &AccessRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +func (api *API) retrieveAccessRule(ctx context.Context, prefix, accessRuleID string) (*AccessRuleResponse, error) { + uri := fmt.Sprintf("%s/firewall/access_rules/rules/%s", prefix, accessRuleID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + if err != nil { + return nil, err + } + + response := &AccessRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +func (api *API) updateAccessRule(ctx context.Context, prefix, accessRuleID string, accessRule AccessRule) (*AccessRuleResponse, error) { + uri := fmt.Sprintf("%s/firewall/access_rules/rules/%s", prefix, accessRuleID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, accessRule) + if err != nil { + return nil, err + } + + response := &AccessRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response, nil +} + +func (api *API) deleteAccessRule(ctx context.Context, prefix, accessRuleID string) (*AccessRuleResponse, error) { + uri := fmt.Sprintf("%s/firewall/access_rules/rules/%s", prefix, accessRuleID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + + response := &AccessRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} diff --git a/pkg/cloudflare-go/firewall_example_test.go b/pkg/cloudflare-go/firewall_example_test.go new file mode 100644 index 000000000..571b61709 --- /dev/null +++ b/pkg/cloudflare-go/firewall_example_test.go @@ -0,0 +1,106 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ListZoneAccessRules_all() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch all access rules for a zone + response, err := api.ListZoneAccessRules(context.Background(), zoneID, cloudflare.AccessRule{}, 1) + if err != nil { + log.Fatal(err) + } + + for _, r := range response.Result { + fmt.Printf("%s: %s\n", r.Configuration.Value, r.Mode) + } +} + +func ExampleAPI_ListZoneAccessRules_filterByIP() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch only access rules whose target is 198.51.100.1 + localhost := cloudflare.AccessRule{ + Configuration: cloudflare.AccessRuleConfiguration{Target: "198.51.100.1"}, + } + response, err := api.ListZoneAccessRules(context.Background(), zoneID, localhost, 1) + if err != nil { + log.Fatal(err) + } + + for _, r := range response.Result { + fmt.Printf("%s: %s\n", r.Configuration.Value, r.Mode) + } +} + +func ExampleAPI_ListZoneAccessRules_filterByMode() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch access rules with an action of "block" + foo := cloudflare.AccessRule{ + Mode: "block", + } + response, err := api.ListZoneAccessRules(context.Background(), zoneID, foo, 1) + if err != nil { + log.Fatal(err) + } + + for _, r := range response.Result { + fmt.Printf("%s: %s\n", r.Configuration.Value, r.Mode) + } +} + +func ExampleAPI_ListZoneAccessRules_filterByNote() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch only access rules with notes containing "example" + foo := cloudflare.AccessRule{ + Notes: "example", + } + response, err := api.ListZoneAccessRules(context.Background(), zoneID, foo, 1) + if err != nil { + log.Fatal(err) + } + + for _, r := range response.Result { + fmt.Printf("%s: %s\n", r.Configuration.Value, r.Mode) + } +} diff --git a/pkg/cloudflare-go/firewall_rules.go b/pkg/cloudflare-go/firewall_rules.go new file mode 100644 index 000000000..edcd2a0b9 --- /dev/null +++ b/pkg/cloudflare-go/firewall_rules.go @@ -0,0 +1,244 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/goccy/go-json" +) + +// FirewallRule is the struct of the firewall rule. +type FirewallRule struct { + ID string `json:"id,omitempty"` + Paused bool `json:"paused"` + Description string `json:"description"` + Action string `json:"action"` + Priority interface{} `json:"priority"` + Filter Filter `json:"filter"` + Products []string `json:"products,omitempty"` + Ref string `json:"ref,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` +} + +// FirewallRulesDetailResponse is the API response for the firewall +// rules. +type FirewallRulesDetailResponse struct { + Result []FirewallRule `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// FirewallRuleResponse is the API response that is returned +// for requesting a single firewall rule on a zone. +type FirewallRuleResponse struct { + Result FirewallRule `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// FirewallRuleCreateParams contains required and optional params +// for creating a firewall rule. +type FirewallRuleCreateParams struct { + ID string `json:"id,omitempty"` + Paused bool `json:"paused"` + Description string `json:"description"` + Action string `json:"action"` + Priority interface{} `json:"priority"` + Filter Filter `json:"filter"` + Products []string `json:"products,omitempty"` + Ref string `json:"ref,omitempty"` +} + +// FirewallRuleUpdateParams contains required and optional params +// for updating a firewall rule. +type FirewallRuleUpdateParams struct { + ID string `json:"id"` + Paused bool `json:"paused"` + Description string `json:"description"` + Action string `json:"action"` + Priority interface{} `json:"priority"` + Filter Filter `json:"filter"` + Products []string `json:"products,omitempty"` + Ref string `json:"ref,omitempty"` +} + +type FirewallRuleListParams struct { + ResultInfo +} + +// FirewallRules returns all firewall rules. +// +// Automatically paginates all results unless `params.PerPage` and `params.Page` +// is set. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/get/#get-all-rules +func (api *API) FirewallRules(ctx context.Context, rc *ResourceContainer, params FirewallRuleListParams) ([]FirewallRule, *ResultInfo, error) { + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var firewallRules []FirewallRule + var fResponse FirewallRulesDetailResponse + for { + fResponse = FirewallRulesDetailResponse{} + uri := buildURI(fmt.Sprintf("/zones/%s/firewall/rules", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []FirewallRule{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &fResponse) + if err != nil { + return []FirewallRule{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal filters JSON data: %w", err) + } + + firewallRules = append(firewallRules, fResponse.Result...) + params.ResultInfo = fResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return firewallRules, &fResponse.ResultInfo, nil +} + +// FirewallRule returns a single firewall rule based on the ID. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/get/#get-by-rule-id +func (api *API) FirewallRule(ctx context.Context, rc *ResourceContainer, firewallRuleID string) (FirewallRule, error) { + uri := fmt.Sprintf("/zones/%s/firewall/rules/%s", rc.Identifier, firewallRuleID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return FirewallRule{}, err + } + + var firewallRuleResponse FirewallRuleResponse + err = json.Unmarshal(res, &firewallRuleResponse) + if err != nil { + return FirewallRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return firewallRuleResponse.Result, nil +} + +// CreateFirewallRules creates new firewall rules. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/post/ +func (api *API) CreateFirewallRules(ctx context.Context, rc *ResourceContainer, params []FirewallRuleCreateParams) ([]FirewallRule, error) { + uri := fmt.Sprintf("/zones/%s/firewall/rules", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return []FirewallRule{}, err + } + + var firewallRulesDetailResponse FirewallRulesDetailResponse + err = json.Unmarshal(res, &firewallRulesDetailResponse) + if err != nil { + return []FirewallRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return firewallRulesDetailResponse.Result, nil +} + +// UpdateFirewallRule updates a single firewall rule. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/put/#update-a-single-rule +func (api *API) UpdateFirewallRule(ctx context.Context, rc *ResourceContainer, params FirewallRuleUpdateParams) (FirewallRule, error) { + if params.ID == "" { + return FirewallRule{}, fmt.Errorf("firewall rule ID cannot be empty") + } + + uri := fmt.Sprintf("/zones/%s/firewall/rules/%s", rc.Identifier, params.ID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return FirewallRule{}, err + } + + var firewallRuleResponse FirewallRuleResponse + err = json.Unmarshal(res, &firewallRuleResponse) + if err != nil { + return FirewallRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return firewallRuleResponse.Result, nil +} + +// UpdateFirewallRules updates a single firewall rule. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/put/#update-multiple-rules +func (api *API) UpdateFirewallRules(ctx context.Context, rc *ResourceContainer, params []FirewallRuleUpdateParams) ([]FirewallRule, error) { + for _, firewallRule := range params { + if firewallRule.ID == "" { + return []FirewallRule{}, fmt.Errorf("firewall ID cannot be empty") + } + } + + uri := fmt.Sprintf("/zones/%s/firewall/rules", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return []FirewallRule{}, err + } + + var firewallRulesDetailResponse FirewallRulesDetailResponse + err = json.Unmarshal(res, &firewallRulesDetailResponse) + if err != nil { + return []FirewallRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return firewallRulesDetailResponse.Result, nil +} + +// DeleteFirewallRule deletes a single firewall rule. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/delete/#delete-a-single-rule +func (api *API) DeleteFirewallRule(ctx context.Context, rc *ResourceContainer, firewallRuleID string) error { + if firewallRuleID == "" { + return fmt.Errorf("firewall rule ID cannot be empty") + } + + uri := fmt.Sprintf("/zones/%s/firewall/rules/%s", rc.Identifier, firewallRuleID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} + +// DeleteFirewallRules deletes multiple firewall rules at once. +// +// API reference: https://developers.cloudflare.com/firewall/api/cf-firewall-rules/delete/#delete-multiple-rules +func (api *API) DeleteFirewallRules(ctx context.Context, rc *ResourceContainer, firewallRuleIDs []string) error { + v := url.Values{} + + for _, ruleID := range firewallRuleIDs { + v.Add("id", ruleID) + } + + uri := fmt.Sprintf("/zones/%s/firewall/rules?%s", rc.Identifier, v.Encode()) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/firewall_rules_test.go b/pkg/cloudflare-go/firewall_rules_test.go new file mode 100644 index 000000000..42db16192 --- /dev/null +++ b/pkg/cloudflare-go/firewall_rules_test.go @@ -0,0 +1,630 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFirewallRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result":[ + { + "id":"2ae338944d6143383c3cf05a7c80d984", + "paused":false, + "description":"allow uploads without waf", + "action":"bypass", + "products": ["waf"], + "priority":null, + "filter":{ + "id":"74217d7bd5ab435e84b1bd473bf4fb9f", + "expression":"http.request.uri.path matches \"^/upload$\"", + "paused":false, + "description":"/upload" + } + }, + { + "id":"4ae338944d6143378c3cf05a7c77d983", + "paused":false, + "description":"allow API traffic without challenge", + "action":"allow", + "priority":null, + "filter":{ + "id":"14217d7bd5ab435e84b1bd468bf4fb9f", + "expression":"http.request.uri.path matches \"^/api/.*$\"", + "paused":false, + "description":"/api" + } + }, + { + "id":"f2d427378e7542acb295380d352e2ebd", + "paused":false, + "description":"do not challenge login from office", + "action":"allow", + "priority":null, + "filter":{ + "id":"b7ff25282d394be7b945e23c7106ce8a", + "expression":"(http.request.uri.path ~ \"^.*/xmlrpc.php$\"", + "paused":false, + "description":"wordpress xmlrpc" + } + }, + { + "id":"cbf4b7a5a2a24e59a03044d6d44ceb09", + "paused":false, + "description":"challenge login", + "action":"challenge", + "priority":null, + "filter":{ + "id":"c218c536b2bd406f958f278cf0fa8c0f", + "expression":"(http.request.uri.path ~ \"^.*/wp-login.php$\"", + "paused":false, + "description":"Login" + } + }, + { + "id":"52161eb6af4241bb9d4b32394be72fdf", + "paused":false, + "description":"JS challenge site", + "action":"js_challenge", + "priority":null, + "filter":{ + "id":"f2a64520581a4209aab12187a0081364", + "expression":"not http.request.uri.path matches \"^/api/.*$\"", + "paused":false, + "description":"not /api" + } + } + ], + "success":true, + "errors":null, + "messages":null, + "result_info":{ + "page":1, + "per_page":25, + "count":5, + "total_count":5 + } + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules", handler) + want := []FirewallRule{ + { + ID: "2ae338944d6143383c3cf05a7c80d984", + Paused: false, + Description: "allow uploads without waf", + Action: "bypass", + Priority: nil, + Products: []string{"waf"}, + Filter: Filter{ + ID: "74217d7bd5ab435e84b1bd473bf4fb9f", + Expression: "http.request.uri.path matches \"^/upload$\"", + Paused: false, + Description: "/upload", + }, + }, + { + ID: "4ae338944d6143378c3cf05a7c77d983", + Paused: false, + Description: "allow API traffic without challenge", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "14217d7bd5ab435e84b1bd468bf4fb9f", + Expression: "http.request.uri.path matches \"^/api/.*$\"", + Paused: false, + Description: "/api", + }, + }, + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "(http.request.uri.path ~ \"^.*/xmlrpc.php$\"", + Paused: false, + Description: "wordpress xmlrpc", + }, + }, + { + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Paused: false, + Description: "challenge login", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Expression: "(http.request.uri.path ~ \"^.*/wp-login.php$\"", + Paused: false, + Description: "Login", + }, + }, + { + ID: "52161eb6af4241bb9d4b32394be72fdf", + Paused: false, + Description: "JS challenge site", + Action: "js_challenge", + Priority: nil, + Filter: Filter{ + ID: "f2a64520581a4209aab12187a0081364", + Expression: "not http.request.uri.path matches \"^/api/.*$\"", + Paused: false, + Description: "not /api", + }, + }, + } + + actual, _, err := client.FirewallRules(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), FirewallRuleListParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestFirewallRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result":{ + "id":"f2d427378e7542acb295380d352e2ebd", + "paused":false, + "description":"do not challenge login from office", + "action":"allow", + "priority":null, + "filter":{ + "id":"b7ff25282d394be7b945e23c7106ce8a", + "expression":"ip.src in {198.51.100.1} ~ \"^.*/login.php$\")", + "paused":false, + "description":"Login from office" + } + }, + "success":true, + "errors":null, + "messages":null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules/f2d427378e7542acb295380d352e2ebd", handler) + want := FirewallRule{ + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.1} ~ \"^.*/login.php$\")", + Paused: false, + Description: "Login from office", + }, + } + + actual, err := client.FirewallRule(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), "f2d427378e7542acb295380d352e2ebd") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSingleFirewallRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result":[ + { + "id":"f2d427378e7542acb295380d352e2ebd", + "paused":false, + "description":"do not challenge login from office", + "action":"allow", + "priority":null, + "filter":{ + "id":"b7ff25282d394be7b945e23c7106ce8a", + "expression":"ip.src in {198.51.100.0/24}", + "paused":false, + "description":"Login from office" + } + } + ], + "success":true, + "errors":null, + "messages":null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules", handler) + params := []FirewallRuleCreateParams{ + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.0/24}", + Paused: false, + Description: "Login from office", + }, + }, + } + + want := []FirewallRule{ + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.0/24}", + Paused: false, + Description: "Login from office", + }, + }, + } + + actual, err := client.CreateFirewallRules(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMultipleFirewallRules(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result":[ + { + "id":"f2d427378e7542acb295380d352e2ebd", + "paused":false, + "description":"do not challenge login from office", + "action":"allow", + "priority":null, + "filter":{ + "id":"b7ff25282d394be7b945e23c7106ce8a", + "expression":"ip.src in {198.51.100.0/24}", + "paused":false, + "description":"Login from office" + } + }, + { + "id":"cbf4b7a5a2a24e59a03044d6d44ceb09", + "paused":false, + "description":"challenge login", + "action":"challenge", + "priority":null, + "filter":{ + "id":"c218c536b2bd406f958f278cf0fa8c0f", + "expression":"(http.request.uri.path ~ \"^.*/wp-login.php$\")", + "paused":false, + "description":"Login" + } + } + ], + "success":true, + "errors":null, + "messages":null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules", handler) + params := []FirewallRuleCreateParams{ + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.0/24}", + Paused: false, + Description: "Login from office", + }, + }, + { + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Paused: false, + Description: "challenge login", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Expression: "(http.request.uri.path ~ \"^.*/wp-login.php$\")", + Paused: false, + Description: "Login", + }, + }, + } + + want := []FirewallRule{ + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.0/24}", + Paused: false, + Description: "Login from office", + }, + }, + { + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Paused: false, + Description: "challenge login", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Expression: "(http.request.uri.path ~ \"^.*/wp-login.php$\")", + Paused: false, + Description: "Login", + }, + }, + } + + actual, err := client.CreateFirewallRules(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateFirewallRuleWithMissingID(t *testing.T) { + setup() + defer teardown() + + want := FirewallRuleUpdateParams{ + ID: "", + Paused: false, + Description: "challenge site", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "f2a64520581a4209aab12187a0081364", + Expression: "not http.request.uri.path matches \"^/api/.*$\"", + Paused: false, + Description: "not /api", + }, + } + + _, err := client.UpdateFirewallRule(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), want) + assert.EqualError(t, err, "firewall rule ID cannot be empty") +} + +func TestUpdateSingleFirewallRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result":{ + "id":"52161eb6af4241bb9d4b32394be72fdf", + "paused":false, + "description":"challenge site", + "action":"challenge", + "priority":null, + "filter":{ + "id":"f2a64520581a4209aab12187a0081364", + "expression":"not http.request.uri.path matches \"^/api/.*$\"", + "paused":false, + "description":"not /api" + } + }, + "success":true, + "errors":null, + "messages":null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules/52161eb6af4241bb9d4b32394be72fdf", handler) + params := FirewallRuleUpdateParams{ + ID: "52161eb6af4241bb9d4b32394be72fdf", + Paused: false, + Description: "challenge site", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "f2a64520581a4209aab12187a0081364", + Expression: "not http.request.uri.path matches \"^/api/.*$\"", + Paused: false, + Description: "not /api", + }, + } + + want := FirewallRule{ + ID: "52161eb6af4241bb9d4b32394be72fdf", + Paused: false, + Description: "challenge site", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "f2a64520581a4209aab12187a0081364", + Expression: "not http.request.uri.path matches \"^/api/.*$\"", + Paused: false, + Description: "not /api", + }, + } + + actual, err := client.UpdateFirewallRule(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateMultipleFirewallRules(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result":[ + { + "id":"f2d427378e7542acb295380d352e2ebd", + "paused":false, + "description":"do not challenge login from office", + "action":"allow", + "priority":null, + "filter":{ + "id":"b7ff25282d394be7b945e23c7106ce8a", + "expression":"ip.src in {198.51.100.0/24}", + "paused":false, + "description":"Login from office" + } + }, + { + "id":"cbf4b7a5a2a24e59a03044d6d44ceb09", + "paused":false, + "description":"challenge login", + "action":"challenge", + "priority":null, + "filter":{ + "id":"c218c536b2bd406f958f278cf0fa8c0f", + "expression":"(http.request.uri.path ~ \"^.*/wp-login.php$\")", + "paused":false, + "description":"Login" + } + } + ], + "success":true, + "errors":null, + "messages":null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules", handler) + params := []FirewallRuleUpdateParams{ + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.0/24}", + Paused: false, + Description: "Login from office", + }, + }, + { + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Paused: false, + Description: "challenge login", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Expression: "(http.request.uri.path ~ \"^.*/wp-login.php$\")", + Paused: false, + Description: "Login", + }, + }, + } + + want := []FirewallRule{ + { + ID: "f2d427378e7542acb295380d352e2ebd", + Paused: false, + Description: "do not challenge login from office", + Action: "allow", + Priority: nil, + Filter: Filter{ + ID: "b7ff25282d394be7b945e23c7106ce8a", + Expression: "ip.src in {198.51.100.0/24}", + Paused: false, + Description: "Login from office", + }, + }, + { + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Paused: false, + Description: "challenge login", + Action: "challenge", + Priority: nil, + Filter: Filter{ + ID: "c218c536b2bd406f958f278cf0fa8c0f", + Expression: "(http.request.uri.path ~ \"^.*/wp-login.php$\")", + Paused: false, + Description: "Login", + }, + }, + } + + actual, err := client.UpdateFirewallRules(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteSingleFirewallRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [], + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/rules/f2d427378e7542acb295380d352e2ebd", handler) + + err := client.DeleteFirewallRule(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), "f2d427378e7542acb295380d352e2ebd") + assert.NoError(t, err) +} + +func TestDeleteFirewallRuleWithMissingID(t *testing.T) { + setup() + defer teardown() + + err := client.DeleteFirewallRule(context.Background(), ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), "") + assert.EqualError(t, err, "firewall rule ID cannot be empty") +} diff --git a/pkg/cloudflare-go/firewall_test.go b/pkg/cloudflare-go/firewall_test.go new file mode 100644 index 000000000..b8a1bcb82 --- /dev/null +++ b/pkg/cloudflare-go/firewall_test.go @@ -0,0 +1,406 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListAccessRules(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "92f17202ed8bd63d69a66b86a49a8f6b", + "notes": "This rule is on because of an event that occurred on date X", + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge" + ], + "mode": "challenge", + "configuration": { + "target": "ip", + "value": "198.51.100.4" + }, + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "scope": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "type": "user" + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 200 + } + }`) + } + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := &AccessRuleListResponse{ + Result: []AccessRule{{ + ID: "92f17202ed8bd63d69a66b86a49a8f6b", + Notes: "This rule is on because of an event that occurred on date X", + AllowedModes: []string{"whitelist", "block", "challenge", "js_challenge"}, + Mode: "challenge", + Configuration: AccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.4", + }, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Scope: AccessRuleScope{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + Type: "user", + }, + }}, + ResultInfo: ResultInfo{ + Page: 1, + PerPage: 20, + Count: 1, + Total: 200, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + accessRule := AccessRule{ + Notes: "my note", + Mode: "challenge", + Configuration: AccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.4", + }, + } + + mux.HandleFunc("/user/firewall/access_rules/rules", handler) + actual, err := client.ListUserAccessRules(context.Background(), accessRule, 1) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/access_rules/rules", handler) + actual, err = client.ListZoneAccessRules(context.Background(), testZoneID, accessRule, 1) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/accounts/"+testAccountID+"/firewall/access_rules/rules", handler) + actual, err = client.ListAccountAccessRules(context.Background(), testAccountID, accessRule, 1) + require.NoError(t, err) + assert.Equal(t, want, actual) +} + +func TestCreateAccessRule(t *testing.T) { + setup() + defer teardown() + + input := AccessRule{ + Mode: "challenge", + Configuration: AccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.4", + }, + Notes: "This rule is on because of an event that occurred on date X", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + var v AccessRule + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "92f17202ed8bd63d69a66b86a49a8f6b", + "notes": "This rule is on because of an event that occurred on date X", + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge" + ], + "mode": "challenge", + "configuration": { + "target": "ip", + "value": "198.51.100.4" + }, + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "scope": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "type": "user" + } + } + }`) + } + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := &AccessRuleResponse{ + Result: AccessRule{ + ID: "92f17202ed8bd63d69a66b86a49a8f6b", + Notes: input.Notes, + AllowedModes: []string{"whitelist", "block", "challenge", "js_challenge"}, + Mode: input.Mode, + Configuration: input.Configuration, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Scope: AccessRuleScope{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + Type: "user", + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + mux.HandleFunc("/user/firewall/access_rules/rules", handler) + actual, err := client.CreateUserAccessRule(context.Background(), input) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/access_rules/rules", handler) + actual, err = client.CreateZoneAccessRule(context.Background(), testZoneID, input) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/accounts/"+testAccountID+"/firewall/access_rules/rules", handler) + actual, err = client.CreateAccountAccessRule(context.Background(), testAccountID, input) + require.NoError(t, err) + assert.Equal(t, want, actual) +} + +func TestAccessRule(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "92f17202ed8bd63d69a66b86a49a8f6b", + "notes": "This rule is on because of an event that occurred on date X", + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge" + ], + "mode": "challenge", + "configuration": { + "target": "ip", + "value": "198.51.100.4" + }, + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "scope": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "type": "user" + } + } + }`) + } + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := &AccessRuleResponse{ + Result: AccessRule{ + ID: "92f17202ed8bd63d69a66b86a49a8f6b", + Notes: "This rule is on because of an event that occurred on date X", + AllowedModes: []string{"whitelist", "block", "challenge", "js_challenge"}, + Mode: "challenge", + Configuration: AccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.4", + }, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Scope: AccessRuleScope{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + Type: "user", + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + accessRuleID := "92f17202ed8bd63d69a66b86a49a8f6b" + + mux.HandleFunc("/user/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err := client.UserAccessRule(context.Background(), accessRuleID) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err = client.ZoneAccessRule(context.Background(), testZoneID, accessRuleID) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/accounts/"+testAccountID+"/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err = client.AccountAccessRule(context.Background(), testAccountID, accessRuleID) + require.NoError(t, err) + assert.Equal(t, want, actual) +} + +func TestUpdateAccessRule(t *testing.T) { + setup() + defer teardown() + + input := AccessRule{ + Mode: "challenge", + Notes: "This rule is on because of an event that occurred on date X", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + var v AccessRule + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "92f17202ed8bd63d69a66b86a49a8f6b", + "notes": "This rule is on because of an event that occurred on date X", + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge" + ], + "mode": "challenge", + "configuration": { + "target": "ip", + "value": "198.51.100.4" + }, + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "scope": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "type": "user" + } + } + }`) + } + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := &AccessRuleResponse{ + Result: AccessRule{ + ID: "92f17202ed8bd63d69a66b86a49a8f6b", + Notes: "This rule is on because of an event that occurred on date X", + AllowedModes: []string{"whitelist", "block", "challenge", "js_challenge"}, + Mode: "challenge", + Configuration: AccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.4", + }, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + Scope: AccessRuleScope{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + Type: "user", + }, + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + accessRuleID := "92f17202ed8bd63d69a66b86a49a8f6b" + + mux.HandleFunc("/user/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err := client.UpdateUserAccessRule(context.Background(), accessRuleID, input) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err = client.UpdateZoneAccessRule(context.Background(), testZoneID, accessRuleID, input) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/accounts/"+testAccountID+"/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err = client.UpdateAccountAccessRule(context.Background(), testAccountID, accessRuleID, input) + require.NoError(t, err) + assert.Equal(t, want, actual) +} + +func TestDeleteAccessRule(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "92f17202ed8bd63d69a66b86a49a8f6b" + } + }`) + } + + want := &AccessRuleResponse{ + Result: AccessRule{ + ID: "92f17202ed8bd63d69a66b86a49a8f6b", + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + accessRuleID := "92f17202ed8bd63d69a66b86a49a8f6b" + + mux.HandleFunc("/user/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err := client.DeleteUserAccessRule(context.Background(), accessRuleID) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err = client.DeleteZoneAccessRule(context.Background(), testZoneID, accessRuleID) + require.NoError(t, err) + assert.Equal(t, want, actual) + + mux.HandleFunc("/accounts/"+testAccountID+"/firewall/access_rules/rules/"+accessRuleID, handler) + actual, err = client.DeleteAccountAccessRule(context.Background(), testAccountID, accessRuleID) + require.NoError(t, err) + assert.Equal(t, want, actual) +} diff --git a/pkg/cloudflare-go/flake.lock b/pkg/cloudflare-go/flake.lock new file mode 100644 index 000000000..153b6ef7d --- /dev/null +++ b/pkg/cloudflare-go/flake.lock @@ -0,0 +1,540 @@ +{ + "nodes": { + "builtfilter": { + "inputs": { + "flox-floxpkgs": [ + "flox-floxpkgs" + ] + }, + "locked": { + "lastModified": 1679586988, + "narHash": "sha256-TkoF4E4yN40s042YG6DaOzk+vtbzsoey0lUO+tm03is=", + "owner": "flox", + "repo": "builtfilter", + "rev": "931a381bb96d909f51fcf25d7ca4fa9dcfb12aff", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "builtfilter-rs", + "repo": "builtfilter", + "type": "github" + } + }, + "capacitor": { + "inputs": { + "nixpkgs": "nixpkgs", + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1675110136, + "narHash": "sha256-83n/ZLBMoIkgYGy12F1hNaqMUgJsfkno5P1+sm9liOU=", + "owner": "flox", + "repo": "capacitor", + "rev": "9d4b9bce0f439e01fe2c2b2a1bfe08592a6204c4", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "v0", + "repo": "capacitor", + "type": "github" + } + }, + "capacitor_2": { + "inputs": { + "nixpkgs": [ + "flox-floxpkgs", + "nixpkgs", + "nixpkgs" + ], + "nixpkgs-lib": "nixpkgs-lib_2" + }, + "locked": { + "lastModified": 1675110136, + "narHash": "sha256-83n/ZLBMoIkgYGy12F1hNaqMUgJsfkno5P1+sm9liOU=", + "owner": "flox", + "repo": "capacitor", + "rev": "9d4b9bce0f439e01fe2c2b2a1bfe08592a6204c4", + "type": "github" + }, + "original": { + "owner": "flox", + "repo": "capacitor", + "type": "github" + } + }, + "catalog": { + "flake": false, + "locked": { + "lastModified": 1665076737, + "narHash": "sha256-S0bD7Z434Lvm7U4VHwvmxdTMrexdr72Yk6z0ExE3j7s=", + "owner": "flox", + "repo": "floxpkgs", + "rev": "bd8326c2fea27d01933eacb922f5ae70f97140c6", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "publish", + "repo": "floxpkgs", + "type": "github" + } + }, + "commitizen-src": { + "flake": false, + "locked": { + "lastModified": 1679149521, + "narHash": "sha256-F/fbBEwG7ijHELy4RnpvlXOPkTsXFxjALdK7UIGIzMo=", + "owner": "commitizen-tools", + "repo": "commitizen", + "rev": "378a42881891633d8a81939cb46426eb36ed01aa", + "type": "github" + }, + "original": { + "owner": "commitizen-tools", + "repo": "commitizen", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "locked": { + "lastModified": 1667395993, + "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flox": { + "inputs": { + "commitizen-src": "commitizen-src", + "flox-bash": [ + "flox-floxpkgs", + "flox-bash" + ], + "flox-floxpkgs": [ + "flox-floxpkgs" + ], + "shellHooks": "shellHooks" + }, + "locked": { + "lastModified": 1679679655, + "narHash": "sha256-zug5lg3CqDMkmtbort6CHFJgUPyzRip0tg7t3dbSTWo=", + "ref": "main", + "rev": "98a44ab06d9f9f5e4824fd3add2d32aef95e606f", + "revCount": 427, + "type": "git", + "url": "ssh://git@github.com/flox/flox" + }, + "original": { + "ref": "main", + "type": "git", + "url": "ssh://git@github.com/flox/flox" + } + }, + "flox-bash": { + "inputs": { + "flox-floxpkgs": [ + "flox-floxpkgs" + ] + }, + "locked": { + "lastModified": 1679665116, + "narHash": "sha256-v1qgb6rOVa9q8oIlps/Z4kw7+YQqN3AsScNEgy5xwGQ=", + "ref": "main", + "rev": "db7efcacb5f5935b286006288f23f931a573a918", + "revCount": 216, + "type": "git", + "url": "ssh://git@github.com/flox/flox-bash" + }, + "original": { + "ref": "main", + "type": "git", + "url": "ssh://git@github.com/flox/flox-bash" + } + }, + "flox-floxpkgs": { + "inputs": { + "builtfilter": "builtfilter", + "capacitor": "capacitor", + "catalog": "catalog", + "flox": "flox", + "flox-bash": "flox-bash", + "nixpkgs": "nixpkgs_3", + "tracelinks": "tracelinks" + }, + "locked": { + "lastModified": 1680131458, + "narHash": "sha256-B0xvpF2h5iotKAMP13l0VtsXRAPwKQLitmxKdBSWLQQ=", + "owner": "flox", + "repo": "floxpkgs", + "rev": "6d8216d24ce8959b599107ccff8b15c959540b1a", + "type": "github" + }, + "original": { + "owner": "flox", + "repo": "floxpkgs", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "flox-floxpkgs", + "flox", + "shellHooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1660459072, + "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1676973346, + "narHash": "sha256-rft8oGMocTAhUVqG3LW6I8K/Fo9ICGmNjRqaWTJwav0=", + "owner": "flox", + "repo": "nixpkgs", + "rev": "d0d55259081f0b97c828f38559cad899d351cad1", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "stable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1679791877, + "narHash": "sha256-tTV1Mf0hPWIMtqyU16Kd2JUBDWvfHlDC9pF57vcbgpQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "cc060ddbf652a532b54057081d5abd6144d01971", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-lib_2": { + "locked": { + "lastModified": 1679791877, + "narHash": "sha256-tTV1Mf0hPWIMtqyU16Kd2JUBDWvfHlDC9pF57vcbgpQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "cc060ddbf652a532b54057081d5abd6144d01971", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1678872516, + "narHash": "sha256-/E1YwtMtFAu2KUQKV/1+KFuReYPANM2Rzehk84VxVoc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9b8e5abb18324c7fe9f07cb100c3cd4a29cda8b8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-22.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable_2": { + "locked": { + "lastModified": 1676973346, + "narHash": "sha256-rft8oGMocTAhUVqG3LW6I8K/Fo9ICGmNjRqaWTJwav0=", + "owner": "flox", + "repo": "nixpkgs", + "rev": "d0d55259081f0b97c828f38559cad899d351cad1", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "stable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-staging": { + "locked": { + "lastModified": 1679262748, + "narHash": "sha256-DQCrrAFrkxijC6haUzOC5ZoFqpcv/tg2WxnyW3np1Cc=", + "owner": "flox", + "repo": "nixpkgs", + "rev": "60c1d71f2ba4c80178ec84523c2ca0801522e0a6", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "staging", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1679944645, + "narHash": "sha256-e5Qyoe11UZjVfgRfwNoSU57ZeKuEmjYb77B9IVW7L/M=", + "owner": "flox", + "repo": "nixpkgs", + "rev": "4bb072f0a8b267613c127684e099a70e1f6ff106", + "type": "github" + }, + "original": { + "owner": "flox", + "ref": "unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1678898370, + "narHash": "sha256-xTICr1j+uat5hk9FyuPOFGxpWHdJRibwZC+ATi0RbtE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac718d02867a84b42522a0ece52d841188208f2c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { + "inputs": { + "capacitor": "capacitor_2", + "flox": [ + "flox-floxpkgs", + "flox" + ], + "flox-bash": [ + "flox-floxpkgs", + "flox-bash" + ], + "flox-floxpkgs": [ + "flox-floxpkgs" + ], + "nixpkgs": [ + "flox-floxpkgs", + "nixpkgs", + "nixpkgs-stable" + ], + "nixpkgs-stable": "nixpkgs-stable_2", + "nixpkgs-staging": "nixpkgs-staging", + "nixpkgs-unstable": "nixpkgs-unstable", + "nixpkgs__flox__aarch64-darwin": "nixpkgs__flox__aarch64-darwin", + "nixpkgs__flox__aarch64-linux": "nixpkgs__flox__aarch64-linux", + "nixpkgs__flox__i686-linux": "nixpkgs__flox__i686-linux", + "nixpkgs__flox__x86_64-darwin": "nixpkgs__flox__x86_64-darwin", + "nixpkgs__flox__x86_64-linux": "nixpkgs__flox__x86_64-linux" + }, + "locked": { + "lastModified": 1680113891, + "narHash": "sha256-JiAmKV8ECf877cDbTum06NQ28n8FvwC4DYlCYzEVWxg=", + "owner": "flox", + "repo": "nixpkgs-flox", + "rev": "b7e7e40e2aa1ca2a44db440f5dc52213564af02f", + "type": "github" + }, + "original": { + "owner": "flox", + "repo": "nixpkgs-flox", + "type": "github" + } + }, + "nixpkgs__flox__aarch64-darwin": { + "flake": false, + "locked": { + "host": "catalog.floxsdlc.com", + "lastModified": 1680113678, + "narHash": "sha256-rCkwbly3gyvNcw21VBMXcf6EmF7crKZAS8Ylh2B5H+I=", + "owner": "flox", + "repo": "nixpkgs-flox", + "rev": "31d3b5fafdf504842e1e47a0c9b5888cf5dbae06", + "type": "github" + }, + "original": { + "host": "catalog.floxsdlc.com", + "owner": "flox", + "ref": "aarch64-darwin", + "repo": "nixpkgs-flox", + "type": "github" + } + }, + "nixpkgs__flox__aarch64-linux": { + "flake": false, + "locked": { + "host": "catalog.floxsdlc.com", + "lastModified": 1680113660, + "narHash": "sha256-ZofvvoatURsk1p5JvqajdrcO9iIT7UHjV2cb7io4FK0=", + "owner": "flox", + "repo": "nixpkgs-flox", + "rev": "8112f3ce4a227cd5088165d929761d877eea6709", + "type": "github" + }, + "original": { + "host": "catalog.floxsdlc.com", + "owner": "flox", + "ref": "aarch64-linux", + "repo": "nixpkgs-flox", + "type": "github" + } + }, + "nixpkgs__flox__i686-linux": { + "flake": false, + "locked": { + "host": "catalog.floxsdlc.com", + "lastModified": 1680113793, + "narHash": "sha256-xbjWq4lOP+K2772K8kDF17+CPoVyalqXYWyoH3x8au8=", + "owner": "flox", + "repo": "nixpkgs-flox", + "rev": "c189975540c856ac025be398761788c118ff5733", + "type": "github" + }, + "original": { + "host": "catalog.floxsdlc.com", + "owner": "flox", + "ref": "i686-linux", + "repo": "nixpkgs-flox", + "type": "github" + } + }, + "nixpkgs__flox__x86_64-darwin": { + "flake": false, + "locked": { + "host": "catalog.floxsdlc.com", + "lastModified": 1680113786, + "narHash": "sha256-HBNnTigb0MxCM6j6XmIH/0BRlUp7Rpe1et13TBf7xfM=", + "owner": "flox", + "repo": "nixpkgs-flox", + "rev": "5ab94a52855a10ad1ac0edd7277654bd587faf3e", + "type": "github" + }, + "original": { + "host": "catalog.floxsdlc.com", + "owner": "flox", + "ref": "x86_64-darwin", + "repo": "nixpkgs-flox", + "type": "github" + } + }, + "nixpkgs__flox__x86_64-linux": { + "flake": false, + "locked": { + "host": "catalog.floxsdlc.com", + "lastModified": 1680113768, + "narHash": "sha256-YnzWsglWn0TAeg2xQaVg8UFgALVU0TYZ/PdKV32EEtk=", + "owner": "flox", + "repo": "nixpkgs-flox", + "rev": "50ecffffa7c51a92a7a6bb9eb105625ed8b8f18d", + "type": "github" + }, + "original": { + "host": "catalog.floxsdlc.com", + "owner": "flox", + "ref": "x86_64-linux", + "repo": "nixpkgs-flox", + "type": "github" + } + }, + "root": { + "inputs": { + "flox-floxpkgs": "flox-floxpkgs" + } + }, + "shellHooks": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils", + "gitignore": "gitignore", + "nixpkgs": "nixpkgs_2", + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1678976941, + "narHash": "sha256-skNr08frCwN9NO+7I77MjOHHAw+L410/37JknNld+W4=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "32b1dbedfd77892a6e375737ef04d8efba634e9e", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "tracelinks": { + "inputs": { + "flox-floxpkgs": [ + "flox-floxpkgs" + ] + }, + "locked": { + "lastModified": 1674847293, + "narHash": "sha256-wPirp+8gIUpoAgE8zoXZalAJzCzcdDHKLEPOapJUtfs=", + "ref": "main", + "rev": "46108503f52bc2fcc948abb9b00fc65a13e5f5bd", + "revCount": 9, + "type": "git", + "url": "ssh://git@github.com/flox/tracelinks" + }, + "original": { + "ref": "main", + "type": "git", + "url": "ssh://git@github.com/flox/tracelinks" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/pkg/cloudflare-go/flake.nix b/pkg/cloudflare-go/flake.nix new file mode 100644 index 000000000..3fd85dbde --- /dev/null +++ b/pkg/cloudflare-go/flake.nix @@ -0,0 +1,7 @@ +{ + description = "A flox project"; + + inputs.flox-floxpkgs.url = "github:flox/floxpkgs"; + + outputs = args @ {flox-floxpkgs, ...}: flox-floxpkgs.project args (_: {}); +} diff --git a/pkg/cloudflare-go/flox.nix b/pkg/cloudflare-go/flox.nix new file mode 100644 index 000000000..3f82311a7 --- /dev/null +++ b/pkg/cloudflare-go/flox.nix @@ -0,0 +1,3 @@ +{ + packages.nixpkgs-flox.go_1_20 = { }; +} diff --git a/pkg/cloudflare-go/go.mod b/pkg/cloudflare-go/go.mod new file mode 100644 index 000000000..5524d6975 --- /dev/null +++ b/pkg/cloudflare-go/go.mod @@ -0,0 +1,32 @@ +module github.com/cloudflare/cloudflare-go + +go 1.19 + +require ( + github.com/goccy/go-json v0.10.3 + github.com/google/go-querystring v1.1.0 + github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/olekukonko/tablewriter v0.0.5 + github.com/stretchr/testify v1.9.0 + github.com/urfave/cli/v2 v2.27.2 + golang.org/x/net v0.26.0 + golang.org/x/time v0.5.0 +) + +require gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.5.8 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect + golang.org/x/text v0.16.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/pkg/cloudflare-go/go.sum b/pkg/cloudflare-go/go.sum new file mode 100644 index 000000000..b1a05597d --- /dev/null +++ b/pkg/cloudflare-go/go.sum @@ -0,0 +1,64 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cloudflare-go/healthchecks.go b/pkg/cloudflare-go/healthchecks.go new file mode 100644 index 000000000..cd5acd154 --- /dev/null +++ b/pkg/cloudflare-go/healthchecks.go @@ -0,0 +1,199 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// Healthcheck describes a Healthcheck object. +type Healthcheck struct { + ID string `json:"id,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Suspended bool `json:"suspended"` + Address string `json:"address"` + Retries int `json:"retries,omitempty"` + Timeout int `json:"timeout,omitempty"` + Interval int `json:"interval,omitempty"` + ConsecutiveSuccesses int `json:"consecutive_successes,omitempty"` + ConsecutiveFails int `json:"consecutive_fails,omitempty"` + Type string `json:"type,omitempty"` + CheckRegions []string `json:"check_regions"` + HTTPConfig *HealthcheckHTTPConfig `json:"http_config,omitempty"` + TCPConfig *HealthcheckTCPConfig `json:"tcp_config,omitempty"` + Status string `json:"status"` + FailureReason string `json:"failure_reason"` +} + +// HealthcheckHTTPConfig describes configuration for a HTTP healthcheck. +type HealthcheckHTTPConfig struct { + Method string `json:"method"` + Port uint16 `json:"port,omitempty"` + Path string `json:"path"` + ExpectedCodes []string `json:"expected_codes"` + ExpectedBody string `json:"expected_body"` + FollowRedirects bool `json:"follow_redirects"` + AllowInsecure bool `json:"allow_insecure"` + Header map[string][]string `json:"header"` +} + +// HealthcheckTCPConfig describes configuration for a TCP healthcheck. +type HealthcheckTCPConfig struct { + Method string `json:"method"` + Port uint16 `json:"port,omitempty"` +} + +// HealthcheckListResponse is the API response, containing an array of healthchecks. +type HealthcheckListResponse struct { + Response + Result []Healthcheck `json:"result"` + ResultInfo `json:"result_info"` +} + +// HealthcheckResponse is the API response, containing a single healthcheck. +type HealthcheckResponse struct { + Response + Result Healthcheck `json:"result"` +} + +// Healthchecks returns all healthchecks for a zone. +// +// API reference: https://api.cloudflare.com/#health-checks-list-health-checks +func (api *API) Healthchecks(ctx context.Context, zoneID string) ([]Healthcheck, error) { + uri := fmt.Sprintf("/zones/%s/healthchecks", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Healthcheck{}, err + } + var r HealthcheckListResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []Healthcheck{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// Healthcheck returns a single healthcheck by ID. +// +// API reference: https://api.cloudflare.com/#health-checks-health-check-details +func (api *API) Healthcheck(ctx context.Context, zoneID, healthcheckID string) (Healthcheck, error) { + uri := fmt.Sprintf("/zones/%s/healthchecks/%s", zoneID, healthcheckID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Healthcheck{}, err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Healthcheck{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateHealthcheck creates a new healthcheck in a zone. +// +// API reference: https://api.cloudflare.com/#health-checks-create-health-check +func (api *API) CreateHealthcheck(ctx context.Context, zoneID string, healthcheck Healthcheck) (Healthcheck, error) { + uri := fmt.Sprintf("/zones/%s/healthchecks", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, healthcheck) + if err != nil { + return Healthcheck{}, err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Healthcheck{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateHealthcheck updates an existing healthcheck. +// +// API reference: https://api.cloudflare.com/#health-checks-update-health-check +func (api *API) UpdateHealthcheck(ctx context.Context, zoneID string, healthcheckID string, healthcheck Healthcheck) (Healthcheck, error) { + uri := fmt.Sprintf("/zones/%s/healthchecks/%s", zoneID, healthcheckID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, healthcheck) + if err != nil { + return Healthcheck{}, err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Healthcheck{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteHealthcheck deletes a healthcheck in a zone. +// +// API reference: https://api.cloudflare.com/#health-checks-delete-health-check +func (api *API) DeleteHealthcheck(ctx context.Context, zoneID string, healthcheckID string) error { + uri := fmt.Sprintf("/zones/%s/healthchecks/%s", zoneID, healthcheckID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// CreateHealthcheckPreview creates a new preview of a healthcheck in a zone. +// +// API reference: https://api.cloudflare.com/#health-checks-create-preview-health-check +func (api *API) CreateHealthcheckPreview(ctx context.Context, zoneID string, healthcheck Healthcheck) (Healthcheck, error) { + uri := fmt.Sprintf("/zones/%s/healthchecks/preview", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, healthcheck) + if err != nil { + return Healthcheck{}, err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Healthcheck{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// HealthcheckPreview returns a single healthcheck preview by its ID. +// +// API reference: https://api.cloudflare.com/#health-checks-health-check-preview-details +func (api *API) HealthcheckPreview(ctx context.Context, zoneID, id string) (Healthcheck, error) { + uri := fmt.Sprintf("/zones/%s/healthchecks/preview/%s", zoneID, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Healthcheck{}, err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Healthcheck{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteHealthcheckPreview deletes a healthcheck preview in a zone if it exists. +// +// API reference: https://api.cloudflare.com/#health-checks-delete-preview-health-check +func (api *API) DeleteHealthcheckPreview(ctx context.Context, zoneID string, id string) error { + uri := fmt.Sprintf("/zones/%s/healthchecks/preview/%s", zoneID, id) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r HealthcheckResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} diff --git a/pkg/cloudflare-go/healthchecks_test.go b/pkg/cloudflare-go/healthchecks_test.go new file mode 100644 index 000000000..80c24a93c --- /dev/null +++ b/pkg/cloudflare-go/healthchecks_test.go @@ -0,0 +1,309 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + healthcheckID = "314d6b003029433741b94a7c9284915a" + healthcheckResponse = `{ + "id": "%s", + "name": "example-healthcheck", + "description": "Example Healthcheck", + "suspended": false, + "address": "www.example.com", + "retries": 2, + "timeout": 5, + "consecutive_successes": 2, + "consecutive_fails": 2, + "interval": 60, + "type": "HTTP", + "check_regions": [ + "WNAM" + ], + "http_config": { + "method": "GET", + "path": "/", + "port": 8443, + "expected_body": "", + "expected_codes": [ + "200" + ], + "follow_redirects": true, + "allow_insecure": false, + "header": { + "Host": [ + "www.example.com" + ] + } + }, + "tcp_config": null, + "created_on": "2019-01-13T12:20:00.12345Z", + "modified_on": "2019-01-13T12:20:00.12345Z", + "status": "unknown", + "failure_reason": "" + }` +) + +var ( + createdOn, _ = time.Parse(time.RFC3339, "2019-01-13T12:20:00.12345Z") + modifiedOn, _ = time.Parse(time.RFC3339, "2019-01-13T12:20:00.12345Z") + + expectedHealthcheck = Healthcheck{ + ID: "314d6b003029433741b94a7c9284915a", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Example Healthcheck", + Name: "example-healthcheck", + Suspended: false, + Address: "www.example.com", + Retries: 2, + ConsecutiveSuccesses: 2, + ConsecutiveFails: 2, + Timeout: 5, + Interval: 60, + Type: "HTTP", + CheckRegions: []string{"WNAM"}, + HTTPConfig: &HealthcheckHTTPConfig{ + Method: http.MethodGet, + Path: "/", + Port: 8443, + ExpectedBody: "", + ExpectedCodes: []string{"200"}, + FollowRedirects: true, + AllowInsecure: false, + Header: map[string][]string{ + "Host": {"www.example.com"}, + }, + }, + Status: "unknown", + FailureReason: "", + } +) + +func TestHealthchecks(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 25, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + } + `, fmt.Sprintf(healthcheckResponse, healthcheckID)) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks", handler) + want := []Healthcheck{expectedHealthcheck} + + actual, err := client.Healthchecks(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestHealthcheck(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": [], + "messages": [] + } + `, fmt.Sprintf(healthcheckResponse, healthcheckID)) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks/"+healthcheckID, handler) + want := expectedHealthcheck + + actual, err := client.Healthcheck(context.Background(), testZoneID, healthcheckID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateHealthcheck(t *testing.T) { + setup() + defer teardown() + newHealthcheck := Healthcheck{ + Name: "example-healthcheck", + Address: "www.example.com", + Suspended: false, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(healthcheckResponse, healthcheckID)) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks", handler) + want := expectedHealthcheck + + actual, err := client.CreateHealthcheck(context.Background(), testZoneID, newHealthcheck) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateHealthcheck(t *testing.T) { + setup() + defer teardown() + updatedHealthcheck := Healthcheck{ + Name: "example-healthcheck", + Address: "www.example.com", + HTTPConfig: &HealthcheckHTTPConfig{ + Path: "/newpath", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(healthcheckResponse, healthcheckID)) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks/"+healthcheckID, handler) + want := expectedHealthcheck + + actual, err := client.UpdateHealthcheck(context.Background(), testZoneID, healthcheckID, updatedHealthcheck) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteHealthcheck(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": null, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks/"+healthcheckID, handler) + + err := client.DeleteHealthcheck(context.Background(), testZoneID, healthcheckID) + assert.NoError(t, err) +} + +func TestCreateHealthcheckPreview(t *testing.T) { + setup() + defer teardown() + newHealthcheck := Healthcheck{ + Name: "example-healthcheck", + Address: "www.example.com", + Suspended: false, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(healthcheckResponse, healthcheckID)) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks/preview", handler) + want := expectedHealthcheck + + actual, err := client.CreateHealthcheckPreview(context.Background(), testZoneID, newHealthcheck) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestHealthcheckPreview(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": [], + "messages": [] + } + `, fmt.Sprintf(healthcheckResponse, healthcheckID)) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks/preview/"+healthcheckID, handler) + want := expectedHealthcheck + + actual, err := client.HealthcheckPreview(context.Background(), testZoneID, healthcheckID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteHealthcheckPreview(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": null, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/healthchecks/preview/"+healthcheckID, handler) + + err := client.DeleteHealthcheckPreview(context.Background(), testZoneID, healthcheckID) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/hyperdrive.go b/pkg/cloudflare-go/hyperdrive.go new file mode 100644 index 000000000..c1ddbc9a5 --- /dev/null +++ b/pkg/cloudflare-go/hyperdrive.go @@ -0,0 +1,214 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingHyperdriveConfigID = errors.New("required hyperdrive config id is missing") + ErrMissingHyperdriveConfigName = errors.New("required hyperdrive config name is missing") + ErrMissingHyperdriveConfigOriginDatabase = errors.New("required hyperdrive config origin database is missing") + ErrMissingHyperdriveConfigOriginPassword = errors.New("required hyperdrive config origin password is missing") + ErrMissingHyperdriveConfigOriginHost = errors.New("required hyperdrive config origin host is missing") + ErrMissingHyperdriveConfigOriginScheme = errors.New("required hyperdrive config origin scheme is missing") + ErrMissingHyperdriveConfigOriginUser = errors.New("required hyperdrive config origin user is missing") +) + +type HyperdriveConfig struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Origin HyperdriveConfigOrigin `json:"origin,omitempty"` + Caching HyperdriveConfigCaching `json:"caching,omitempty"` +} + +type HyperdriveConfigOrigin struct { + Database string `json:"database,omitempty"` + Password string `json:"password"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Scheme string `json:"scheme,omitempty"` + User string `json:"user,omitempty"` +} + +type HyperdriveConfigCaching struct { + Disabled *bool `json:"disabled,omitempty"` + MaxAge int `json:"max_age,omitempty"` + StaleWhileRevalidate int `json:"stale_while_revalidate,omitempty"` +} + +type HyperdriveConfigListResponse struct { + Response + Result []HyperdriveConfig `json:"result"` +} + +type CreateHyperdriveConfigParams struct { + Name string `json:"name"` + Origin HyperdriveConfigOrigin `json:"origin"` + Caching HyperdriveConfigCaching `json:"caching,omitempty"` +} + +type HyperdriveConfigResponse struct { + Response + Result HyperdriveConfig `json:"result"` +} + +type UpdateHyperdriveConfigParams struct { + HyperdriveID string `json:"-"` + Name string `json:"name"` + Origin HyperdriveConfigOrigin `json:"origin"` + Caching HyperdriveConfigCaching `json:"caching,omitempty"` +} + +type ListHyperdriveConfigParams struct{} + +// ListHyperdriveConfigs returns the Hyperdrive configs owned by an account. +// +// API reference: https://developers.cloudflare.com/api/operations/list-hyperdrive +func (api *API) ListHyperdriveConfigs(ctx context.Context, rc *ResourceContainer, params ListHyperdriveConfigParams) ([]HyperdriveConfig, error) { + if rc.Identifier == "" { + return []HyperdriveConfig{}, ErrMissingAccountID + } + + hResponse := HyperdriveConfigListResponse{} + uri := fmt.Sprintf("/accounts/%s/hyperdrive/configs", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []HyperdriveConfig{}, err + } + + err = json.Unmarshal(res, &hResponse) + if err != nil { + return []HyperdriveConfig{}, fmt.Errorf("failed to unmarshal filters JSON data: %w", err) + } + + return hResponse.Result, nil +} + +// CreateHyperdriveConfig creates a new Hyperdrive config. +// +// API reference: https://developers.cloudflare.com/api/operations/create-hyperdrive +func (api *API) CreateHyperdriveConfig(ctx context.Context, rc *ResourceContainer, params CreateHyperdriveConfigParams) (HyperdriveConfig, error) { + if rc.Identifier == "" { + return HyperdriveConfig{}, ErrMissingAccountID + } + + if params.Name == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigName + } + + uri := fmt.Sprintf("/accounts/%s/hyperdrive/configs", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return HyperdriveConfig{}, err + } + + var r HyperdriveConfigResponse + err = json.Unmarshal(res, &r) + if err != nil { + return HyperdriveConfig{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteHyperdriveConfig deletes a Hyperdrive config. +// +// API reference: https://developers.cloudflare.com/api/operations/delete-hyperdrive +func (api *API) DeleteHyperdriveConfig(ctx context.Context, rc *ResourceContainer, hyperdriveID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + if hyperdriveID == "" { + return ErrMissingHyperdriveConfigID + } + + uri := fmt.Sprintf("/accounts/%s/hyperdrive/configs/%s", rc.Identifier, hyperdriveID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} + +// GetHyperdriveConfig returns a single Hyperdrive config based on the ID. +// +// API reference: https://developers.cloudflare.com/api/operations/get-hyperdrive +func (api *API) GetHyperdriveConfig(ctx context.Context, rc *ResourceContainer, hyperdriveID string) (HyperdriveConfig, error) { + if rc.Identifier == "" { + return HyperdriveConfig{}, ErrMissingAccountID + } + + if hyperdriveID == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigID + } + + uri := fmt.Sprintf("/accounts/%s/hyperdrive/configs/%s", rc.Identifier, hyperdriveID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return HyperdriveConfig{}, err + } + + var r HyperdriveConfigResponse + err = json.Unmarshal(res, &r) + if err != nil { + return HyperdriveConfig{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateHyperdriveConfig updates a Hyperdrive config. +// +// API reference: https://developers.cloudflare.com/api/operations/update-hyperdrive +func (api *API) UpdateHyperdriveConfig(ctx context.Context, rc *ResourceContainer, params UpdateHyperdriveConfigParams) (HyperdriveConfig, error) { + if rc.Identifier == "" { + return HyperdriveConfig{}, ErrMissingAccountID + } + + if params.HyperdriveID == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigID + } + + if params.Origin.Database == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigOriginDatabase + } + + if params.Origin.Password == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigOriginPassword + } + + if params.Origin.Host == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigOriginHost + } + + if params.Origin.Scheme == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigOriginScheme + } + + if params.Origin.User == "" { + return HyperdriveConfig{}, ErrMissingHyperdriveConfigOriginUser + } + + uri := fmt.Sprintf("/accounts/%s/hyperdrive/configs/%s", rc.Identifier, params.HyperdriveID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return HyperdriveConfig{}, err + } + + var r HyperdriveConfigResponse + err = json.Unmarshal(res, &r) + if err != nil { + return HyperdriveConfig{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} diff --git a/pkg/cloudflare-go/hyperdrive_test.go b/pkg/cloudflare-go/hyperdrive_test.go new file mode 100644 index 000000000..c19378f14 --- /dev/null +++ b/pkg/cloudflare-go/hyperdrive_test.go @@ -0,0 +1,280 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + testHyperdriveConfigId = "6b7efc370ea34ded8327fa20698dfe3a" + testHyperdriveConfigName = "example-hyperdrive" +) + +func testHyperdriveConfig() HyperdriveConfig { + return HyperdriveConfig{ + ID: testHyperdriveConfigId, + Name: testHyperdriveConfigName, + Origin: HyperdriveConfigOrigin{ + Database: "postgres", + Host: "database.example.com", + Port: 5432, + Scheme: "postgres", + User: "postgres", + }, + Caching: HyperdriveConfigCaching{ + Disabled: BoolPtr(false), + MaxAge: 30, + StaleWhileRevalidate: 15, + }, + } +} + +func TestHyperdriveConfig_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/hyperdrive/configs", testAccountID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [{ + "id": "6b7efc370ea34ded8327fa20698dfe3a", + "caching": { + "disabled": false, + "max_age": 30, + "stale_while_revalidate": 15 + }, + "name": "example-hyperdrive", + "origin": { + "database": "postgres", + "host": "database.example.com", + "port": 5432, + "scheme": "postgres", + "user": "postgres" + } + }] + }`) + }) + + _, err := client.ListHyperdriveConfigs(context.Background(), AccountIdentifier(""), ListHyperdriveConfigParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + result, err := client.ListHyperdriveConfigs(context.Background(), AccountIdentifier(testAccountID), ListHyperdriveConfigParams{}) + if assert.NoError(t, err) { + assert.Equal(t, 1, len(result)) + assert.Equal(t, testHyperdriveConfig(), result[0]) + } +} + +func TestHyperdriveConfig_Get(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/hyperdrive/configs/%s", testAccountID, testHyperdriveConfigId), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "6b7efc370ea34ded8327fa20698dfe3a", + "caching": { + "disabled": false, + "max_age": 30, + "stale_while_revalidate": 15 + }, + "name": "example-hyperdrive", + "origin": { + "database": "postgres", + "host": "database.example.com", + "port": 5432, + "scheme": "postgres", + "user": "postgres" + } + } + }`) + }) + + _, err := client.GetHyperdriveConfig(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.GetHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingHyperdriveConfigID, err) + } + + result, err := client.GetHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), testHyperdriveConfigId) + if assert.NoError(t, err) { + assert.Equal(t, testHyperdriveConfig(), result) + } +} + +func TestHyperdriveConfig_Create(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/hyperdrive/configs", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "6b7efc370ea34ded8327fa20698dfe3a", + "caching": { + "disabled": false, + "max_age": 30, + "stale_while_revalidate": 15 + }, + "name": "example-hyperdrive", + "origin": { + "database": "postgres", + "host": "database.example.com", + "port": 5432, + "scheme": "postgres", + "user": "postgres" + } + } + }`) + }) + + _, err := client.CreateHyperdriveConfig(context.Background(), AccountIdentifier(""), CreateHyperdriveConfigParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.CreateHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), CreateHyperdriveConfigParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingHyperdriveConfigName, err) + } + + result, err := client.CreateHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), CreateHyperdriveConfigParams{ + Name: "example-hyperdrive", + Origin: HyperdriveConfigOrigin{ + Database: "postgres", + Password: "password", + Host: "database.example.com", + Port: 5432, + Scheme: "postgres", + User: "postgres", + }, + Caching: HyperdriveConfigCaching{ + Disabled: BoolPtr(false), + MaxAge: 30, + StaleWhileRevalidate: 15, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, testHyperdriveConfig(), result) + } +} + +func TestHyperdriveConfig_Delete(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/hyperdrive/configs/%s", testAccountID, testHyperdriveConfigId), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + }) + err := client.DeleteHyperdriveConfig(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + err = client.DeleteHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingHyperdriveConfigID, err) + } + + err = client.DeleteHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), testHyperdriveConfigId) + assert.NoError(t, err) +} + +func TestHyperdriveConfig_Update(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/hyperdrive/configs/%s", testAccountID, testHyperdriveConfigId), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "6b7efc370ea34ded8327fa20698dfe3a", + "caching": { + "disabled": false, + "max_age": 30, + "stale_while_revalidate": 15 + }, + "name": "example-hyperdrive", + "origin": { + "database": "postgres", + "host": "database.example.com", + "port": 5432, + "scheme": "postgres", + "user": "postgres" + } + } + }`) + }) + + _, err := client.UpdateHyperdriveConfig(context.Background(), AccountIdentifier(""), UpdateHyperdriveConfigParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.UpdateHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), UpdateHyperdriveConfigParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingHyperdriveConfigID, err) + } + + result, err := client.UpdateHyperdriveConfig(context.Background(), AccountIdentifier(testAccountID), UpdateHyperdriveConfigParams{ + HyperdriveID: "6b7efc370ea34ded8327fa20698dfe3a", + Name: "example-hyperdrive", + Origin: HyperdriveConfigOrigin{ + Database: "postgres", + Password: "password", + Host: "database.example.com", + Port: 5432, + Scheme: "postgres", + User: "postgres", + }, + Caching: HyperdriveConfigCaching{ + Disabled: BoolPtr(false), + MaxAge: 30, + StaleWhileRevalidate: 15, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, testHyperdriveConfig(), result) + } +} diff --git a/pkg/cloudflare-go/images.go b/pkg/cloudflare-go/images.go new file mode 100644 index 000000000..35208ea80 --- /dev/null +++ b/pkg/cloudflare-go/images.go @@ -0,0 +1,401 @@ +package cloudflare + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrInvalidImagesAPIVersion = errors.New("invalid images API version") + ErrMissingImageID = errors.New("required image ID missing") +) + +type ImagesAPIVersion string + +const ( + ImagesAPIVersionV1 ImagesAPIVersion = "v1" + ImagesAPIVersionV2 ImagesAPIVersion = "v2" +) + +// Image represents a Cloudflare Image. +type Image struct { + ID string `json:"id"` + Filename string `json:"filename"` + Meta map[string]interface{} `json:"meta,omitempty"` + RequireSignedURLs bool `json:"requireSignedURLs"` + Variants []string `json:"variants"` + Uploaded time.Time `json:"uploaded"` +} + +// UploadImageParams is the data required for an Image Upload request. +type UploadImageParams struct { + File io.ReadCloser + URL string + Name string + RequireSignedURLs bool + Metadata map[string]interface{} +} + +// write writes the image upload data to a multipart writer, so +// it can be used in an HTTP request. +func (b UploadImageParams) write(mpw *multipart.Writer) error { + if b.File == nil && b.URL == "" { + return errors.New("a file or url to upload must be specified") + } + + if b.File != nil { + name := b.Name + part, err := mpw.CreateFormFile("file", name) + if err != nil { + return err + } + _, err = io.Copy(part, b.File) + if err != nil { + _ = b.File.Close() + return err + } + _ = b.File.Close() + } + + if b.URL != "" { + err := mpw.WriteField("url", b.URL) + if err != nil { + return err + } + } + + // According to the Cloudflare docs, this field defaults to false. + // For simplicity, we will only send it if the value is true, however + // if the default is changed to true, this logic will need to be updated. + if b.RequireSignedURLs { + err := mpw.WriteField("requireSignedURLs", "true") + if err != nil { + return err + } + } + + if b.Metadata != nil { + part, err := mpw.CreateFormField("metadata") + if err != nil { + return err + } + err = json.NewEncoder(part).Encode(b.Metadata) + if err != nil { + return err + } + } + + return nil +} + +// UpdateImageParams is the data required for an UpdateImage request. +type UpdateImageParams struct { + ID string `json:"-"` + RequireSignedURLs bool `json:"requireSignedURLs"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// CreateImageDirectUploadURLParams is the data required for a CreateImageDirectUploadURL request. +type CreateImageDirectUploadURLParams struct { + Version ImagesAPIVersion `json:"-"` + Expiry *time.Time `json:"expiry,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + RequireSignedURLs *bool `json:"requireSignedURLs,omitempty"` +} + +// ImageDirectUploadURLResponse is the API response for a direct image upload url. +type ImageDirectUploadURLResponse struct { + Result ImageDirectUploadURL `json:"result"` + Response +} + +// ImageDirectUploadURL . +type ImageDirectUploadURL struct { + ID string `json:"id"` + UploadURL string `json:"uploadURL"` +} + +// ImagesListResponse is the API response for listing all images. +type ImagesListResponse struct { + Result struct { + Images []Image `json:"images"` + } `json:"result"` + Response +} + +// ImageDetailsResponse is the API response for getting an image's details. +type ImageDetailsResponse struct { + Result Image `json:"result"` + Response +} + +// ImagesStatsResponse is the API response for image stats. +type ImagesStatsResponse struct { + Result struct { + Count ImagesStatsCount `json:"count"` + } `json:"result"` + Response +} + +// ImagesStatsCount is the stats attached to a ImagesStatsResponse. +type ImagesStatsCount struct { + Current int64 `json:"current"` + Allowed int64 `json:"allowed"` +} + +type ListImagesParams struct { + ResultInfo +} + +// UploadImage uploads a single image. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-upload-an-image-using-a-single-http-request +func (api *API) UploadImage(ctx context.Context, rc *ResourceContainer, params UploadImageParams) (Image, error) { + if rc.Level != AccountRouteLevel { + return Image{}, ErrRequiredAccountLevelResourceContainer + } + + if params.File != nil && params.URL != "" { + return Image{}, errors.New("file and url uploads are mutually exclusive and can only be performed individually") + } + + uri := fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier) + + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + if err := params.write(w); err != nil { + _ = w.Close() + return Image{}, fmt.Errorf("error writing multipart body: %w", err) + } + _ = w.Close() + + res, err := api.makeRequestContextWithHeaders( + ctx, + http.MethodPost, + uri, + body, + http.Header{ + "Accept": []string{"application/json"}, + "Content-Type": []string{w.FormDataContentType()}, + }, + ) + if err != nil { + return Image{}, err + } + + var imageDetailsResponse ImageDetailsResponse + err = json.Unmarshal(res, &imageDetailsResponse) + if err != nil { + return Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return imageDetailsResponse.Result, nil +} + +// UpdateImage updates an existing image's metadata. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-update-image +func (api *API) UpdateImage(ctx context.Context, rc *ResourceContainer, params UpdateImageParams) (Image, error) { + if rc.Level != AccountRouteLevel { + return Image{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, params.ID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return Image{}, err + } + + var imageDetailsResponse ImageDetailsResponse + err = json.Unmarshal(res, &imageDetailsResponse) + if err != nil { + return Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return imageDetailsResponse.Result, nil +} + +var imagesMultipartBoundary = "----CloudflareImagesGoClientBoundary" + +// CreateImageDirectUploadURL creates an authenticated direct upload url. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-create-authenticated-direct-upload-url +func (api *API) CreateImageDirectUploadURL(ctx context.Context, rc *ResourceContainer, params CreateImageDirectUploadURLParams) (ImageDirectUploadURL, error) { + if rc.Level != AccountRouteLevel { + return ImageDirectUploadURL{}, ErrRequiredAccountLevelResourceContainer + } + + if params.Version != "" && params.Version != ImagesAPIVersionV1 && params.Version != ImagesAPIVersionV2 { + return ImageDirectUploadURL{}, ErrInvalidImagesAPIVersion + } + + var err error + var uri string + var res []byte + switch params.Version { + case ImagesAPIVersionV2: + uri = fmt.Sprintf("/%s/%s/images/%s/direct_upload", rc.Level, rc.Identifier, params.Version) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.SetBoundary(imagesMultipartBoundary); err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("error setting multipart boundary") + } + + if *params.RequireSignedURLs { + if err = writer.WriteField("requireSignedURLs", "true"); err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("error writing requireSignedURLs field: %w", err) + } + } + if !params.Expiry.IsZero() { + if err = writer.WriteField("expiry", params.Expiry.Format(time.RFC3339)); err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("error writing expiry field: %w", err) + } + } + if params.Metadata != nil { + var metadataBytes []byte + if metadataBytes, err = json.Marshal(params.Metadata); err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("error marshalling metadata to JSON: %w", err) + } + if err = writer.WriteField("metadata", string(metadataBytes)); err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("error writing metadata field: %w", err) + } + } + if err = writer.Close(); err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("error closing multipart writer: %w", err) + } + + res, err = api.makeRequestContextWithHeaders( + ctx, + http.MethodPost, + uri, + body, + http.Header{ + "Accept": []string{"application/json"}, + "Content-Type": []string{writer.FormDataContentType()}, + }, + ) + case ImagesAPIVersionV1: + case "": + uri = fmt.Sprintf("/%s/%s/images/%s/direct_upload", rc.Level, rc.Identifier, ImagesAPIVersionV1) + res, err = api.makeRequestContext(ctx, http.MethodPost, uri, params) + default: + return ImageDirectUploadURL{}, ErrInvalidImagesAPIVersion + } + + if err != nil { + return ImageDirectUploadURL{}, err + } + + var imageDirectUploadURLResponse ImageDirectUploadURLResponse + err = json.Unmarshal(res, &imageDirectUploadURLResponse) + if err != nil { + return ImageDirectUploadURL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return imageDirectUploadURLResponse.Result, nil +} + +// ListImages lists all images. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-list-images +func (api *API) ListImages(ctx context.Context, rc *ResourceContainer, params ListImagesParams) ([]Image, error) { + uri := buildURI(fmt.Sprintf("/accounts/%s/images/v1", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Image{}, err + } + + var imagesListResponse ImagesListResponse + err = json.Unmarshal(res, &imagesListResponse) + if err != nil { + return []Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return imagesListResponse.Result.Images, nil +} + +// GetImage gets the details of an uploaded image. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-image-details +func (api *API) GetImage(ctx context.Context, rc *ResourceContainer, id string) (Image, error) { + if rc.Level != AccountRouteLevel { + return Image{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, id) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Image{}, err + } + + var imageDetailsResponse ImageDetailsResponse + err = json.Unmarshal(res, &imageDetailsResponse) + if err != nil { + return Image{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return imageDetailsResponse.Result, nil +} + +// GetBaseImage gets the base image used to derive variants. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-base-image +func (api *API) GetBaseImage(ctx context.Context, rc *ResourceContainer, id string) ([]byte, error) { + if rc.Level != AccountRouteLevel { + return []byte{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/images/v1/%s/blob", rc.Identifier, id) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + return res, nil +} + +// DeleteImage deletes an image. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-delete-image +func (api *API) DeleteImage(ctx context.Context, rc *ResourceContainer, id string) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/images/v1/%s", rc.Identifier, id) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + return nil +} + +// GetImagesStats gets an account's statistics for Cloudflare Images. +// +// API Reference: https://api.cloudflare.com/#cloudflare-images-images-usage-statistics +func (api *API) GetImagesStats(ctx context.Context, rc *ResourceContainer) (ImagesStatsCount, error) { + if rc.Level != AccountRouteLevel { + return ImagesStatsCount{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/images/v1/stats", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ImagesStatsCount{}, err + } + + var imagesStatsResponse ImagesStatsResponse + err = json.Unmarshal(res, &imagesStatsResponse) + if err != nil { + return ImagesStatsCount{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return imagesStatsResponse.Result.Count, nil +} diff --git a/pkg/cloudflare-go/images_test.go b/pkg/cloudflare-go/images_test.go new file mode 100644 index 000000000..f986a3e68 --- /dev/null +++ b/pkg/cloudflare-go/images_test.go @@ -0,0 +1,539 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func timeMustParse(layout, value string) time.Time { + t, err := time.Parse(layout, value) + if err != nil { + panic(err) + } + return t +} + +var expectedImageStruct = Image{ + ID: "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + Filename: "avatar.png", + Meta: map[string]interface{}{ + "meta": "metaID", + }, + RequireSignedURLs: true, + Variants: []string{ + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail", + }, + Uploaded: timeMustParse(time.RFC3339, "2014-01-02T02:20:00Z"), +} + +func TestUploadImage(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + u, err := parseImageMultipartUpload(r) + if !assert.NoError(t, err) { + w.WriteHeader(http.StatusBadRequest) + return + } + assert.Equal(t, u.RequireSignedURLs, true) + assert.Equal(t, u.Metadata, map[string]interface{}{"meta": "metaID"}) + assert.Equal(t, u.File, []byte("this is definitely an image")) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "filename": "avatar.png", + "meta": { + "meta": "metaID" + }, + "requireSignedURLs": true, + "variants": [ + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail" + ], + "uploaded": "2014-01-02T02:20:00Z" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1", handler) + want := expectedImageStruct + + actual, err := client.UploadImage(context.Background(), AccountIdentifier(testAccountID), UploadImageParams{ + File: fakeFile{ + Buffer: bytes.NewBufferString("this is definitely an image"), + }, + Name: "avatar.png", + RequireSignedURLs: true, + Metadata: map[string]interface{}{ + "meta": "metaID", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUploadImageByUrl(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + u, err := parseImageMultipartUpload(r) + if !assert.NoError(t, err) { + w.WriteHeader(http.StatusBadRequest) + return + } + assert.Equal(t, u.RequireSignedURLs, true) + assert.Equal(t, u.Metadata, map[string]interface{}{"meta": "metaID"}) + assert.Equal(t, u.Url, "https://www.images-elsewhere.com/avatar.png") + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "filename": "avatar.png", + "meta": { + "meta": "metaID" + }, + "requireSignedURLs": true, + "variants": [ + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail" + ], + "uploaded": "2014-01-02T02:20:00Z" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1", handler) + want := expectedImageStruct + + actual, err := client.UploadImage(context.Background(), AccountIdentifier(testAccountID), UploadImageParams{ + URL: "https://www.images-elsewhere.com/avatar.png", + RequireSignedURLs: true, + Metadata: map[string]interface{}{ + "meta": "metaID", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateImage(t *testing.T) { + setup() + defer teardown() + + input := UpdateImageParams{ + RequireSignedURLs: true, + Metadata: map[string]interface{}{ + "meta": "metaID", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + var v UpdateImageParams + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "filename": "avatar.png", + "meta": { + "meta": "metaID" + }, + "requireSignedURLs": true, + "variants": [ + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail" + ], + "uploaded": "2014-01-02T02:20:00Z" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA", handler) + want := expectedImageStruct + + actual, err := client.UpdateImage(context.Background(), AccountIdentifier(testAccountID), UpdateImageParams{ + ID: "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + RequireSignedURLs: true, + Metadata: map[string]interface{}{ + "meta": "metaID", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateImageDirectUploadURL(t *testing.T) { + setup() + defer teardown() + + expiry := time.Now().UTC().Add(30 * time.Minute) + input := CreateImageDirectUploadURLParams{ + Expiry: &expiry, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + var v CreateImageDirectUploadURLParams + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "uploadURL": "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/direct_upload", handler) + want := ImageDirectUploadURL{ + ID: "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + UploadURL: "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383", + } + + actual, err := client.CreateImageDirectUploadURL(context.Background(), AccountIdentifier(testAccountID), input) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateImageConflictingTypes(t *testing.T) { + setup() + defer teardown() + + _, err := client.UploadImage(context.Background(), AccountIdentifier(testAccountID), UploadImageParams{ + URL: "https://example.com/foo.jpg", + File: fakeFile{ + Buffer: bytes.NewBufferString("this is definitely an image"), + }, + }) + + assert.Error(t, err) +} + +func TestCreateImageDirectUploadURLV2(t *testing.T) { + setup() + defer teardown() + + exp := time.Now().UTC().Add(30 * time.Minute) + metadata := map[string]interface{}{ + "metaKey1": "metaValue1", + "metaKey2": "metaValue2", + } + requireSignedURLs := true + input := CreateImageDirectUploadURLParams{ + Version: ImagesAPIVersionV2, + Expiry: &exp, + Metadata: metadata, + RequireSignedURLs: &requireSignedURLs, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + require.Equal(t, + fmt.Sprintf("multipart/form-data; boundary=%s", imagesMultipartBoundary), + r.Header.Get("Content-Type"), + ) + require.NoError(t, r.ParseMultipartForm(32<<20)) + require.Equal(t, exp.Format(time.RFC3339), r.Form.Get("expiry")) + require.Equal(t, "true", r.Form.Get("requireSignedURLs")) + marshalledMetadata, err := json.Marshal(metadata) + require.NoError(t, err) + require.Equal(t, string(marshalledMetadata), r.Form.Get("metadata")) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "uploadURL": "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v2/direct_upload", handler) + want := ImageDirectUploadURL{ + ID: "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + UploadURL: "https://upload.imagedelivery.net/fgr33htrthytjtyereifjewoi338272s7w1383", + } + + actual, err := client.CreateImageDirectUploadURL(context.Background(), AccountIdentifier(testAccountID), input) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListImages(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "images": [ + { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "filename": "avatar.png", + "meta": { + "meta": "metaID" + }, + "requireSignedURLs": true, + "variants": [ + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail" + ], + "uploaded": "2014-01-02T02:20:00Z" + } + ] + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1", handler) + want := []Image{expectedImageStruct} + + actual, err := client.ListImages(context.Background(), AccountIdentifier(testAccountID), ListImagesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestImageDetails(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ZxR0pLaXRldlBtaFhhO2FiZGVnaA", + "filename": "avatar.png", + "meta": { + "meta": "metaID" + }, + "requireSignedURLs": true, + "variants": [ + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/hero", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/original", + "https://imagedelivery.net/MTt4OTd0b0w5aj/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/thumbnail" + ], + "uploaded": "2014-01-02T02:20:00Z" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA", handler) + want := expectedImageStruct + + actual, err := client.GetImage(context.Background(), AccountIdentifier(testAccountID), "ZxR0pLaXRldlBtaFhhO2FiZGVnaA") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestBaseImage(t *testing.T) { + setup() + defer teardown() + + 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", "image/png") + _, _ = w.Write([]byte{}) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA/blob", handler) + want := []byte{} + + actual, err := client.GetBaseImage(context.Background(), AccountIdentifier(testAccountID), "ZxR0pLaXRldlBtaFhhO2FiZGVnaA") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteImage(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {} + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/ZxR0pLaXRldlBtaFhhO2FiZGVnaA", handler) + + err := client.DeleteImage(context.Background(), AccountIdentifier(testAccountID), "ZxR0pLaXRldlBtaFhhO2FiZGVnaA") + require.NoError(t, err) +} + +type fakeFile struct { + *bytes.Buffer +} + +func (f fakeFile) Close() error { + return nil +} + +type imageMultipartUpload struct { + // this is for testing, never read an entire file into memory, + // especially when being done on a per-http request basis. + File []byte + Url string + RequireSignedURLs bool + Metadata map[string]interface{} +} + +func parseImageMultipartUpload(r *http.Request) (imageMultipartUpload, error) { + var u imageMultipartUpload + mdBytes, err := getImageFormValue(r, "metadata") + if err != nil { + if !strings.HasPrefix(err.Error(), "no value found for key") { + return u, err + } + } + if mdBytes != nil { + err = json.Unmarshal(mdBytes, &u.Metadata) + if err != nil { + return u, err + } + } + + rsuBytes, err := getImageFormValue(r, "requireSignedURLs") + if err != nil { + if !strings.HasPrefix(err.Error(), "no value found for key") { + return u, err + } + } + if rsuBytes != nil { + if bytes.Equal(rsuBytes, []byte("true")) { + u.RequireSignedURLs = true + } + } + + if _, ok := r.MultipartForm.Value["url"]; ok { + urlBytes, err := getImageFormValue(r, "url") + if err != nil { + if !strings.HasPrefix(err.Error(), "no value found for key") { + return u, err + } + } + if urlBytes != nil { + u.Url = string(urlBytes) + } + } else { + f, _, err := r.FormFile("file") + if err != nil { + return u, err + } + defer f.Close() + + u.File, err = io.ReadAll(f) + if err != nil { + return u, err + } + } + + return u, nil +} + +// See getFormValue for more information, the only difference between +// getFormValue and this one is the max memory. +func getImageFormValue(r *http.Request, key string) ([]byte, error) { + err := r.ParseMultipartForm(10 * 1024 * 1024) + if err != nil { + return nil, err + } + + if values, ok := r.MultipartForm.Value[key]; ok { + return []byte(values[0]), nil + } + + if fileHeaders, ok := r.MultipartForm.File[key]; ok { + file, err := fileHeaders[0].Open() + if err != nil { + return nil, err + } + return io.ReadAll(file) + } + + return nil, fmt.Errorf("no value found for key %v", key) +} diff --git a/pkg/cloudflare-go/images_variants.go b/pkg/cloudflare-go/images_variants.go new file mode 100644 index 000000000..294955116 --- /dev/null +++ b/pkg/cloudflare-go/images_variants.go @@ -0,0 +1,163 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type ImagesVariant struct { + ID string `json:"id,omitempty"` + NeverRequireSignedURLs *bool `json:"neverRequireSignedURLs,omitempty"` + Options ImagesVariantsOptions `json:"options,omitempty"` +} + +type ImagesVariantsOptions struct { + Fit string `json:"fit,omitempty"` + Height int `json:"height,omitempty"` + Metadata string `json:"metadata,omitempty"` + Width int `json:"width,omitempty"` +} + +type ListImageVariantsParams struct{} + +type ListImagesVariantsResponse struct { + Result ListImageVariantsResult `json:"result,omitempty"` + Response +} + +type ListImageVariantsResult struct { + ImagesVariants map[string]ImagesVariant `json:"variants,omitempty"` +} + +type CreateImagesVariantParams struct { + ID string `json:"id,omitempty"` + NeverRequireSignedURLs *bool `json:"neverRequireSignedURLs,omitempty"` + Options ImagesVariantsOptions `json:"options,omitempty"` +} + +type UpdateImagesVariantParams struct { + ID string `json:"-"` + NeverRequireSignedURLs *bool `json:"neverRequireSignedURLs,omitempty"` + Options ImagesVariantsOptions `json:"options,omitempty"` +} + +type ImagesVariantResult struct { + Variant ImagesVariant `json:"variant,omitempty"` +} + +type ImagesVariantResponse struct { + Result ImagesVariantResult `json:"result,omitempty"` + Response +} + +// Lists existing variants. +// +// API Reference: https://developers.cloudflare.com/api/operations/cloudflare-images-variants-list-variants +func (api *API) ListImagesVariants(ctx context.Context, rc *ResourceContainer, params ListImageVariantsParams) (ListImageVariantsResult, error) { + if rc.Identifier == "" { + return ListImageVariantsResult{}, ErrMissingAccountID + } + + baseURL := fmt.Sprintf("/accounts/%s/images/v1/variants", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return ListImageVariantsResult{}, err + } + + var listImageVariantsResponse ListImagesVariantsResponse + err = json.Unmarshal(res, &listImageVariantsResponse) + if err != nil { + return ListImageVariantsResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return listImageVariantsResponse.Result, nil +} + +// Fetch details for a single variant. +// +// API Reference: https://developers.cloudflare.com/api/operations/cloudflare-images-variants-variant-details +func (api *API) GetImagesVariant(ctx context.Context, rc *ResourceContainer, variantID string) (ImagesVariant, error) { + if rc.Identifier == "" { + return ImagesVariant{}, ErrMissingAccountID + } + + baseURL := fmt.Sprintf("/accounts/%s/images/v1/variants/%s", rc.Identifier, variantID) + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return ImagesVariant{}, err + } + + var imagesVariantDetailResponse ImagesVariantResponse + err = json.Unmarshal(res, &imagesVariantDetailResponse) + if err != nil { + return ImagesVariant{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return imagesVariantDetailResponse.Result.Variant, nil +} + +// Specify variants that allow you to resize images for different use cases. +// +// API Reference: https://developers.cloudflare.com/api/operations/cloudflare-images-variants-create-a-variant +func (api *API) CreateImagesVariant(ctx context.Context, rc *ResourceContainer, params CreateImagesVariantParams) (ImagesVariant, error) { + if rc.Identifier == "" { + return ImagesVariant{}, ErrMissingAccountID + } + + baseURL := fmt.Sprintf("/accounts/%s/images/v1/variants", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, baseURL, params) + if err != nil { + return ImagesVariant{}, err + } + + var createImagesVariantResponse ImagesVariantResponse + err = json.Unmarshal(res, &createImagesVariantResponse) + if err != nil { + return ImagesVariant{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return createImagesVariantResponse.Result.Variant, nil +} + +// Deleting a variant purges the cache for all images associated with the variant. +// +// API Reference: https://developers.cloudflare.com/api/operations/cloudflare-images-variants-variant-details +func (api *API) DeleteImagesVariant(ctx context.Context, rc *ResourceContainer, variantID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + baseURL := fmt.Sprintf("/accounts/%s/images/v1/variants/%s", rc.Identifier, variantID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, baseURL, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} + +// Updating a variant purges the cache for all images associated with the variant. +// +// API Reference: https://developers.cloudflare.com/api/operations/cloudflare-images-variants-variant-details +func (api *API) UpdateImagesVariant(ctx context.Context, rc *ResourceContainer, params UpdateImagesVariantParams) (ImagesVariant, error) { + if rc.Identifier == "" { + return ImagesVariant{}, ErrMissingAccountID + } + + baseURL := fmt.Sprintf("/accounts/%s/images/v1/variants/%s", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, baseURL, params) + if err != nil { + return ImagesVariant{}, err + } + + var imagesVariantDetailResponse ImagesVariantResponse + err = json.Unmarshal(res, &imagesVariantDetailResponse) + if err != nil { + return ImagesVariant{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return imagesVariantDetailResponse.Result.Variant, nil +} diff --git a/pkg/cloudflare-go/images_variants_test.go b/pkg/cloudflare-go/images_variants_test.go new file mode 100644 index 000000000..994eaad2c --- /dev/null +++ b/pkg/cloudflare-go/images_variants_test.go @@ -0,0 +1,195 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + testImagesVariantID = "hero" +) + +func TestImageVariants_List(t *testing.T) { + setup() + defer teardown() + + 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, loadFixture("images_variants", "single_list")) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/images/v1/variants", handler) + + want := ListImageVariantsResult{ + ImagesVariants: map[string]ImagesVariant{ + "hero": { + ID: "hero", + NeverRequireSignedURLs: BoolPtr(true), + Options: ImagesVariantsOptions{ + Fit: "scale-down", + Height: 768, + Width: 1366, + Metadata: "none", + }, + }, + }, + } + + got, err := client.ListImagesVariants(context.Background(), AccountIdentifier(testAccountID), ListImageVariantsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestImageVariants_Delete(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method '%s', got %s", http.MethodDelete, r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {} + }`) + } + + url := fmt.Sprintf("/accounts/%s/images/v1/variants/%s", testAccountID, testImagesVariantID) + mux.HandleFunc(url, handler) + + err := client.DeleteImagesVariant(context.Background(), AccountIdentifier(testAccountID), testImagesVariantID) + assert.NoError(t, err) +} + +func TestImagesVariants_Get(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method '%s', got %s", http.MethodGet, r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, loadFixture("images_variants", "single_full")) + } + + url := fmt.Sprintf("/accounts/%s/images/v1/variants/%s", testAccountID, testImagesVariantID) + mux.HandleFunc(url, handler) + + want := ImagesVariant{ + ID: "hero", + NeverRequireSignedURLs: BoolPtr(true), + Options: ImagesVariantsOptions{ + Fit: "scale-down", + Height: 768, + Width: 1366, + Metadata: "none", + }, + } + + got, err := client.GetImagesVariant(context.Background(), AccountIdentifier(testAccountID), testImagesVariantID) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestImagesVariants_Create(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method '%s', got %s", http.MethodPost, r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, loadFixture("images_variants", "single_full")) + } + + url := fmt.Sprintf("/accounts/%s/images/v1/variants", testAccountID) + mux.HandleFunc(url, handler) + + want := ImagesVariant{ + ID: "hero", + NeverRequireSignedURLs: BoolPtr(true), + Options: ImagesVariantsOptions{ + Fit: "scale-down", + Height: 768, + Width: 1366, + Metadata: "none", + }, + } + + got, err := client.CreateImagesVariant(context.Background(), AccountIdentifier(testAccountID), CreateImagesVariantParams{ + ID: testImagesVariantID, + NeverRequireSignedURLs: BoolPtr(true), + Options: ImagesVariantsOptions{ + Fit: "scale-down", + Height: 768, + Width: 1366, + Metadata: "none", + }, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestImagesVariants_Update(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method '%s', got %s", http.MethodPatch, r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, loadFixture("images_variants", "single_full")) + } + + url := fmt.Sprintf("/accounts/%s/images/v1/variants/%s", testAccountID, testImagesVariantID) + mux.HandleFunc(url, handler) + + want := ImagesVariant{ + ID: "hero", + NeverRequireSignedURLs: BoolPtr(true), + Options: ImagesVariantsOptions{ + Fit: "scale-down", + Height: 768, + Width: 1366, + Metadata: "none", + }, + } + + got, err := client.UpdateImagesVariant(context.Background(), AccountIdentifier(testAccountID), UpdateImagesVariantParams{ + ID: "hero", + NeverRequireSignedURLs: BoolPtr(true), + Options: ImagesVariantsOptions{ + Fit: "scale-down", + Height: 768, + Width: 1366, + Metadata: "none", + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestImageVariants_MissingAccountId(t *testing.T) { + _, err := client.ListImagesVariants(context.Background(), AccountIdentifier(""), ListImageVariantsParams{}) + assert.Equal(t, ErrMissingAccountID, err) + + _, err = client.GetImagesVariant(context.Background(), AccountIdentifier(""), testImagesVariantID) + assert.Equal(t, ErrMissingAccountID, err) + + _, err = client.CreateImagesVariant(context.Background(), AccountIdentifier(""), CreateImagesVariantParams{}) + assert.Equal(t, ErrMissingAccountID, err) + + err = client.DeleteImagesVariant(context.Background(), AccountIdentifier(""), testImagesVariantID) + assert.Equal(t, ErrMissingAccountID, err) + + _, err = client.UpdateImagesVariant(context.Background(), AccountIdentifier(""), UpdateImagesVariantParams{}) + assert.Equal(t, ErrMissingAccountID, err) +} diff --git a/pkg/cloudflare-go/intelligence_asn.go b/pkg/cloudflare-go/intelligence_asn.go new file mode 100644 index 000000000..f66cdff68 --- /dev/null +++ b/pkg/cloudflare-go/intelligence_asn.go @@ -0,0 +1,101 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// ErrMissingASN is for when ASN is required but not set. +var ErrMissingASN = errors.New("required asn missing") + +// ASNInfo represents ASN information. +type ASNInfo struct { + ASN int `json:"asn"` + Description string `json:"description"` + Country string `json:"country"` + Type string `json:"type"` + DomainCount int `json:"domain_count"` + TopDomains []string `json:"top_domains"` +} + +// IntelligenceASNOverviewParameters represents parameters for an ASN request. +type IntelligenceASNOverviewParameters struct { + AccountID string + ASN int +} + +// IntelligenceASNResponse represents an API response for ASN info. +type IntelligenceASNResponse struct { + Response + Result []ASNInfo `json:"result,omitempty"` +} + +// IntelligenceASNSubnetsParameters represents parameters for an ASN subnet request. +type IntelligenceASNSubnetsParameters struct { + AccountID string + ASN int +} + +// IntelligenceASNSubnetResponse represents an ASN subnet API response. +type IntelligenceASNSubnetResponse struct { + ASN int `json:"asn,omitempty"` + IPCountTotal int `json:"ip_count_total,omitempty"` + Subnets []string `json:"subnets,omitempty"` + Count int `json:"count,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` +} + +// IntelligenceASNOverview get overview for an ASN number +// +// API Reference: https://api.cloudflare.com/#asn-intelligence-get-asn-overview +func (api *API) IntelligenceASNOverview(ctx context.Context, params IntelligenceASNOverviewParameters) ([]ASNInfo, error) { + if params.AccountID == "" { + return []ASNInfo{}, ErrMissingAccountID + } + + if params.ASN == 0 { + return []ASNInfo{}, ErrMissingASN + } + + uri := fmt.Sprintf("/accounts/%s/intel/asn/%d", params.AccountID, params.ASN) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []ASNInfo{}, err + } + + var asnInfoResponse IntelligenceASNResponse + if err := json.Unmarshal(res, &asnInfoResponse); err != nil { + return []ASNInfo{}, err + } + return asnInfoResponse.Result, nil +} + +// IntelligenceASNSubnets gets all subnets of an ASN +// +// API Reference: https://api.cloudflare.com/#asn-intelligence-get-asn-subnets +func (api *API) IntelligenceASNSubnets(ctx context.Context, params IntelligenceASNSubnetsParameters) (IntelligenceASNSubnetResponse, error) { + if params.AccountID == "" { + return IntelligenceASNSubnetResponse{}, ErrMissingAccountID + } + + if params.ASN == 0 { + return IntelligenceASNSubnetResponse{}, ErrMissingASN + } + + uri := fmt.Sprintf("/accounts/%s/intel/asn/%d/subnets", params.AccountID, params.ASN) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return IntelligenceASNSubnetResponse{}, err + } + + var intelligenceASNSubnetResponse IntelligenceASNSubnetResponse + if err := json.Unmarshal(res, &intelligenceASNSubnetResponse); err != nil { + return IntelligenceASNSubnetResponse{}, err + } + return intelligenceASNSubnetResponse, nil +} diff --git a/pkg/cloudflare-go/intelligence_asn_test.go b/pkg/cloudflare-go/intelligence_asn_test.go new file mode 100644 index 000000000..f66fffbe8 --- /dev/null +++ b/pkg/cloudflare-go/intelligence_asn_test.go @@ -0,0 +1,111 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testASNNumber = "13335" + +func TestIntelligence_ASNOverview(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/asn/"+testASNNumber, 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "asn": 13335, + "description": "CLOUDFLARENET", + "country": "US", + "type": "hosting_provider", + "domain_count": 1, + "top_domains": [ + "example.com" + ] + } + ] +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceASNOverview(context.Background(), IntelligenceASNOverviewParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + // Make sure missing ASN is thrown + _, err = client.IntelligenceASNOverview(context.Background(), IntelligenceASNOverviewParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingASN, err) + } + want := ASNInfo{ + ASN: 13335, + Description: "CLOUDFLARENET", + Country: "US", + Type: "hosting_provider", + DomainCount: 1, + TopDomains: []string{"example.com"}, + } + + out, err := client.IntelligenceASNOverview(context.Background(), IntelligenceASNOverviewParameters{AccountID: testAccountID, ASN: 13335}) + if assert.NoError(t, err) { + assert.Equal(t, len(out), 1, "Length of ASN overview not expected") + assert.Equal(t, out[0], want, "structs not equal") + } +} + +func TestIntelligence_ASNSubnet(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/asn/"+testASNNumber+"/subnets", 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, `{ + "asn": 13335, + "ip_count_total": 1, + "subnets": [ + "192.0.2.0/24", + "2001:DB8::/32" + ], + "count": 1, + "page": 1, + "per_page": 20 +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceASNSubnets(context.Background(), IntelligenceASNSubnetsParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing ASN is thrown + _, err = client.IntelligenceASNSubnets(context.Background(), IntelligenceASNSubnetsParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingASN, err) + } + + want := IntelligenceASNSubnetResponse{ + ASN: 13335, + IPCountTotal: 1, + Subnets: []string{"192.0.2.0/24", "2001:DB8::/32"}, + Count: 1, + Page: 1, + PerPage: 20, + } + + out, err := client.IntelligenceASNSubnets(context.Background(), IntelligenceASNSubnetsParameters{AccountID: testAccountID, ASN: 13335}) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} diff --git a/pkg/cloudflare-go/intelligence_domain.go b/pkg/cloudflare-go/intelligence_domain.go new file mode 100644 index 000000000..b12b2c8ee --- /dev/null +++ b/pkg/cloudflare-go/intelligence_domain.go @@ -0,0 +1,177 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// ErrMissingDomain is for when domain is needed but not given. +var ErrMissingDomain = errors.New("required domain missing") + +// DomainDetails represents details for a domain. +type DomainDetails struct { + Domain string `json:"domain"` + ResolvesToRefs []ResolvesToRefs `json:"resolves_to_refs"` + PopularityRank int `json:"popularity_rank"` + Application Application `json:"application"` + RiskTypes []interface{} `json:"risk_types"` + ContentCategories []ContentCategories `json:"content_categories"` + AdditionalInformation AdditionalInformation `json:"additional_information"` +} + +// ResolvesToRefs what a domain resolves to. +type ResolvesToRefs struct { + ID string `json:"id"` + Value string `json:"value"` +} + +type Application struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// ContentCategories represents the categories for a domain. +type ContentCategories struct { + ID int `json:"id"` + SuperCategoryID int `json:"super_category_id"` + Name string `json:"name"` +} + +// AdditionalInformation represents any additional information for a domain. +type AdditionalInformation struct { + SuspectedMalwareFamily string `json:"suspected_malware_family"` +} + +// DomainHistory represents the history for a domain. +type DomainHistory struct { + Domain string `json:"domain"` + Categorizations []Categorizations `json:"categorizations"` +} + +// Categories represents categories for a domain. +type Categories struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// Categorizations represents the categories and when those categories were set. +type Categorizations struct { + Categories []Categories `json:"categories"` + Start string `json:"start"` + End string `json:"end"` +} + +// GetDomainDetailsParameters represent the parameters for a domain details request. +type GetDomainDetailsParameters struct { + AccountID string `url:"-"` + Domain string `url:"domain,omitempty"` +} + +// DomainDetailsResponse represents an API response for domain details. +type DomainDetailsResponse struct { + Response + Result DomainDetails `json:"result,omitempty"` +} + +// GetBulkDomainDetailsParameters represents the parameters for bulk domain details request. +type GetBulkDomainDetailsParameters struct { + AccountID string `url:"-"` + Domains []string `url:"domain"` +} + +// GetBulkDomainDetailsResponse represents an API response for bulk domain details. +type GetBulkDomainDetailsResponse struct { + Response + Result []DomainDetails `json:"result,omitempty"` +} + +// GetDomainHistoryParameters represents the parameters for domain history request. +type GetDomainHistoryParameters struct { + AccountID string `url:"-"` + Domain string `url:"domain,omitempty"` +} + +// GetDomainHistoryResponse represents an API response for domain history. +type GetDomainHistoryResponse struct { + Response + Result []DomainHistory `json:"result,omitempty"` +} + +// IntelligenceDomainDetails gets domain information. +// +// API Reference: https://api.cloudflare.com/#domain-intelligence-get-domain-details +func (api *API) IntelligenceDomainDetails(ctx context.Context, params GetDomainDetailsParameters) (DomainDetails, error) { + if params.AccountID == "" { + return DomainDetails{}, ErrMissingAccountID + } + + if params.Domain == "" { + return DomainDetails{}, ErrMissingDomain + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel/domain", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return DomainDetails{}, err + } + + var domainDetails DomainDetailsResponse + if err := json.Unmarshal(res, &domainDetails); err != nil { + return DomainDetails{}, err + } + return domainDetails.Result, nil +} + +// IntelligenceBulkDomainDetails gets domain information for a list of domains. +// +// API Reference: https://api.cloudflare.com/#domain-intelligence-get-multiple-domain-details +func (api *API) IntelligenceBulkDomainDetails(ctx context.Context, params GetBulkDomainDetailsParameters) ([]DomainDetails, error) { + if params.AccountID == "" { + return []DomainDetails{}, ErrMissingAccountID + } + + if len(params.Domains) == 0 { + return []DomainDetails{}, ErrMissingDomain + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel/domain/bulk", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DomainDetails{}, err + } + + var domainDetails GetBulkDomainDetailsResponse + if err := json.Unmarshal(res, &domainDetails); err != nil { + return []DomainDetails{}, err + } + return domainDetails.Result, nil +} + +// IntelligenceDomainHistory get domain history for given domain +// +// API Reference: https://api.cloudflare.com/#domain-history-get-domain-history +func (api *API) IntelligenceDomainHistory(ctx context.Context, params GetDomainHistoryParameters) ([]DomainHistory, error) { + if params.AccountID == "" { + return []DomainHistory{}, ErrMissingAccountID + } + + if params.Domain == "" { + return []DomainHistory{}, ErrMissingDomain + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel/domain-history", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []DomainHistory{}, err + } + + var domainDetails GetDomainHistoryResponse + if err := json.Unmarshal(res, &domainDetails); err != nil { + return []DomainHistory{}, err + } + return domainDetails.Result, nil +} diff --git a/pkg/cloudflare-go/intelligence_domain_test.go b/pkg/cloudflare-go/intelligence_domain_test.go new file mode 100644 index 000000000..185502bdb --- /dev/null +++ b/pkg/cloudflare-go/intelligence_domain_test.go @@ -0,0 +1,227 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntelligence_DomainDetails(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/domain", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "domain": "cloudflare.com", + "resolves_to_refs": [ + { + "id": "ipv4-addr--baa568ec-6efe-5902-be55-0663833db537", + "value": "192.0.2.0" + } + ], + "popularity_rank": 18, + "application": { + "id": 1370, + "name": "CLOUDFLARE" + }, + "risk_types": [], + "content_categories": [ + { + "id": 155, + "super_category_id": 26, + "name": "Technology" + } + ], + "additional_information": { + "suspected_malware_family": "" + } + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceDomainDetails(context.Background(), GetDomainDetailsParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + // Make sure missing domain is thrown + _, err = client.IntelligenceDomainDetails(context.Background(), GetDomainDetailsParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + want := DomainDetails{ + Domain: "cloudflare.com", + ResolvesToRefs: []ResolvesToRefs{ + { + ID: "ipv4-addr--baa568ec-6efe-5902-be55-0663833db537", + Value: "192.0.2.0", + }, + }, + PopularityRank: 18, + Application: Application{ + ID: 1370, + Name: "CLOUDFLARE", + }, + RiskTypes: []interface{}{}, + ContentCategories: []ContentCategories{ + { + ID: 155, + SuperCategoryID: 26, + Name: "Technology", + }, + }, + AdditionalInformation: AdditionalInformation{ + SuspectedMalwareFamily: "", + }, + } + + out, err := client.IntelligenceDomainDetails(context.Background(), GetDomainDetailsParameters{AccountID: testAccountID, Domain: "cloudflare.com"}) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} + +func TestIntelligence_BulkDomainDetails(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/domain/bulk", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "domain": "cloudflare.com", + "popularity_rank": 18, + "application": { + "id": 1370, + "name": "CLOUDFLARE" + }, + "risk_types": [], + "content_categories": [ + { + "id": 155, + "super_category_id": 26, + "name": "Technology" + } + ], + "additional_information": { + "suspected_malware_family": "" + } + } + ] +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceBulkDomainDetails(context.Background(), GetBulkDomainDetailsParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + // Make sure missing domain is thrown + _, err = client.IntelligenceBulkDomainDetails(context.Background(), GetBulkDomainDetailsParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + want := DomainDetails{ + Domain: "cloudflare.com", + PopularityRank: 18, + Application: Application{ + ID: 1370, + Name: "CLOUDFLARE", + }, + RiskTypes: []interface{}{}, + ContentCategories: []ContentCategories{ + { + ID: 155, + SuperCategoryID: 26, + Name: "Technology", + }, + }, + AdditionalInformation: AdditionalInformation{ + SuspectedMalwareFamily: "", + }, + } + + out, err := client.IntelligenceBulkDomainDetails(context.Background(), GetBulkDomainDetailsParameters{AccountID: testAccountID, Domains: []string{"cloudflare.com"}}) + if assert.NoError(t, err) { + assert.Equal(t, len(out), 1, "Length of ASN overview not expected") + assert.Equal(t, out[0], want, "structs not equal") + } +} + +func TestIntelligence_DomainHistory(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/domain-history", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "domain": "cloudflare.com", + "categorizations": [ + { + "categories": [ + { + "id": 155, + "name": "Technology" + } + ], + "start": "2021-04-01", + "end": "2021-04-30" + } + ] + } + ] +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceDomainHistory(context.Background(), GetDomainHistoryParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + // Make sure missing domain is thrown + _, err = client.IntelligenceDomainHistory(context.Background(), GetDomainHistoryParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + want := DomainHistory{ + Domain: "cloudflare.com", + Categorizations: []Categorizations{ + { + Categories: []Categories{ + { + ID: 155, + Name: "Technology", + }, + }, + Start: "2021-04-01", + End: "2021-04-30", + }, + }, + } + + out, err := client.IntelligenceDomainHistory(context.Background(), GetDomainHistoryParameters{AccountID: testAccountID, Domain: "cloudflare.com"}) + if assert.NoError(t, err) { + assert.Equal(t, len(out), 1, "Length of Domain History not expected") + assert.Equal(t, out[0], want, "structs not equal") + } +} diff --git a/pkg/cloudflare-go/intelligence_ip.go b/pkg/cloudflare-go/intelligence_ip.go new file mode 100644 index 000000000..47c74b30e --- /dev/null +++ b/pkg/cloudflare-go/intelligence_ip.go @@ -0,0 +1,156 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// IPIntelligence represents IP intelligence information. +type IPIntelligence struct { + IP string `json:"ip"` + BelongsToRef BelongsToRef `json:"belongs_to_ref"` + RiskTypes []RiskTypes `json:"risk_types"` +} + +// BelongsToRef represents information about who owns an IP address. +type BelongsToRef struct { + ID string `json:"id"` + Value int `json:"value"` + Type string `json:"type"` + Country string `json:"country"` + Description string `json:"description"` +} + +// RiskTypes represent risk types for an IP. +type RiskTypes struct { + ID int `json:"id"` + SuperCategoryID int `json:"super_category_id"` + Name string `json:"name"` +} + +// IPPassiveDNS represent DNS response. +type IPPassiveDNS struct { + ReverseRecords []ReverseRecords `json:"reverse_records,omitempty"` + Count int `json:"count,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` +} + +// ReverseRecords represent records for passive DNS. +type ReverseRecords struct { + FirstSeen string `json:"first_seen,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +// IPIntelligenceParameters represents parameters for an IP Intelligence request. +type IPIntelligenceParameters struct { + AccountID string `url:"-"` + IPv4 string `url:"ipv4,omitempty"` + IPv6 string `url:"ipv6,omitempty"` +} + +// IPIntelligenceResponse represents an IP Intelligence API response. +type IPIntelligenceResponse struct { + Response + Result []IPIntelligence `json:"result,omitempty"` +} + +// IPIntelligenceListParameters represents the parameters for an IP list request. +type IPIntelligenceListParameters struct { + AccountID string +} + +// IPIntelligenceItem represents an item in an IP list. +type IPIntelligenceItem struct { + ID int `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +// IPIntelligenceListResponse represents the response for an IP list API response. +type IPIntelligenceListResponse struct { + Response + Result []IPIntelligenceItem `json:"result,omitempty"` +} + +// IPIntelligencePassiveDNSParameters represents the parameters for a passive DNS request. +type IPIntelligencePassiveDNSParameters struct { + AccountID string `url:"-"` + IPv4 string `url:"ipv4,omitempty"` + Start string `url:"start,omitempty"` + End string `url:"end,omitempty"` + Page int `url:"page,omitempty"` + PerPage int `url:"per_page,omitempty"` +} + +// IPIntelligencePassiveDNSResponse represents a passive API response. +type IPIntelligencePassiveDNSResponse struct { + Response + Result IPPassiveDNS `json:"result,omitempty"` +} + +// IntelligenceGetIPOverview gets information about ipv4 or ipv6 address. +// +// API Reference: https://api.cloudflare.com/#ip-intelligence-get-ip-overview +func (api *API) IntelligenceGetIPOverview(ctx context.Context, params IPIntelligenceParameters) ([]IPIntelligence, error) { + if params.AccountID == "" { + return []IPIntelligence{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel/ip", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []IPIntelligence{}, err + } + + var ipDetails IPIntelligenceResponse + if err := json.Unmarshal(res, &ipDetails); err != nil { + return []IPIntelligence{}, err + } + return ipDetails.Result, nil +} + +// IntelligenceGetIPList gets intelligence ip-lists. +// +// API Reference: https://api.cloudflare.com/#ip-list-get-ip-lists +func (api *API) IntelligenceGetIPList(ctx context.Context, params IPIntelligenceListParameters) ([]IPIntelligenceItem, error) { + if params.AccountID == "" { + return []IPIntelligenceItem{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/intel/ip-list", params.AccountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []IPIntelligenceItem{}, err + } + + var ipListItem IPIntelligenceListResponse + if err := json.Unmarshal(res, &ipListItem); err != nil { + return []IPIntelligenceItem{}, err + } + return ipListItem.Result, nil +} + +// IntelligencePassiveDNS gets a history of DNS for an ip. +// +// API Reference: https://api.cloudflare.com/#passive-dns-by-ip-get-passive-dns-by-ip +func (api *API) IntelligencePassiveDNS(ctx context.Context, params IPIntelligencePassiveDNSParameters) (IPPassiveDNS, error) { + if params.AccountID == "" { + return IPPassiveDNS{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel/dns", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return IPPassiveDNS{}, err + } + + var passiveDNS IPIntelligencePassiveDNSResponse + if err := json.Unmarshal(res, &passiveDNS); err != nil { + return IPPassiveDNS{}, err + } + return passiveDNS.Result, nil +} diff --git a/pkg/cloudflare-go/intelligence_ip_test.go b/pkg/cloudflare-go/intelligence_ip_test.go new file mode 100644 index 000000000..b8389fec5 --- /dev/null +++ b/pkg/cloudflare-go/intelligence_ip_test.go @@ -0,0 +1,163 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntelligence_GetIPOverview(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/ip", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "ip": "192.0.2.0", + "belongs_to_ref": { + "id": "autonomous-system--2fa28d71-3549-5a38-af05-770b79ad6ea8", + "value": 13335, + "type": "hosting_provider", + "country": "US", + "description": "CLOUDFLARENET" + }, + "risk_types": [ + { + "id": 131, + "super_category_id": 21, + "name": "Phishing" + } + ] + } + ] +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceGetIPOverview(context.Background(), IPIntelligenceParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := IPIntelligence{ + IP: "192.0.2.0", + BelongsToRef: BelongsToRef{ + ID: "autonomous-system--2fa28d71-3549-5a38-af05-770b79ad6ea8", + Value: 13335, + Type: "hosting_provider", + Country: "US", + Description: "CLOUDFLARENET", + }, + RiskTypes: []RiskTypes{ + { + ID: 131, + SuperCategoryID: 21, + Name: "Phishing", + }, + }, + } + + out, err := client.IntelligenceGetIPOverview(context.Background(), IPIntelligenceParameters{AccountID: testAccountID, IPv4: "192.0.2.0", IPv6: "2001:0DB8::"}) + if assert.NoError(t, err) { + assert.Equal(t, len(out), 1, "IP overview length mismatch") + assert.Equal(t, out[0], want, "structs not equal") + } +} + +func TestIntelligence_GetIPLists(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/ip-list", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": 3, + "name": "Malware" + } +] +} + `) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceGetIPList(context.Background(), IPIntelligenceListParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := []IPIntelligenceItem{ + { + ID: 3, + Name: "Malware", + }, + } + + out, err := client.IntelligenceGetIPList(context.Background(), IPIntelligenceListParameters{AccountID: testAccountID}) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} + +func TestIntelligence_PassiveDNS(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/dns", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "reverse_records": [ + { + "first_seen": "2021-04-01", + "last_seen": "2021-04-30", + "hostname": "cloudflare.com" + } + ], + "count": 1, + "page": 1, + "per_page": 20 + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligencePassiveDNS(context.Background(), IPIntelligencePassiveDNSParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := IPPassiveDNS{ + Count: 1, + Page: 1, + PerPage: 20, + ReverseRecords: []ReverseRecords{{ + FirstSeen: "2021-04-01", + LastSeen: "2021-04-30", + Hostname: "cloudflare.com", + }}, + } + + out, err := client.IntelligencePassiveDNS(context.Background(), IPIntelligencePassiveDNSParameters{AccountID: testAccountID}) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} diff --git a/pkg/cloudflare-go/intelligence_phishing.go b/pkg/cloudflare-go/intelligence_phishing.go new file mode 100644 index 000000000..ceb3f497e --- /dev/null +++ b/pkg/cloudflare-go/intelligence_phishing.go @@ -0,0 +1,52 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// PhishingScan represent information about a phishing scan. +type PhishingScan struct { + URL string `json:"url"` + Phishing bool `json:"phishing"` + Verified bool `json:"verified"` + Score float64 `json:"score"` + Classifier string `json:"classifier"` +} + +// PhishingScanParameters represent parameters for a phishing scan request. +type PhishingScanParameters struct { + AccountID string `url:"-"` + URL string `url:"url,omitempty"` + Skip bool `url:"skip,omitempty"` +} + +// PhishingScanResponse represent an API response for a phishing scan. +type PhishingScanResponse struct { + Response + Result PhishingScan `json:"result,omitempty"` +} + +// IntelligencePhishingScan scans a URL for suspected phishing +// +// API Reference: https://api.cloudflare.com/#phishing-url-scanner-scan-suspicious-url +func (api *API) IntelligencePhishingScan(ctx context.Context, params PhishingScanParameters) (PhishingScan, error) { + if params.AccountID == "" { + return PhishingScan{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel-phishing/predict", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PhishingScan{}, err + } + + var phishingScanResponse PhishingScanResponse + if err := json.Unmarshal(res, &phishingScanResponse); err != nil { + return PhishingScan{}, err + } + return phishingScanResponse.Result, nil +} diff --git a/pkg/cloudflare-go/intelligence_phishing_test.go b/pkg/cloudflare-go/intelligence_phishing_test.go new file mode 100644 index 000000000..f394b5ba0 --- /dev/null +++ b/pkg/cloudflare-go/intelligence_phishing_test.go @@ -0,0 +1,51 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntelligence_PhishingScan(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel-phishing/predict", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "url": "https://www.cloudflare.com", + "phishing": false, + "verified": false, + "score": 0.99, + "classifier": "MACHINE_LEARNING_v2" + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligencePhishingScan(context.Background(), PhishingScanParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + want := PhishingScan{ + URL: "https://www.cloudflare.com", + Phishing: false, + Verified: false, + Score: 0.99, + Classifier: "MACHINE_LEARNING_v2", + } + + out, err := client.IntelligencePhishingScan(context.Background(), PhishingScanParameters{AccountID: testAccountID, URL: "https://www.cloudflare.com"}) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} diff --git a/pkg/cloudflare-go/intelligence_whois.go b/pkg/cloudflare-go/intelligence_whois.go new file mode 100644 index 000000000..72a3a6136 --- /dev/null +++ b/pkg/cloudflare-go/intelligence_whois.go @@ -0,0 +1,60 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// WHOIS represents whois information. +type WHOIS struct { + Domain string `json:"domain,omitempty"` + CreatedDate string `json:"created_date,omitempty"` + UpdatedDate string `json:"updated_date,omitempty"` + Registrant string `json:"registrant,omitempty"` + RegistrantOrg string `json:"registrant_org,omitempty"` + RegistrantCountry string `json:"registrant_country,omitempty"` + RegistrantEmail string `json:"registrant_email,omitempty"` + Registrar string `json:"registrar,omitempty"` + Nameservers []string `json:"nameservers,omitempty"` +} + +// WHOISParameters represents parameters for a who is request. +type WHOISParameters struct { + AccountID string `url:"-"` + Domain string `url:"domain"` +} + +// WHOISResponse represents an API response for a whois request. +type WHOISResponse struct { + Response + Result WHOIS `json:"result,omitempty"` +} + +// IntelligenceWHOIS gets whois information for a domain. +// +// API Reference: https://api.cloudflare.com/#whois-record-get-whois-record +func (api *API) IntelligenceWHOIS(ctx context.Context, params WHOISParameters) (WHOIS, error) { + if params.AccountID == "" { + return WHOIS{}, ErrMissingAccountID + } + + if params.Domain == "" { + return WHOIS{}, ErrMissingDomain + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/intel/whois", params.AccountID), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WHOIS{}, err + } + + var whoisResponse WHOISResponse + if err := json.Unmarshal(res, &whoisResponse); err != nil { + return WHOIS{}, err + } + + return whoisResponse.Result, nil +} diff --git a/pkg/cloudflare-go/intelligence_whois_test.go b/pkg/cloudflare-go/intelligence_whois_test.go new file mode 100644 index 000000000..819dcfa89 --- /dev/null +++ b/pkg/cloudflare-go/intelligence_whois_test.go @@ -0,0 +1,77 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntelligence_WHOIS(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/intel/whois", 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "domain": "cloudflare.com", + "created_date": "2009-02-17", + "updated_date": "2017-05-24", + "registrant": "DATA REDACTED", + "registrant_org": "DATA REDACTED", + "registrant_country": "United States", + "registrant_email": "https://domaincontact.cloudflareregistrar.com/cloudflare.com", + "registrar": "Cloudflare, Inc.", + "nameservers": [ + "ns3.cloudflare.com", + "ns4.cloudflare.com", + "ns5.cloudflare.com", + "ns6.cloudflare.com", + "ns7.cloudflare.com" + ] + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.IntelligenceWHOIS(context.Background(), WHOISParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing domain is thrown + _, err = client.IntelligenceWHOIS(context.Background(), WHOISParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + + want := WHOIS{ + Domain: "cloudflare.com", + CreatedDate: "2009-02-17", + UpdatedDate: "2017-05-24", + Registrant: "DATA REDACTED", + RegistrantOrg: "DATA REDACTED", + RegistrantCountry: "United States", + RegistrantEmail: "https://domaincontact.cloudflareregistrar.com/cloudflare.com", + Registrar: "Cloudflare, Inc.", + Nameservers: []string{ + "ns3.cloudflare.com", + "ns4.cloudflare.com", + "ns5.cloudflare.com", + "ns6.cloudflare.com", + "ns7.cloudflare.com", + }, + } + + out, err := client.IntelligenceWHOIS(context.Background(), WHOISParameters{AccountID: testAccountID, Domain: "cloudflare.com"}) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} diff --git a/pkg/cloudflare-go/internal/tools/cmd/changelog-check/main.go b/pkg/cloudflare-go/internal/tools/cmd/changelog-check/main.go new file mode 100644 index 000000000..d52e5a355 --- /dev/null +++ b/pkg/cloudflare-go/internal/tools/cmd/changelog-check/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/cloudflare/cloudflare-go" + "github.com/google/go-github/github" + "golang.org/x/oauth2" +) + +const ( + changelogEntryFileFormat = ".changelog/%d.txt" + changelogProcessDocumentation = "https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md" + changelogDetectedMessage = "changelog detected :white_check_mark:" +) + +var ( + changelogEntryPresent = false + successMessageAlreadyPresent = false +) + +func getSkipLabels() []string { + return []string{"workflow/skip-changelog-entry", "dependencies"} +} + +func main() { + ctx := context.Background() + if len(os.Args) < 2 { + log.Fatalf("Usage: changelog-check PR#\n") + } + pr := os.Args[1] + prNo, err := strconv.Atoi(pr) + if err != nil { + log.Fatalf("error parsing PR %q as a number: %s", pr, err) + } + + owner := os.Getenv("GITHUB_OWNER") + repo := os.Getenv("GITHUB_REPO") + token := os.Getenv("GITHUB_TOKEN") + + if owner == "" { + log.Fatalf("GITHUB_OWNER not set") + } + + if repo == "" { + log.Fatalf("GITHUB_REPO not set") + } + + if token == "" { + log.Fatalf("GITHUB_TOKEN not set") + } + + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + client := github.NewClient(tc) + + pullRequest, _, err := client.PullRequests.Get(ctx, owner, repo, prNo) + if err != nil { + log.Fatalf("error retrieving pull request %s/%s#%d: %s", owner, repo, prNo, err) + } + + for _, label := range pullRequest.Labels { + for _, skipLabel := range getSkipLabels() { + if label.GetName() == skipLabel { + log.Printf("%s label found, exiting as changelog is not required\n", label.GetName()) + os.Exit(0) + } + } + } + + files, _, _ := client.PullRequests.ListFiles(ctx, owner, repo, prNo, &github.ListOptions{}) + if err != nil { + log.Fatalf("error retrieving files on pull request %s/%s#%d: %s", owner, repo, prNo, err) + } + + for _, file := range files { + if file.GetFilename() == fmt.Sprintf(changelogEntryFileFormat, prNo) { + changelogEntryPresent = true + } + } + + comments, _, _ := client.Issues.ListComments(ctx, owner, repo, prNo, &github.IssueListCommentsOptions{}) + for _, comment := range comments { + if strings.Contains(comment.GetBody(), "no changelog entry is attached to") { + if changelogEntryPresent { + client.Issues.EditComment(ctx, owner, repo, *comment.ID, &github.IssueComment{ + Body: cloudflare.StringPtr(changelogDetectedMessage), + }) + os.Exit(0) + } + log.Println("no change in status of changelog checks; exiting") + os.Exit(1) + } + + if strings.Contains(comment.GetBody(), changelogDetectedMessage) { + successMessageAlreadyPresent = true + } + } + + if changelogEntryPresent { + if !successMessageAlreadyPresent { + _, _, _ = client.Issues.CreateComment(ctx, owner, repo, prNo, &github.IssueComment{ + Body: cloudflare.StringPtr(changelogDetectedMessage), + }) + } + log.Printf("changelog found for %d, skipping remainder of checks\n", prNo) + os.Exit(0) + } + + body := "Oops! It looks like no changelog entry is attached to" + + " this PR. Please include a release note as described in " + + changelogProcessDocumentation + ".\n\nExample: " + + "\n\n~~~\n```release-note:TYPE\nRelease note" + + "\n```\n~~~\n\n" + + "If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label." + + _, _, err = client.Issues.CreateComment(ctx, owner, repo, prNo, &github.IssueComment{ + Body: &body, + }) + + if err != nil { + log.Fatalf("failed to comment on pull request %s/%s#%d: %s", owner, repo, prNo, err) + } + + os.Exit(1) +} diff --git a/pkg/cloudflare-go/internal/tools/go.mod b/pkg/cloudflare-go/internal/tools/go.mod new file mode 100644 index 000000000..c59259042 --- /dev/null +++ b/pkg/cloudflare-go/internal/tools/go.mod @@ -0,0 +1,249 @@ +module github.com/cloudflare/cloudflare-go/internal/tools + +go 1.19 + +require ( + github.com/breml/bidichk v0.2.3 + github.com/cloudflare/cloudflare-go v0.48.0 + github.com/curioswitch/go-reassign v0.2.0 + github.com/cweill/gotests v1.6.0 + github.com/go-delve/delve v1.9.0 + github.com/golangci/golangci-lint v1.48.0 + github.com/google/go-github v17.0.0+incompatible + github.com/hashicorp/go-changelog v0.0.0-20220419201213-5edfc0d651d8 + github.com/jgautheron/goconst v1.5.1 + github.com/kyoh86/exportloopref v0.1.8 + github.com/orijtech/structslop v0.0.6 + github.com/ramya-rao-a/go-outline v0.0.0-20210608161538-9736a4bde949 + github.com/securego/gosec/v2 v2.13.1 + github.com/uudashr/gopkgs/v2 v2.1.2 + golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 + golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 + golang.org/x/tools v0.13.0 + golang.org/x/tools/gopls v0.9.4 +) + +require ( + 4d63.com/gochecknoglobals v0.1.0 // indirect + dario.cat/mergo v1.0.0 // indirect + github.com/Antonboom/errname v0.1.7 // indirect + github.com/Antonboom/nilnil v0.1.1 // indirect + github.com/BurntSushi/toml v1.2.0 // indirect + github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect + github.com/GaijinEntertainment/go-exhaustruct/v2 v2.2.2 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/semver/v3 v3.1.1 // indirect + github.com/Masterminds/sprig/v3 v3.2.2 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/OpenPeeDeeP/depguard v1.1.0 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/alexkohler/prealloc v1.0.0 // indirect + github.com/alingse/asasalint v0.0.11 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/ashanbrown/forbidigo v1.3.0 // indirect + github.com/ashanbrown/makezero v1.1.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bflad/gopaniccheck v0.1.0 // indirect + github.com/bflad/tfproviderlint v0.28.1 // indirect + github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/bkielbasa/cyclop v1.2.0 // indirect + github.com/blizzy78/varnamelen v0.8.0 // indirect + github.com/bombsimon/wsl/v3 v3.3.0 // indirect + github.com/breml/errchkjson v0.3.0 // indirect + github.com/butuzov/ireturn v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/charithe/durationcheck v0.0.9 // indirect + github.com/chavacava/garif v0.0.0-20220316182200-5cad0b5181d4 // indirect + github.com/cilium/ebpf v0.7.0 // indirect + github.com/client9/misspell v0.3.4 // indirect + github.com/cloudflare/circl v1.3.7 // indirect + github.com/cosiner/argv v0.1.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/daixiang0/gci v0.6.2 // indirect + github.com/dave/dst v0.26.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/denis-tingaikin/go-header v0.4.3 // indirect + github.com/derekparker/trie v0.0.0-20200317170641-1fdf38b7b0e9 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/esimonov/ifshort v1.0.4 // indirect + github.com/ettle/strcase v0.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/firefart/nonamedreturns v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.5.4 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/go-critic/go-critic v0.6.3 // indirect + github.com/go-delve/liner v1.2.3-0.20220127212407-d32d89dd2a5d // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-git/go-git/v5 v5.11.0 // indirect + github.com/go-toolsmith/astcast v1.0.0 // indirect + github.com/go-toolsmith/astcopy v1.0.0 // indirect + github.com/go-toolsmith/astequal v1.0.1 // indirect + github.com/go-toolsmith/astfmt v1.0.0 // indirect + github.com/go-toolsmith/astp v1.0.0 // indirect + github.com/go-toolsmith/strparse v1.0.0 // indirect + github.com/go-toolsmith/typep v1.0.2 // indirect + github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gofrs/flock v0.8.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2 // indirect + github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect + github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe // indirect + github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a // indirect + github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0 // indirect + github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca // indirect + github.com/golangci/misspell v0.3.5 // indirect + github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6 // indirect + github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-dap v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gookit/color v1.5.1 // indirect + github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect + github.com/gostaticanalysis/analysisutil v0.7.1 // indirect + github.com/gostaticanalysis/comment v1.4.2 // indirect + github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect + github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-checkpoint v0.5.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.1 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hc-install v0.4.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.17.2 // indirect + github.com/hashicorp/terraform-json v0.14.0 // indirect + github.com/hashicorp/terraform-plugin-docs v0.13.0 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/huandu/xstrings v1.3.2 // indirect + github.com/imdario/mergo v0.3.13 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af // indirect + github.com/julz/importas v0.1.0 // indirect + github.com/karrick/godirwalk v1.12.0 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kisielk/errcheck v1.6.2 // indirect + github.com/kisielk/gotool v1.0.0 // indirect + github.com/kulti/thelper v0.6.3 // indirect + github.com/kunwardeep/paralleltest v1.0.6 // indirect + github.com/ldez/gomoddirectives v0.2.3 // indirect + github.com/ldez/tagliatelle v0.3.1 // indirect + github.com/leonklingele/grouper v1.1.0 // indirect + github.com/lufeee/execinquery v1.2.1 // indirect + github.com/magiconair/properties v1.8.6 // indirect + github.com/maratori/testpackage v1.1.0 // indirect + github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mbilski/exhaustivestruct v1.2.0 // indirect + github.com/mgechev/revive v1.2.1 // indirect + github.com/mitchellh/cli v1.1.4 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moricho/tparallel v0.2.1 // indirect + github.com/nakabonne/nestif v0.3.1 // indirect + github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 // indirect + github.com/nishanths/exhaustive v0.8.1 // indirect + github.com/nishanths/predeclared v0.2.2 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.0.2 // indirect + github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/polyfloyd/go-errorlint v1.0.0 // indirect + github.com/posener/complete v1.2.3 // indirect + github.com/prometheus/client_golang v1.12.1 // indirect + github.com/prometheus/client_model v0.2.0 // indirect + github.com/prometheus/common v0.32.1 // indirect + github.com/prometheus/procfs v0.7.3 // indirect + github.com/quasilyte/go-ruleguard v0.3.16-0.20220213074421-6aa060fab41a // indirect + github.com/quasilyte/gogrep v0.0.0-20220120141003-628d8b3623b5 // indirect + github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 // indirect + github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday v1.6.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/ryancurrah/gomodguard v1.2.4 // indirect + github.com/ryanrolds/sqlclosecheck v0.3.0 // indirect + github.com/sanposhiho/wastedassign/v2 v2.0.6 // indirect + github.com/sashamelentyev/usestdlibvars v1.8.0 // indirect + github.com/sergi/go-diff v1.2.0 // indirect + github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/sivchari/containedctx v1.0.2 // indirect + github.com/sivchari/nosnakecase v1.7.0 // indirect + github.com/sivchari/tenv v1.7.0 // indirect + github.com/skeema/knownhosts v1.2.1 // indirect + github.com/sonatard/noctx v0.0.1 // indirect + github.com/sourcegraph/go-diff v0.6.1 // indirect + github.com/spf13/afero v1.8.2 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/cobra v1.5.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.12.0 // indirect + github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect + github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/subosito/gotenv v1.4.0 // indirect + github.com/sylvia7788/contextcheck v1.0.4 // indirect + github.com/tdakkota/asciicheck v0.1.1 // indirect + github.com/tetafro/godot v1.4.11 // indirect + github.com/timakin/bodyclose v0.0.0-20210704033933-f49887972144 // indirect + github.com/tomarrell/wrapcheck/v2 v2.6.2 // indirect + github.com/tommy-muehle/go-mnd/v2 v2.5.0 // indirect + github.com/ultraware/funlen v0.0.3 // indirect + github.com/ultraware/whitespace v0.0.5 // indirect + github.com/uudashr/gocognit v1.0.6 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect + github.com/yagipy/maintidx v1.0.0 // indirect + github.com/yeya24/promlinter v0.2.0 // indirect + github.com/zclconf/go-cty v1.10.0 // indirect + gitlab.com/bosi/decorder v0.2.3 // indirect + go.starlark.net v0.0.0-20200821142938-949cc6f4b097 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.17.0 // indirect + golang.org/x/arch v0.0.0-20190927153633-4e8777c89be4 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20220722155223-a9213eeb770e // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect + golang.org/x/vuln v0.0.0-20220725105440-4151a5aca1df // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/ini.v1 v1.66.6 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + honnef.co/go/tools v0.3.3 // indirect + mvdan.cc/gofumpt v0.3.1 // indirect + mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed // indirect + mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b // indirect + mvdan.cc/unparam v0.0.0-20220706161116-678bad134442 // indirect + mvdan.cc/xurls/v2 v2.4.0 // indirect +) diff --git a/pkg/cloudflare-go/internal/tools/go.sum b/pkg/cloudflare-go/internal/tools/go.sum new file mode 100644 index 000000000..8375f5d09 --- /dev/null +++ b/pkg/cloudflare-go/internal/tools/go.sum @@ -0,0 +1,1429 @@ +4d63.com/gochecknoglobals v0.1.0 h1:zeZSRqj5yCg28tCkIV/z/lWbwvNm5qnKVS15PI8nhD0= +4d63.com/gochecknoglobals v0.1.0/go.mod h1:wfdC5ZjKSPr7CybKEcgJhUOgeAQW1+7WcyK8OvUilfo= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.61.0/go.mod h1:XukKJg4Y7QsUu0Hxg3qQKUWR4VuWivmyMK2+rUyxAqw= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Antonboom/errname v0.1.7 h1:mBBDKvEYwPl4WFFNwec1CZO096G6vzK9vvDQzAwkako= +github.com/Antonboom/errname v0.1.7/go.mod h1:g0ONh16msHIPgJSGsecu1G/dcF2hlYR/0SddnIAGavU= +github.com/Antonboom/nilnil v0.1.1 h1:PHhrh5ANKFWRBh7TdYmyyq2gyT2lotnvFvvFbylF81Q= +github.com/Antonboom/nilnil v0.1.1/go.mod h1:L1jBqoWM7AOeTD+tSquifKSesRHs4ZdaxvZR+xdJEaI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= +github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= +github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= +github.com/GaijinEntertainment/go-exhaustruct/v2 v2.2.2 h1:DGdS4FlsdM6OkluXOhgkvwx05ZjD3Idm9WqtYnOmSuY= +github.com/GaijinEntertainment/go-exhaustruct/v2 v2.2.2/go.mod h1:xj0D2jwLdp6tOKLheyZCsfL0nz8DaicmJxSwj3VcHtY= +github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= +github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig/v3 v3.2.0/go.mod h1:tWhwTbUTndesPNeF0C900vKoq283u6zp4APT9vaF3SI= +github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= +github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OpenPeeDeeP/depguard v1.1.0 h1:pjK9nLPS1FwQYGGpPxoMYpe7qACHOhAWQMQzV71i49o= +github.com/OpenPeeDeeP/depguard v1.1.0/go.mod h1:JtAMzWkmFEzDPyAd+W0NHl1lvpQKTvT9jnRVsohBKpc= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= +github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= +github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= +github.com/andybalholm/crlf v0.0.0-20171020200849-670099aa064f/go.mod h1:k8feO4+kXDxro6ErPXBRTJ/ro2mf0SsFG8s7doP9kJE= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/apparentlymart/go-cidr v1.0.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= +github.com/apparentlymart/go-cidr v1.1.0/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= +github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= +github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= +github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= +github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/ashanbrown/forbidigo v1.3.0 h1:VkYIwb/xxdireGAdJNZoo24O4lmnEWkactplBlWTShc= +github.com/ashanbrown/forbidigo v1.3.0/go.mod h1:vVW7PEdqEFqapJe95xHkTfB1+XvZXBFg8t0sG2FIxmI= +github.com/ashanbrown/makezero v1.1.1 h1:iCQ87C0V0vSyO+M9E/FZYbu65auqH0lnsOkf5FcB28s= +github.com/ashanbrown/makezero v1.1.1/go.mod h1:i1bJLCRSCHOcOa9Y6MyF2FTfMZMFdHvxKHxgO5Z1axI= +github.com/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= +github.com/aws/aws-sdk-go v1.25.3/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bflad/gopaniccheck v0.1.0 h1:tJftp+bv42ouERmUMWLoUn/5bi/iQZjHPznM00cP/bU= +github.com/bflad/gopaniccheck v0.1.0/go.mod h1:ZCj2vSr7EqVeDaqVsWN4n2MwdROx1YL+LFo47TSWtsA= +github.com/bflad/tfproviderlint v0.28.1 h1:7f54/ynV6/lK5/1EyG7tHtc4sMdjJSEFGjZNRJKwBs8= +github.com/bflad/tfproviderlint v0.28.1/go.mod h1:7Z9Pyl1Z1UWJcPBuyjN89D2NaJGpjReQb5NoaaQCthQ= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/bkielbasa/cyclop v1.2.0 h1:7Jmnh0yL2DjKfw28p86YTd/B4lRGcNuu12sKE35sM7A= +github.com/bkielbasa/cyclop v1.2.0/go.mod h1:qOI0yy6A7dYC4Zgsa72Ppm9kONl0RoIlPbzot9mhmeI= +github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= +github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= +github.com/bombsimon/wsl/v3 v3.3.0 h1:Mka/+kRLoQJq7g2rggtgQsjuI/K5Efd87WX96EWFxjM= +github.com/bombsimon/wsl/v3 v3.3.0/go.mod h1:st10JtZYLE4D5sC7b8xV4zTKZwAQjCH/Hy2Pm1FNZIc= +github.com/breml/bidichk v0.2.3 h1:qe6ggxpTfA8E75hdjWPZ581sY3a2lnl0IRxLQFelECI= +github.com/breml/bidichk v0.2.3/go.mod h1:8u2C6DnAy0g2cEq+k/A2+tr9O1s+vHGxWn0LTc70T2A= +github.com/breml/errchkjson v0.3.0 h1:YdDqhfqMT+I1vIxPSas44P+9Z9HzJwCeAzjB8PxP1xw= +github.com/breml/errchkjson v0.3.0/go.mod h1:9Cogkyv9gcT8HREpzi3TiqBxCqDzo8awa92zSDFcofU= +github.com/butuzov/ireturn v0.1.1 h1:QvrO2QF2+/Cx1WA/vETCIYBKtRjc30vesdoPUNo1EbY= +github.com/butuzov/ireturn v0.1.1/go.mod h1:Wh6Zl3IMtTpaIKbmwzqi6olnM9ptYQxxVacMsOEFPoc= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charithe/durationcheck v0.0.9 h1:mPP4ucLrf/rKZiIG/a9IPXHGlh8p4CzgpyTy6EEutYk= +github.com/charithe/durationcheck v0.0.9/go.mod h1:SSbRIBVfMjCi/kEB6K65XEA83D6prSM8ap1UCpNKtgg= +github.com/chavacava/garif v0.0.0-20220316182200-5cad0b5181d4 h1:tFXjAxje9thrTF4h57Ckik+scJjTWdwAtZqZPtOT48M= +github.com/chavacava/garif v0.0.0-20220316182200-5cad0b5181d4/go.mod h1:W8EnPSQ8Nv4fUjc/v1/8tHFqhuOJXnRub0dTfuAQktU= +github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cilium/ebpf v0.7.0 h1:1k/q3ATgxSXRdrmPfH8d7YK0GfqVsEKZAX9dQZvs56k= +github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/cloudflare-go v0.48.0 h1:bg4mGzmR21+85p9qvwupje42IUqVK6ZSunrnY8lNGtE= +github.com/cloudflare/cloudflare-go v0.48.0/go.mod h1:NTQPvombbf52QYX6YdY/oA15F7u9GNzeBhY3c3H49vA= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cosiner/argv v0.1.0 h1:BVDiEL32lwHukgJKP87btEPenzrrHUjajs/8yzaqcXg= +github.com/cosiner/argv v0.1.0/go.mod h1:EusR6TucWKX+zFgtdUsKT2Cvg45K5rtpCcWz4hK06d8= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/curioswitch/go-reassign v0.2.0 h1:G9UZyOcpk/d7Gd6mqYgd8XYWFMw/znxwGDUstnC9DIo= +github.com/curioswitch/go-reassign v0.2.0/go.mod h1:x6OpXuWvgfQaMGks2BZybTngWjT84hqJfKoO8Tt/Roc= +github.com/cweill/gotests v1.6.0 h1:KJx+/p4EweijYzqPb4Y/8umDCip1Cv6hEVyOx0mE9W8= +github.com/cweill/gotests v1.6.0/go.mod h1:CaRYbxQZGQOxXDvM9l0XJVV2Tjb2E5H53vq+reR2GrA= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/daixiang0/gci v0.6.2 h1:TXCP5RqjE/UupXO+p33MEhqdv7QxjKGw5MVkt9ATiMs= +github.com/daixiang0/gci v0.6.2/go.mod h1:EpVfrztufwVgQRXjnX4zuNinEpLj5OmMjtu/+MB0V0c= +github.com/dave/dst v0.26.2 h1:lnxLAKI3tx7MgLNVDirFCsDTlTG9nKTk7GcptKcWSwY= +github.com/dave/dst v0.26.2/go.mod h1:UMDJuIRPfyUCC78eFuB+SV/WI8oDeyFDvM/JR6NI3IU= +github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e/go.mod h1:i00+b/gKdIDIxuLDFob7ustLAVqhsZRk2qVZrArELGQ= +github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= +github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e/go.mod h1:qZqlPyPvfsDJt+3wHJ1EvSXDuVjFTK0j2p/ca+gtsb8= +github.com/dave/rebecca v0.9.1/go.mod h1:N6XYdMD/OKw3lkF3ywh8Z6wPGuwNFDNtWYEMFWEmXBA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denis-tingaikin/go-header v0.4.3 h1:tEaZKAlqql6SKCY++utLmkPLd6K8IBM20Ha7UVm+mtU= +github.com/denis-tingaikin/go-header v0.4.3/go.mod h1:0wOCWuN71D5qIgE2nz9KrKmuYBAC2Mra5RassOIQ2/c= +github.com/derekparker/trie v0.0.0-20200317170641-1fdf38b7b0e9 h1:G765iDCq7bP5opdrPkXk+4V3yfkgV9iGFuheWZ/X/zY= +github.com/derekparker/trie v0.0.0-20200317170641-1fdf38b7b0e9/go.mod h1:D6ICZm05D9VN1n/8iOtBxLpXtoGp6HDFUJ1RNVieOSE= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/esimonov/ifshort v1.0.4 h1:6SID4yGWfRae/M7hkVDVVyppy8q/v9OuxNdmjLQStBA= +github.com/esimonov/ifshort v1.0.4/go.mod h1:Pe8zjlRrJ80+q2CxHLfEOfTwxCZ4O+MuhcHcfgNWTk0= +github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw= +github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/firefart/nonamedreturns v1.0.4 h1:abzI1p7mAEPYuR4A+VLKn4eNDOycjYo2phmY9sfv40Y= +github.com/firefart/nonamedreturns v1.0.4/go.mod h1:TDhe/tjI1BXo48CmYbUduTV7BdIga8MAO/xbKdcVsGI= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= +github.com/go-critic/go-critic v0.6.3 h1:abibh5XYBTASawfTQ0rA7dVtQT+6KzpGqb/J+DxRDaw= +github.com/go-critic/go-critic v0.6.3/go.mod h1:c6b3ZP1MQ7o6lPR7Rv3lEf7pYQUmAcx8ABHgdZCQt/k= +github.com/go-delve/delve v1.9.0 h1:+vW0r1vuwk5Fqv+89ZvLTUfx55PvlENvfW4DH3v+x48= +github.com/go-delve/delve v1.9.0/go.mod h1:CMUUF5L5qeBI3DXz9K/vXOt+h+TycVegYuzq8vv2cOk= +github.com/go-delve/liner v1.2.3-0.20220127212407-d32d89dd2a5d h1:pxjSLshkZJGLVm0wv20f/H0oTWiq/egkoJQ2ja6LEvo= +github.com/go-delve/liner v1.2.3-0.20220127212407-d32d89dd2a5d/go.mod h1:biJCRbqp51wS+I92HMqn5H8/A0PAhxn2vyOT+JqhiGI= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git/v5 v5.1.0/go.mod h1:ZKfuPUoY1ZqIG4QG9BDBh3G4gLM5zvPuSJAozQrZuyM= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= +github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= +github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-toolsmith/astcast v1.0.0 h1:JojxlmI6STnFVG9yOImLeGREv8W2ocNUM+iOhR6jE7g= +github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4= +github.com/go-toolsmith/astcopy v1.0.0 h1:OMgl1b1MEpjFQ1m5ztEO06rz5CUd3oBv9RF7+DyvdG8= +github.com/go-toolsmith/astcopy v1.0.0/go.mod h1:vrgyG+5Bxrnz4MZWPF+pI4R8h3qKRjjyvV/DSez4WVQ= +github.com/go-toolsmith/astequal v1.0.0/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY= +github.com/go-toolsmith/astequal v1.0.1 h1:JbSszi42Jiqu36Gnf363HWS9MTEAz67vTQLponh3Moc= +github.com/go-toolsmith/astequal v1.0.1/go.mod h1:4oGA3EZXTVItV/ipGiOx7NWkY5veFfcsOJVS2YxltLw= +github.com/go-toolsmith/astfmt v1.0.0 h1:A0vDDXt+vsvLEdbMFJAUBI/uTbRw1ffOPnxsILnFL6k= +github.com/go-toolsmith/astfmt v1.0.0/go.mod h1:cnWmsOAuq4jJY6Ct5YWlVLmcmLMn1JUPuQIHCY7CJDw= +github.com/go-toolsmith/astp v1.0.0 h1:alXE75TXgcmupDsMK1fRAy0YUzLzqPVvBKoyWV+KPXg= +github.com/go-toolsmith/astp v1.0.0/go.mod h1:RSyrtpVlfTFGDYRbrjyWP1pYu//tSFcvdYrA8meBmLI= +github.com/go-toolsmith/pkgload v1.0.2-0.20220101231613-e814995d17c5 h1:eD9POs68PHkwrx7hAB78z1cb6PfGq/jyWn3wJywsH1o= +github.com/go-toolsmith/pkgload v1.0.2-0.20220101231613-e814995d17c5/go.mod h1:3NAwwmD4uY/yggRxoEjk/S00MIV3A+H7rrE3i87eYxM= +github.com/go-toolsmith/strparse v1.0.0 h1:Vcw78DnpCAKlM20kSbAyO4mPfJn/lyYA4BJUDxe2Jb4= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/typep v1.0.2 h1:8xdsa1+FSIH/RhEkgnD1j2CJOy5mNllW1Q9tRiYwvlk= +github.com/go-toolsmith/typep v1.0.2/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU= +github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b h1:khEcpUM4yFcxg4/FHQWkvVRmgijNXRfzkIDHh23ggEo= +github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2 h1:23T5iq8rbUYlhpt5DB4XJkc6BU31uODLD1o1gKvZmD0= +github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2/go.mod h1:k9Qvh+8juN+UKMCS/3jFtGICgW8O96FVaZsaxdzDkR4= +github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a h1:w8hkcTqaFpzKqonE9uMCefW1WDie15eSP/4MssdenaM= +github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a/go.mod h1:ryS0uhF+x9jgbj/N71xsEqODy9BN81/GonCZiOzirOk= +github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe h1:6RGUuS7EGotKx6J5HIP8ZtyMdiDscjMLfRBSPuzVVeo= +github.com/golangci/go-misc v0.0.0-20220329215616-d24fe342adfe/go.mod h1:gjqyPShc/m8pEMpk0a3SeagVb0kaqvhscv+i9jI5ZhQ= +github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a h1:iR3fYXUjHCR97qWS8ch1y9zPNsgXThGwjKPrYfqMPks= +github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a/go.mod h1:9qCChq59u/eW8im404Q2WWTrnBUQKjpNYKMbU4M7EFU= +github.com/golangci/golangci-lint v1.48.0 h1:hRiBNk9iRqdAKMa06ntfEiLyza1/3IE9rHLNJaek4a8= +github.com/golangci/golangci-lint v1.48.0/go.mod h1:5N+oxduCho+7yuccW69upg/O7cxjfR/d+IQeiNxGmKM= +github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0 h1:MfyDlzVjl1hoaPzPD4Gpb/QgoRfSBR0jdhwGyAWwMSA= +github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0/go.mod h1:66R6K6P6VWk9I95jvqGxkqJxVWGFy9XlDwLwVz1RCFg= +github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca h1:kNY3/svz5T29MYHubXix4aDDuE3RWHkPvopM/EDv/MA= +github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca/go.mod h1:tvlJhZqDe4LMs4ZHD0oMUlt9G2LWuDGoisJTBzLMV9o= +github.com/golangci/misspell v0.3.5 h1:pLzmVdl3VxTOncgzHcvLOKirdvcx/TydsClUQXTehjo= +github.com/golangci/misspell v0.3.5/go.mod h1:dEbvlSfYbMQDtrpRMQU675gSDLDNa8sCPPChZ7PhiVA= +github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6 h1:DIPQnGy2Gv2FSA4B/hh8Q7xx3B7AIDk3DAMeHclH1vQ= +github.com/golangci/revgrep v0.0.0-20220804021717-745bb2f7c2e6/go.mod h1:0AKcRCkMoKvUvlf89F6O7H2LYdhr1zBh736mBItOdRs= +github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4 h1:zwtduBRr5SSWhqsYNgcuWO2kFlpdOZbP0+yRjmvPGys= +github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4/go.mod h1:Izgrg8RkN3rCIMLGE9CyYmU9pY2Jer6DgANEnZ/L/cQ= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-dap v0.6.0 h1:Y1RHGUtv3R8y6sXq2dtGRMYrFB2hSqyFVws7jucrzX4= +github.com/google/go-dap v0.6.0/go.mod h1:5q8aYQFnHOAZEMP+6vmq25HKYAEwE+LF5yh7JKrrhSQ= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181127221834-b4f47329b966/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gookit/color v1.5.1 h1:Vjg2VEcdHpwq+oY63s/ksHrgJYCTo0bwWvmmYWdE9fQ= +github.com/gookit/color v1.5.1/go.mod h1:wZFzea4X8qN6vHOSP2apMb4/+w/orMznEzYsIHPaqKM= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 h1:PVRE9d4AQKmbelZ7emNig1+NT27DUmKZn5qXxfio54U= +github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gostaticanalysis/analysisutil v0.0.0-20190318220348-4088753ea4d3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE= +github.com/gostaticanalysis/analysisutil v0.0.3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE= +github.com/gostaticanalysis/analysisutil v0.1.0/go.mod h1:dMhHRU9KTiDcuLGdy87/2gTR8WruwYZrKdRq9m1O6uw= +github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= +github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= +github.com/gostaticanalysis/comment v1.3.0/go.mod h1:xMicKDx7XRXYdVwY9f9wQpDJVnqWxw9wCauCMKp+IBI= +github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= +github.com/gostaticanalysis/comment v1.4.2 h1:hlnx5+S2fY9Zo9ePo4AhgYsYHbM2+eAv8m/s1JiCd6Q= +github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= +github.com/gostaticanalysis/forcetypeassert v0.1.0 h1:6eUflI3DiGusXGK6X7cCcIgVCpZ2CiZ1Q7jl6ZxNV70= +github.com/gostaticanalysis/forcetypeassert v0.1.0/go.mod h1:qZEedyP/sY1lTGV1uJ3VhWZ2mqag3IkWsDHVbplHXak= +github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= +github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= +github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= +github.com/gostaticanalysis/testutil v0.4.0 h1:nhdCmubdmDF6VEatUNjgUZBJKWRqugoISdUv3PPQgHY= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-changelog v0.0.0-20220419201213-5edfc0d651d8 h1:9YDSbzqiU768LY0p5rAsifqaZ+wGOjBGtaW6GDH5tyg= +github.com/hashicorp/go-changelog v0.0.0-20220419201213-5edfc0d651d8/go.mod h1:bYsOXLQjb/yhHHiZAfoiPzhNHVjiNTGryeeBwzn1Uak= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= +github.com/hashicorp/go-getter v1.4.0/go.mod h1:7qxyCd8rBfcShwsvxgIguu4KbS3l8bUCwg2Umn7RjeY= +github.com/hashicorp/go-getter v1.5.0/go.mod h1:a7z7NPPfNQpJWcn4rSWFtdrSldqLdLPEF3d8nFMsSLM= +github.com/hashicorp/go-getter v1.5.2/go.mod h1:orNH3BTYLu/fIxGIdLjLoAJHWMDQ/UKQr5O4m3iBuoo= +github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v0.15.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.3.0/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYtXdgmf1AVNs0= +github.com/hashicorp/go-plugin v1.4.0/go.mod h1:5fGEH17QVwTTcR0zV7yhDPLLmFX9YSZ38b18Udy6vYQ= +github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ= +github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.5.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hc-install v0.4.0 h1:cZkRFr1WVa0Ty6x5fTvL1TuO1flul231rWkGH92oYYk= +github.com/hashicorp/hc-install v0.4.0/go.mod h1:5d155H8EC5ewegao9A4PUTMNPZaq+TbOzkJJZ4vrXeI= +github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= +github.com/hashicorp/hcl/v2 v2.3.0/go.mod h1:d+FwDBbOLvpAM3Z6J7gPj/VoAGkNe/gm352ZhjJ/Zv8= +github.com/hashicorp/hcl/v2 v2.8.2/go.mod h1:bQTN5mpo+jewjJgh8jr0JUguIi7qPHUF6yIfAEN3jqY= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/terraform-config-inspect v0.0.0-20191212124732-c6ae6269b9d7/go.mod h1:p+ivJws3dpqbp1iP84+npOyAmTTOLMgCzrXd3GSdn/A= +github.com/hashicorp/terraform-exec v0.10.0/go.mod h1:tOT8j1J8rP05bZBGWXfMyU3HkLi1LWyqL3Bzsc3CJjo= +github.com/hashicorp/terraform-exec v0.13.0/go.mod h1:SGhto91bVRlgXQWcJ5znSz+29UZIa8kpBbkGwQ+g9E8= +github.com/hashicorp/terraform-exec v0.17.2 h1:EU7i3Fh7vDUI9nNRdMATCEfnm9axzTnad8zszYZ73Go= +github.com/hashicorp/terraform-exec v0.17.2/go.mod h1:tuIbsL2l4MlwwIZx9HPM+LOV9vVyEfBYu2GsO1uH3/8= +github.com/hashicorp/terraform-json v0.5.0/go.mod h1:eAbqb4w0pSlRmdvl8fOyHAi/+8jnkVYN28gJkSJrLhU= +github.com/hashicorp/terraform-json v0.8.0/go.mod h1:3defM4kkMfttwiE7VakJDwCd4R+umhSQnvJwORXbprE= +github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e17dKDpqV7s= +github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM= +github.com/hashicorp/terraform-plugin-docs v0.13.0 h1:6e+VIWsVGb6jYJewfzq2ok2smPzZrt1Wlm9koLeKazY= +github.com/hashicorp/terraform-plugin-docs v0.13.0/go.mod h1:W0oCmHAjIlTHBbvtppWHe8fLfZ2BznQbuv8+UD8OucQ= +github.com/hashicorp/terraform-plugin-go v0.2.1/go.mod h1:10V6F3taeDWVAoLlkmArKttR3IULlRWFAGtQIQTIDr4= +github.com/hashicorp/terraform-plugin-sdk v1.16.1/go.mod h1:KSsGcuZ1JRqnmYzz+sWIiUwNvJkzXbGRIdefwFfOdyY= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.5.0/go.mod h1:z+cMZ0iswzZOahBJ3XmNWgWkVnAd2bl8g+FhyyuPDH4= +github.com/hashicorp/terraform-plugin-test/v2 v2.1.3/go.mod h1:pmaUHiUtDL/8Mz3FuyZ/vRDb0LpaOWQjVRW9ORF7FHs= +github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg= +github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/imdario/mergo v0.3.13 h1:lFzP57bqS/wsqKssCGmtLAb8A0wKjLGrve2q3PPVcBk= +github.com/imdario/mergo v0.3.13/go.mod h1:4lJ1jqUDcsbIECGy0RUJAXNIhg+6ocWgb1ALK2O4oXg= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jgautheron/goconst v1.5.1 h1:HxVbL1MhydKs8R8n/HE5NPvzfaYmQJA3o879lE4+WcM= +github.com/jgautheron/goconst v1.5.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= +github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= +github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= +github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af h1:KA9BjwUk7KlCh6S9EAGWBt1oExIUv9WyNCiRz5amv48= +github.com/jirfag/go-printf-func-name v0.0.0-20200119135958-7558a9eaa5af/go.mod h1:HEWGJkRDzjJY2sqdDwxccsGicWEf9BQOZsq2tV+xzM0= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/julz/importas v0.1.0 h1:F78HnrsjY3cR7j0etXy5+TU1Zuy7Xt08X/1aJnH5xXY= +github.com/julz/importas v0.1.0/go.mod h1:oSFU2R4XK/P7kNBrnL/FEQlDGN1/6WoxXEjSSXO0DV0= +github.com/karrick/godirwalk v1.12.0 h1:nkS4xxsjiZMvVlazd0mFyiwD4BR9f3m6LXGhM2TUx3Y= +github.com/karrick/godirwalk v1.12.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/go-crypto v0.0.0-20161004153544-93f5b35093ba/go.mod h1:ghbZscTyKdM07+Fw3KSi0hcJm+AlEUWj8QLlPtijN/M= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.6.2 h1:uGQ9xI8/pgc9iOoCe7kWQgRE6SBTrCGmTSf0LrEtY7c= +github.com/kisielk/errcheck v1.6.2/go.mod h1:nXw/i/MfnvRHqXa7XXmQMUB0oNFGuBrNI8d8NLy0LPw= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.11.2/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= +github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= +github.com/kunwardeep/paralleltest v1.0.6 h1:FCKYMF1OF2+RveWlABsdnmsvJrei5aoyZoaGS+Ugg8g= +github.com/kunwardeep/paralleltest v1.0.6/go.mod h1:Y0Y0XISdZM5IKm3TREQMZ6iteqn1YuwCsJO/0kL9Zes= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/kyoh86/exportloopref v0.1.8 h1:5Ry/at+eFdkX9Vsdw3qU4YkvGtzuVfzT4X7S77LoN/M= +github.com/kyoh86/exportloopref v0.1.8/go.mod h1:1tUcJeiioIs7VWe5gcOObrux3lb66+sBqGZrRkMwPgg= +github.com/ldez/gomoddirectives v0.2.3 h1:y7MBaisZVDYmKvt9/l1mjNCiSA1BVn34U0ObUcJwlhA= +github.com/ldez/gomoddirectives v0.2.3/go.mod h1:cpgBogWITnCfRq2qGoDkKMEVSaarhdBr6g8G04uz6d0= +github.com/ldez/tagliatelle v0.3.1 h1:3BqVVlReVUZwafJUwQ+oxbx2BEX2vUG4Yu/NOfMiKiM= +github.com/ldez/tagliatelle v0.3.1/go.mod h1:8s6WJQwEYHbKZDsp/LjArytKOG8qaMrKQQ3mFukHs88= +github.com/leonklingele/grouper v1.1.0 h1:tC2y/ygPbMFSBOs3DcyaEMKnnwH7eYKzohOtRrf0SAg= +github.com/leonklingele/grouper v1.1.0/go.mod h1:uk3I3uDfi9B6PeUjsCKi6ndcf63Uy7snXgR4yDYQVDY= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lufeee/execinquery v1.2.1 h1:hf0Ems4SHcUGBxpGN7Jz78z1ppVkP/837ZlETPCEtOM= +github.com/lufeee/execinquery v1.2.1/go.mod h1:EC7DrEKView09ocscGHC+apXMIaorh4xqSxS/dy8SbM= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/maratori/testpackage v1.1.0 h1:GJY4wlzQhuBusMF1oahQCBtUV/AQ/k69IZ68vxaac2Q= +github.com/maratori/testpackage v1.1.0/go.mod h1:PeAhzU8qkCwdGEMTEupsHJNlQu2gZopMC6RjbhmHeDc= +github.com/matoous/godox v0.0.0-20210227103229-6504466cf951 h1:pWxk9e//NbPwfxat7RXkts09K+dEBJWakUWwICVqYbA= +github.com/matoous/godox v0.0.0-20210227103229-6504466cf951/go.mod h1:1BELzlh859Sh1c6+90blK8lbYy0kwQf1bYlBhBysy1s= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mbilski/exhaustivestruct v1.2.0 h1:wCBmUnSYufAHO6J4AVWY6ff+oxWxsVFrwgOdMUQePUo= +github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aksxSUOUy+nvtVEfzXc= +github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg= +github.com/mgechev/revive v1.2.1 h1:GjFml7ZsoR0IrQ2E2YIvWFNS5GPDV7xNwvA5GM1HZC4= +github.com/mgechev/revive v1.2.1/go.mod h1:+Ro3wqY4vakcYNtkBWdZC7dBg1xSB6sp054wWwmeFm0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/cli v1.1.1/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/cli v1.1.4 h1:qj8czE26AU4PbiaPXK5uVmMSM+V5BYsFBiM9HhGRLUA= +github.com/mitchellh/cli v1.1.4/go.mod h1:vTLESy5mRhKOs9KDp0/RATawxP1UqBmdrpVRMnpcvKQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-testing-interface v1.0.4/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/moricho/tparallel v0.2.1 h1:95FytivzT6rYzdJLdtfn6m1bfFJylOJK41+lgv/EHf4= +github.com/moricho/tparallel v0.2.1/go.mod h1:fXEIZxG2vdfl0ZF8b42f5a78EhjjD5mX8qUplsoSU4k= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= +github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354 h1:4kuARK6Y6FxaNu/BnU2OAaLF86eTVhP2hjTB6iMvItA= +github.com/nbutton23/zxcvbn-go v0.0.0-20210217022336-fa2cb2858354/go.mod h1:KSVJerMDfblTH7p5MZaTt+8zaT2iEk3AkVb9PQdZuE8= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nishanths/exhaustive v0.8.1 h1:0QKNascWv9qIHY7zRoZSxeRr6kuk5aAT3YXLTiDmjTo= +github.com/nishanths/exhaustive v0.8.1/go.mod h1:qj+zJJUgJ76tR92+25+03oYUhzF4R7/2Wk7fGTfCHmg= +github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= +github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= +github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce/go.mod h1:uFMI8w+ref4v2r9jz+c9i1IfIttS/OkmLfrk1jne5hs= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo/v2 v2.1.4 h1:GNapqRSid3zijZ9H77KrgVG4/8KqiyRsxcSxe+7ApXY= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/orijtech/structslop v0.0.6 h1:MAw4cRHkpNgr5+irv9R1sG07wdxjCe1hkd/ozVv9ugU= +github.com/orijtech/structslop v0.0.6/go.mod h1:3zH7DQgjl7qvvnMni63/U82f+EjQ3MofOx9BRxanAiA= +github.com/otiai10/copy v1.2.0 h1:HvG945u96iNadPoG2/Ja2+AUJeW5YuFQMixq9yirC+k= +github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= +github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= +github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= +github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= +github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.0.2 h1:+jQXlF3scKIcSEKkdHzXhCTDLPFi5r1wnK6yPS+49Gw= +github.com/pelletier/go-toml/v2 v2.0.2/go.mod h1:MovirKjgVRESsAvNZlAjtFwV867yGuwRkXbG66OzopI= +github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d h1:CdDQnGF8Nq9ocOS/xlSptM1N3BbrA6/kmaep5ggwaIA= +github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polyfloyd/go-errorlint v1.0.0 h1:pDrQG0lrh68e602Wfp68BlUTRFoHn8PZYAjLgt2LFsM= +github.com/polyfloyd/go-errorlint v1.0.0/go.mod h1:KZy4xxPJyy88/gldCe5OdW6OQRtNO3EZE7hXzmnebgA= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.1/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk= +github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/quasilyte/go-ruleguard v0.3.1-0.20210203134552-1b5a410e1cc8/go.mod h1:KsAh3x0e7Fkpgs+Q9pNLS5XpFSvYCEVl5gP9Pp1xp30= +github.com/quasilyte/go-ruleguard v0.3.16-0.20220213074421-6aa060fab41a h1:sWFavxtIctGrVs5SYZ5Ml1CvrDAs8Kf5kx2PI3C41dA= +github.com/quasilyte/go-ruleguard v0.3.16-0.20220213074421-6aa060fab41a/go.mod h1:VMX+OnnSw4LicdiEGtRSD/1X8kW7GuEscjYNr4cOIT4= +github.com/quasilyte/go-ruleguard/dsl v0.3.0/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/go-ruleguard/dsl v0.3.16/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/go-ruleguard/rules v0.0.0-20201231183845-9e62ed36efe1/go.mod h1:7JTjp89EGyU1d6XfBiXihJNG37wB2VRkd125Q1u7Plc= +github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50= +github.com/quasilyte/gogrep v0.0.0-20220120141003-628d8b3623b5 h1:PDWGei+Rf2bBiuZIbZmM20J2ftEy9IeUCHA8HbQqed8= +github.com/quasilyte/gogrep v0.0.0-20220120141003-628d8b3623b5/go.mod h1:wSEyW6O61xRV6zb6My3HxrQ5/8ke7NE2OayqCHa3xRM= +github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY= +github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs= +github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= +github.com/ramya-rao-a/go-outline v0.0.0-20210608161538-9736a4bde949 h1:iaD+iVf9xGfajsJp+zYrg9Lrk6gMJ6/hZHO4cYq5D5o= +github.com/ramya-rao-a/go-outline v0.0.0-20210608161538-9736a4bde949/go.mod h1:9V3eNbj9Z53yO7cKB6cSX9f0O7rYdIiuGBhjA1YsQuw= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryancurrah/gomodguard v1.2.4 h1:CpMSDKan0LtNGGhPrvupAoLeObRFjND8/tU1rEOtBp4= +github.com/ryancurrah/gomodguard v1.2.4/go.mod h1:+Kem4VjWwvFpUJRJSwa16s1tBJe+vbv02+naTow2f6M= +github.com/ryanrolds/sqlclosecheck v0.3.0 h1:AZx+Bixh8zdUBxUA1NxbxVAS78vTPq4rCb8OUZI9xFw= +github.com/ryanrolds/sqlclosecheck v0.3.0/go.mod h1:1gREqxyTGR3lVtpngyFo3hZAgk0KCtEdgEkHwDbigdA= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sanposhiho/wastedassign/v2 v2.0.6 h1:+6/hQIHKNJAUixEj6EmOngGIisyeI+T3335lYTyxRoA= +github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= +github.com/sashamelentyev/usestdlibvars v1.8.0 h1:QnWP9IOEuRyYKH+IG0LlQIjuJlc0rfdo4K3/Zh3WRMw= +github.com/sashamelentyev/usestdlibvars v1.8.0/go.mod h1:BFt7b5mSVHaaa26ZupiNRV2ODViQBxZZVhtAxAJRrjs= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= +github.com/securego/gosec/v2 v2.13.1 h1:7mU32qn2dyC81MH9L2kefnQyRMUarfDER3iQyMHcjYM= +github.com/securego/gosec/v2 v2.13.1/go.mod h1:EO1sImBMBWFjOTFzMWfTRrZW6M15gm60ljzrmy/wtHo= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c h1:W65qqJCIOVP4jpqPQ0YvHYKwcMEMVWIzWC5iNQQfBTU= +github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c/go.mod h1:/PevMnwAxekIXwN8qQyfc5gl2NlkB3CQlkizAbOkeBs= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sivchari/containedctx v1.0.2 h1:0hLQKpgC53OVF1VT7CeoFHk9YKstur1XOgfYIc1yrHI= +github.com/sivchari/containedctx v1.0.2/go.mod h1:PwZOeqm4/DLoJOqMSIJs3aKqXRX4YO+uXww087KZ7Bw= +github.com/sivchari/nosnakecase v1.7.0 h1:7QkpWIRMe8x25gckkFd2A5Pi6Ymo0qgr4JrhGt95do8= +github.com/sivchari/nosnakecase v1.7.0/go.mod h1:CwDzrzPea40/GB6uynrNLiorAlgFRvRbFSgJx2Gs+QY= +github.com/sivchari/tenv v1.7.0 h1:d4laZMBK6jpe5PWepxlV9S+LC0yXqvYHiq8E6ceoVVE= +github.com/sivchari/tenv v1.7.0/go.mod h1:64yStXKSOxDfX47NlhVwND4dHwfZDdbp2Lyl018Icvg= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sonatard/noctx v0.0.1 h1:VC1Qhl6Oxx9vvWo3UDgrGXYCeKCe3Wbw7qAWL6FrmTY= +github.com/sonatard/noctx v0.0.1/go.mod h1:9D2D/EoULe8Yy2joDHJj7bv3sZoq9AaSb8B4lqBjiZI= +github.com/sourcegraph/go-diff v0.6.1 h1:hmA1LzxW0n1c3Q4YbrFgg4P99GSnebYa3x8gr0HZqLQ= +github.com/sourcegraph/go-diff v0.6.1/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo= +github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= +github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= +github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= +github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= +github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= +github.com/stbenjam/no-sprintf-host-port v0.1.1 h1:tYugd/yrm1O0dV+ThCbaKZh195Dfm07ysF0U6JQXczc= +github.com/stbenjam/no-sprintf-host-port v0.1.1/go.mod h1:TLhvtIvONRzdmkFiio4O8LHsN9N74I+PhRquPsxpL0I= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/subosito/gotenv v1.4.0 h1:yAzM1+SmVcz5R4tXGsNMu1jUl2aOJXoiWUCEwwnGrvs= +github.com/subosito/gotenv v1.4.0/go.mod h1:mZd6rFysKEcUhUHXJk0C/08wAgyDBFuwEYL7vWWGaGo= +github.com/sylvia7788/contextcheck v1.0.4 h1:MsiVqROAdr0efZc/fOCt0c235qm9XJqHtWwM+2h2B04= +github.com/sylvia7788/contextcheck v1.0.4/go.mod h1:vuPKJMQ7MQ91ZTqfdyreNKwZjyUg6KO+IebVyQDedZQ= +github.com/tdakkota/asciicheck v0.1.1 h1:PKzG7JUTUmVspQTDqtkX9eSiLGossXTybutHwTXuO0A= +github.com/tdakkota/asciicheck v0.1.1/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= +github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= +github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= +github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= +github.com/tetafro/godot v1.4.11 h1:BVoBIqAf/2QdbFmSwAWnaIqDivZdOV0ZRwEm6jivLKw= +github.com/tetafro/godot v1.4.11/go.mod h1:LR3CJpxDVGlYOWn3ZZg1PgNZdTUvzsZWu8xaEohUpn8= +github.com/timakin/bodyclose v0.0.0-20210704033933-f49887972144 h1:kl4KhGNsJIbDHS9/4U9yQo1UcPQM0kOMJHn29EoH/Ro= +github.com/timakin/bodyclose v0.0.0-20210704033933-f49887972144/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tomarrell/wrapcheck/v2 v2.6.2 h1:3dI6YNcrJTQ/CJQ6M/DUkc0gnqYSIk6o0rChn9E/D0M= +github.com/tomarrell/wrapcheck/v2 v2.6.2/go.mod h1:ao7l5p0aOlUNJKI0qVwB4Yjlqutd0IvAB9Rdwyilxvg= +github.com/tommy-muehle/go-mnd/v2 v2.5.0 h1:iAj0a8e6+dXSL7Liq0aXPox36FiN1dBbjA6lt9fl65s= +github.com/tommy-muehle/go-mnd/v2 v2.5.0/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= +github.com/ulikunitz/xz v0.5.5/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ultraware/funlen v0.0.3 h1:5ylVWm8wsNwH5aWo9438pwvsK0QiqVuUrt9bn7S/iLA= +github.com/ultraware/funlen v0.0.3/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= +github.com/ultraware/whitespace v0.0.5 h1:hh+/cpIcopyMYbZNVov9iSxvJU3OYQg78Sfaqzi/CzI= +github.com/ultraware/whitespace v0.0.5/go.mod h1:aVMh/gQve5Maj9hQ/hg+F75lr/X5A89uZnzAmWSineA= +github.com/uudashr/gocognit v1.0.6 h1:2Cgi6MweCsdB6kpcVQp7EW4U23iBFQWfTXiWlyp842Y= +github.com/uudashr/gocognit v1.0.6/go.mod h1:nAIUuVBnYU7pcninia3BHOvQkpQCeO76Uscky5BOwcY= +github.com/uudashr/gopkgs/v2 v2.1.2 h1:A0+QH6wqNRHORJnxmqfeuBEsK4nYQ7pgcOHhqpqcrpo= +github.com/uudashr/gopkgs/v2 v2.1.2/go.mod h1:O9VKOuPWrUpVhaxcg7N3QiTrlDhgJb/84Y7b3qaX1rI= +github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= +github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= +github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= +github.com/yeya24/promlinter v0.2.0 h1:xFKDQ82orCU5jQujdaD8stOHiv8UN68BSdn2a8u8Y3o= +github.com/yeya24/promlinter v0.2.0/go.mod h1:u54lkmBOZrpEbQQ6gox2zWKKLKu2SGe+2KOiextY+IA= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= +github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= +github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= +github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= +github.com/zclconf/go-cty v1.7.1/go.mod h1:VDR4+I79ubFBGm1uJac1226K5yANQFHeauxPBoP54+o= +github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0= +github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= +github.com/zclconf/go-cty-yaml v1.0.2/go.mod h1:IP3Ylp0wQpYm50IHK8OZWKMu6sPJIUgKa8XhiVHura0= +gitlab.com/bosi/decorder v0.2.3 h1:gX4/RgK16ijY8V+BRQHAySfQAb354T7/xQpDB2n10P0= +gitlab.com/bosi/decorder v0.2.3/go.mod h1:9K1RB5+VPNQYtXtTDAzd2OEftsZb1oV0IrJrzChSdGE= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.starlark.net v0.0.0-20200821142938-949cc6f4b097 h1:YiRMXXgG+Pg26t1fjq+iAjaauKWMC9cmGFrtOEuwDDg= +go.starlark.net v0.0.0-20200821142938-949cc6f4b097/go.mod h1:f0znQkUKRrkk36XxWbGjMqQM8wGv/xHBVE2qc3B5oFU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +golang.org/x/arch v0.0.0-20180920145803-b19384d3c130/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8= +golang.org/x/arch v0.0.0-20190927153633-4e8777c89be4 h1:QlVATYS7JBoZMVaf+cNjb90WD/beKVHnIxFKT4QaHVI= +golang.org/x/arch v0.0.0-20190927153633-4e8777c89be4/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp/typeparams v0.0.0-20220722155223-a9213eeb770e h1:7Xs2YCOpMlNqSQSmrrnhlzBXIE/bpMecZplbLePTJvE= +golang.org/x/exp/typeparams v0.0.0-20220722155223-a9213eeb770e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8= +golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180903190138-2b024373dcd9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220702020025-31831981b65f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs= +golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190307163923-6a08e3108db3/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190311215038-5c2858a9cfe5/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190321232350-e250d351ecad/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190322203728-c1a832b0ad89/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190910044552-dd2b5c81c578/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190916130336-e45ffcd953cc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191109212701-97ad0ed33101/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117220505-0cba7a3a9ee9/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200214201135-548b770e2dfa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200414032229-332987a829c3/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200622203043-20e05c1c8ffa/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200624225443-88f3c62a19ff/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200625211823-6506e20df31f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200713011307-fd294ab11aed/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200831203904-5a2aa26beb65/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20200917221617-d56e4e40bc9d/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201001104356-43ebab892c4c/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201002184944-ecd9fd270d5d/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201028111035-eafbe7b904eb/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201230224404-63754364767c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.9-0.20211228192929-ee1ca4ffc4da/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools/gopls v0.9.4 h1:YhHOxVi++ILnY+QnH9FGtRKZZrunSaR7OW8/dCp7bBk= +golang.org/x/tools/gopls v0.9.4/go.mod h1:merWH6UwxKhBcAlppWCEC4kJDvHxmHdKNDftyHnMsjU= +golang.org/x/vuln v0.0.0-20220725105440-4151a5aca1df h1:BkeW9/QJhcigekDUPS9N9bIb0v7gPKKmLYeczVAqr2s= +golang.org/x/vuln v0.0.0-20220725105440-4151a5aca1df/go.mod h1:UZshlUPxXeGUM9I14UOawXQg6yosDE9cr1vKY/DzgWo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.34.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200711021454-869866162049/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI= +gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/src-d/go-billy.v4 v4.3.0/go.mod h1:tm33zBoOwxjYHZIE+OV8bxTWFMJLrconzFMd38aARFk= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.3.3 h1:oDx7VAwstgpYpb3wv0oxiZlxY+foCpRAwY7Vk6XpAgA= +honnef.co/go/tools v0.3.3/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= +mvdan.cc/gofumpt v0.3.1 h1:avhhrOmv0IuvQVK7fvwV91oFSGAk5/6Po8GXTzICeu8= +mvdan.cc/gofumpt v0.3.1/go.mod h1:w3ymliuxvzVx8DAutBnVyDqYb1Niy/yCJt/lk821YCE= +mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed h1:WX1yoOaKQfddO/mLzdV4wptyWgoH/6hwLs7QHTixo0I= +mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed/go.mod h1:Xkxe497xwlCKkIaQYRfC7CSLworTXY9RMqwhhCm+8Nc= +mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b h1:DxJ5nJdkhDlLok9K6qO+5290kphDJbHOQO1DFFFTeBo= +mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b/go.mod h1:2odslEg/xrtNQqCYg2/jCoyKnw3vv5biOc3JnIcYfL4= +mvdan.cc/unparam v0.0.0-20220706161116-678bad134442 h1:seuXWbRB1qPrS3NQnHmFKLJLtskWyueeIzmLXghMGgk= +mvdan.cc/unparam v0.0.0-20220706161116-678bad134442/go.mod h1:F/Cxw/6mVrNKqrR2YjFf5CaW0Bw4RL8RfbEf4GRggJk= +mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc= +mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/pkg/cloudflare-go/internal/tools/tools.go b/pkg/cloudflare-go/internal/tools/tools.go new file mode 100644 index 000000000..4a0baee4d --- /dev/null +++ b/pkg/cloudflare-go/internal/tools/tools.go @@ -0,0 +1,45 @@ +//go:build tools +// +build tools + +package tools + +//go:generate go install github.com/breml/bidichk/cmd/bidichk +//go:generate go install github.com/curioswitch/go-reassign +//go:generate go install github.com/cweill/gotests/gotests +//go:generate go install github.com/go-delve/delve/cmd/dlv +//go:generate go install github.com/golangci/golangci-lint/cmd/golangci-lint +//go:generate go install github.com/google/go-github/github +//go:generate go install github.com/hashicorp/go-changelog/cmd/changelog-build +//go:generate go install github.com/jgautheron/goconst/cmd/goconst +//go:generate go install github.com/kyoh86/exportloopref/cmd/exportloopref +//go:generate go install github.com/orijtech/structslop/cmd/structslop +//go:generate go install github.com/ramya-rao-a/go-outline +//go:generate go install github.com/securego/gosec/v2/cmd/gosec +//go:generate go install github.com/uudashr/gopkgs/v2/cmd/gopkgs +//go:generate go install golang.org/x/lint/golint +//go:generate go install golang.org/x/oauth2 +//go:generate go install golang.org/x/tools/gopls@latest +//go:generate go install golang.org/x/tools/cmd/goimports@latest + +import ( + // local development tooling for linting and debugging. + _ "github.com/breml/bidichk/cmd/bidichk" + _ "github.com/curioswitch/go-reassign" + _ "github.com/cweill/gotests/gotests" + _ "github.com/go-delve/delve/cmd/dlv" + _ "github.com/golangci/golangci-lint/cmd/golangci-lint" + _ "github.com/hashicorp/go-changelog/cmd/changelog-build" + _ "github.com/jgautheron/goconst/cmd/goconst" + _ "github.com/kyoh86/exportloopref/cmd/exportloopref" + _ "github.com/orijtech/structslop/cmd/structslop" + _ "github.com/ramya-rao-a/go-outline" + _ "github.com/securego/gosec/v2/cmd/gosec" + _ "github.com/uudashr/gopkgs/v2/cmd/gopkgs" + _ "golang.org/x/lint/golint" + _ "golang.org/x/tools/cmd/goimports" + _ "golang.org/x/tools/gopls" + + // used for changelog-check tooling + _ "github.com/google/go-github/github" + _ "golang.org/x/oauth2" +) diff --git a/pkg/cloudflare-go/ip_access_rules.go b/pkg/cloudflare-go/ip_access_rules.go new file mode 100644 index 000000000..58d29cd08 --- /dev/null +++ b/pkg/cloudflare-go/ip_access_rules.go @@ -0,0 +1,89 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type ListIPAccessRulesOrderOption string +type ListIPAccessRulesMatchOption string +type IPAccessRulesModeOption string + +const ( + IPAccessRulesConfigurationTarget ListIPAccessRulesOrderOption = "configuration.target" + IPAccessRulesConfigurationValue ListIPAccessRulesOrderOption = "configuration.value" + IPAccessRulesMatchOptionAll ListIPAccessRulesMatchOption = "all" + IPAccessRulesMatchOptionAny ListIPAccessRulesMatchOption = "any" + IPAccessRulesModeBlock IPAccessRulesModeOption = "block" + IPAccessRulesModeChallenge IPAccessRulesModeOption = "challenge" + IPAccessRulesModeJsChallenge IPAccessRulesModeOption = "js_challenge" + IPAccessRulesModeManagedChallenge IPAccessRulesModeOption = "managed_challenge" + IPAccessRulesModeWhitelist IPAccessRulesModeOption = "whitelist" +) + +type ListIPAccessRulesFilters struct { + Configuration IPAccessRuleConfiguration `json:"configuration,omitempty"` + Match ListIPAccessRulesMatchOption `json:"match,omitempty"` + Mode IPAccessRulesModeOption `json:"mode,omitempty"` + Notes string `json:"notes,omitempty"` +} + +type ListIPAccessRulesParams struct { + Direction string `url:"direction,omitempty"` + Filters ListIPAccessRulesFilters `url:"filters,omitempty"` + Order ListIPAccessRulesOrderOption `url:"order,omitempty"` + PaginationOptions +} + +type IPAccessRuleConfiguration struct { + Target string `json:"target,omitempty"` + Value string `json:"value,omitempty"` +} + +type IPAccessRule struct { + AllowedModes []IPAccessRulesModeOption `json:"allowed_modes"` + Configuration IPAccessRuleConfiguration `json:"configuration"` + CreatedOn string `json:"created_on"` + ID string `json:"id"` + Mode IPAccessRulesModeOption `json:"mode"` + ModifiedOn string `json:"modified_on"` + Notes string `json:"notes"` +} + +type ListIPAccessRulesResponse struct { + Result []IPAccessRule `json:"result"` + ResultInfo `json:"result_info"` + Response +} + +// ListIPAccessRules fetches IP Access rules of a zone/user/account. You can +// filter the results using several optional parameters. +// +// API references: +// - https://developers.cloudflare.com/api/operations/ip-access-rules-for-a-user-list-ip-access-rules +// - https://developers.cloudflare.com/api/operations/ip-access-rules-for-a-zone-list-ip-access-rules +// - https://developers.cloudflare.com/api/operations/ip-access-rules-for-an-account-list-ip-access-rules +func (api *API) ListIPAccessRules(ctx context.Context, rc *ResourceContainer, params ListIPAccessRulesParams) ([]IPAccessRule, *ResultInfo, error) { + if rc.Identifier == "" { + return []IPAccessRule{}, &ResultInfo{}, ErrMissingResourceIdentifier + } + + uri := buildURI(fmt.Sprintf("/%s/%s/firewall/access_rules/rules", rc.Level, rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []IPAccessRule{}, &ResultInfo{}, err + } + + result := ListIPAccessRulesResponse{} + + err = json.Unmarshal(res, &result) + if err != nil { + return []IPAccessRule{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, &result.ResultInfo, nil +} diff --git a/pkg/cloudflare-go/ip_access_rules_test.go b/pkg/cloudflare-go/ip_access_rules_test.go new file mode 100644 index 000000000..4eba12443 --- /dev/null +++ b/pkg/cloudflare-go/ip_access_rules_test.go @@ -0,0 +1,311 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListIPAccessRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result":[ + { + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge", + "managed_challenge" + ], + "configuration": { + "target": "ip", + "value": "198.51.100.1" + }, + "created_on": "2014-01-01T05:20:00.12345Z", + "id": "f2d427378e7542acb295380d352e2ebd", + "mode": "whitelist", + "modified_on": "2014-01-01T05:20:00.12345Z", + "notes": "Whitelisting this IP." + }, + { + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge", + "managed_challenge" + ], + "configuration": { + "target": "ip", + "value": "198.51.100.2" + }, + "created_on": "2014-01-01T05:20:00.12345Z", + "id": "92f17202ed8bd63d69a66b86a49a8f6b", + "mode": "block", + "modified_on": "2014-01-01T05:20:00.12345Z", + "notes": "This rule is enabled because of an event that occurred on date X." + }, + { + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge", + "managed_challenge" + ], + "configuration": { + "target": "ip", + "value": "198.51.100.3" + }, + "created_on": "2014-01-01T05:20:00.12345Z", + "id": "4ae338944d6143378c3cf05a7c77d983", + "mode": "challenge", + "modified_on": "2014-01-01T05:20:00.12345Z", + "notes": "This rule is enabled because of an event that occurred on date Y." + }, + { + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge", + "managed_challenge" + ], + "configuration": { + "target": "ip", + "value": "198.51.100.4" + }, + "created_on": "2014-01-01T05:20:00.12345Z", + "id": "52161eb6af4241bb9d4b32394be72fdf", + "mode": "js_challenge", + "modified_on": "2014-01-01T05:20:00.12345Z", + "notes": "This rule is enabled because of an event that occurred on date Z." + }, + { + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge", + "managed_challenge" + ], + "configuration": { + "target": "ip", + "value": "198.51.100.5" + }, + "created_on": "2014-01-01T05:20:00.12345Z", + "id": "cbf4b7a5a2a24e59a03044d6d44ceb09", + "mode": "managed_challenge", + "modified_on": "2014-01-01T05:20:00.12345Z", + "notes": "This rule is enabled because we like the challenge page." + } + ], + "success":true, + "errors":null, + "messages":null, + "result_info":{ + "page":1, + "per_page":25, + "count":5, + "total_count":5 + } + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/access_rules/rules", handler) + want := []IPAccessRule{ + { + AllowedModes: []IPAccessRulesModeOption{ + IPAccessRulesModeWhitelist, + IPAccessRulesModeBlock, + IPAccessRulesModeChallenge, + IPAccessRulesModeJsChallenge, + IPAccessRulesModeManagedChallenge, + }, + ID: "f2d427378e7542acb295380d352e2ebd", + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.1", + }, + CreatedOn: "2014-01-01T05:20:00.12345Z", + Mode: "whitelist", + ModifiedOn: "2014-01-01T05:20:00.12345Z", + Notes: "Whitelisting this IP.", + }, + { + AllowedModes: []IPAccessRulesModeOption{ + IPAccessRulesModeWhitelist, + IPAccessRulesModeBlock, + IPAccessRulesModeChallenge, + IPAccessRulesModeJsChallenge, + IPAccessRulesModeManagedChallenge, + }, + ID: "92f17202ed8bd63d69a66b86a49a8f6b", + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.2", + }, + CreatedOn: "2014-01-01T05:20:00.12345Z", + Mode: "block", + ModifiedOn: "2014-01-01T05:20:00.12345Z", + Notes: "This rule is enabled because of an event that occurred on date X.", + }, + { + AllowedModes: []IPAccessRulesModeOption{ + IPAccessRulesModeWhitelist, + IPAccessRulesModeBlock, + IPAccessRulesModeChallenge, + IPAccessRulesModeJsChallenge, + IPAccessRulesModeManagedChallenge, + }, + ID: "4ae338944d6143378c3cf05a7c77d983", + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.3", + }, + CreatedOn: "2014-01-01T05:20:00.12345Z", + Mode: "challenge", + ModifiedOn: "2014-01-01T05:20:00.12345Z", + Notes: "This rule is enabled because of an event that occurred on date Y.", + }, + { + AllowedModes: []IPAccessRulesModeOption{ + IPAccessRulesModeWhitelist, + IPAccessRulesModeBlock, + IPAccessRulesModeChallenge, + IPAccessRulesModeJsChallenge, + IPAccessRulesModeManagedChallenge, + }, + ID: "52161eb6af4241bb9d4b32394be72fdf", + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.4", + }, + CreatedOn: "2014-01-01T05:20:00.12345Z", + Mode: "js_challenge", + ModifiedOn: "2014-01-01T05:20:00.12345Z", + Notes: "This rule is enabled because of an event that occurred on date Z.", + }, + { + AllowedModes: []IPAccessRulesModeOption{ + IPAccessRulesModeWhitelist, + IPAccessRulesModeBlock, + IPAccessRulesModeChallenge, + IPAccessRulesModeJsChallenge, + IPAccessRulesModeManagedChallenge, + }, + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.5", + }, + CreatedOn: "2014-01-01T05:20:00.12345Z", + Mode: "managed_challenge", + ModifiedOn: "2014-01-01T05:20:00.12345Z", + Notes: "This rule is enabled because we like the challenge page.", + }, + } + + actual, _, err := client.ListIPAccessRules(context.Background(), + ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), ListIPAccessRulesParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListIPAccessRulesWithParams(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result":[ + { + "allowed_modes": [ + "whitelist", + "block", + "challenge", + "js_challenge", + "managed_challenge" + ], + "configuration": { + "target": "ip", + "value": "198.51.100.5" + }, + "created_on": "2014-01-01T05:20:00.12345Z", + "id": "cbf4b7a5a2a24e59a03044d6d44ceb09", + "mode": "managed_challenge", + "modified_on": "2014-01-01T05:20:00.12345Z", + "notes": "This rule is enabled because we like the challenge page." + } + ], + "success":true, + "errors":null, + "messages":null, + "result_info":{ + "page":1, + "per_page":100, + "count":1, + "total_count":1 + } + } + `) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/firewall/access_rules/rules", handler) + want := []IPAccessRule{ + { + AllowedModes: []IPAccessRulesModeOption{ + IPAccessRulesModeWhitelist, + IPAccessRulesModeBlock, + IPAccessRulesModeChallenge, + IPAccessRulesModeJsChallenge, + IPAccessRulesModeManagedChallenge, + }, + ID: "cbf4b7a5a2a24e59a03044d6d44ceb09", + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + Value: "198.51.100.5", + }, + CreatedOn: "2014-01-01T05:20:00.12345Z", + Mode: "managed_challenge", + ModifiedOn: "2014-01-01T05:20:00.12345Z", + Notes: "This rule is enabled because we like the challenge page.", + }, + } + + actual, _, err := client.ListIPAccessRules(context.Background(), + ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), ListIPAccessRulesParams{ + Direction: "asc", + Filters: ListIPAccessRulesFilters{ + Configuration: IPAccessRuleConfiguration{ + Target: "ip", + }, + Match: "any", + Mode: "manage_challenge", + Notes: "This rule is enabled because we like the challenge page.", + }, + Order: IPAccessRulesConfigurationTarget, + PaginationOptions: PaginationOptions{ + Page: 1, + PerPage: 100, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/ip_list.go b/pkg/cloudflare-go/ip_list.go new file mode 100644 index 000000000..a9c5e7151 --- /dev/null +++ b/pkg/cloudflare-go/ip_list.go @@ -0,0 +1,507 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// The definitions in this file are deprecated and should be removed after +// enough time is given for users to migrate. Use the more general `List` +// methods instead. + +const ( + // IPListTypeIP specifies a list containing IP addresses. + IPListTypeIP = "ip" +) + +// IPListBulkOperation contains information about a Bulk Operation. +type IPListBulkOperation struct { + ID string `json:"id"` + Status string `json:"status"` + Error string `json:"error"` + Completed *time.Time `json:"completed"` +} + +// IPList contains information about an IP List. +type IPList struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` + NumItems int `json:"num_items"` + NumReferencingFilters int `json:"num_referencing_filters"` + CreatedOn *time.Time `json:"created_on"` + ModifiedOn *time.Time `json:"modified_on"` +} + +// IPListItem contains information about a single IP List Item. +type IPListItem struct { + ID string `json:"id"` + IP string `json:"ip"` + Comment string `json:"comment"` + CreatedOn *time.Time `json:"created_on"` + ModifiedOn *time.Time `json:"modified_on"` +} + +// IPListCreateRequest contains data for a new IP List. +type IPListCreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` +} + +// IPListItemCreateRequest contains data for a new IP List Item. +type IPListItemCreateRequest struct { + IP string `json:"ip"` + Comment string `json:"comment"` +} + +// IPListItemDeleteRequest wraps IP List Items that shall be deleted. +type IPListItemDeleteRequest struct { + Items []IPListItemDeleteItemRequest `json:"items"` +} + +// IPListItemDeleteItemRequest contains single IP List Items that shall be deleted. +type IPListItemDeleteItemRequest struct { + ID string `json:"id"` +} + +// IPListUpdateRequest contains data for an IP List update. +type IPListUpdateRequest struct { + Description string `json:"description"` +} + +// IPListResponse contains a single IP List. +type IPListResponse struct { + Response + Result IPList `json:"result"` +} + +// IPListItemCreateResponse contains information about the creation of an IP List Item. +type IPListItemCreateResponse struct { + Response + Result struct { + OperationID string `json:"operation_id"` + } `json:"result"` +} + +// IPListListResponse contains a slice of IP Lists. +type IPListListResponse struct { + Response + Result []IPList `json:"result"` +} + +// IPListBulkOperationResponse contains information about a Bulk Operation. +type IPListBulkOperationResponse struct { + Response + Result IPListBulkOperation `json:"result"` +} + +// IPListDeleteResponse contains information about the deletion of an IP List. +type IPListDeleteResponse struct { + Response + Result struct { + ID string `json:"id"` + } `json:"result"` +} + +// IPListItemsListResponse contains information about IP List Items. +type IPListItemsListResponse struct { + Response + ResultInfo `json:"result_info"` + Result []IPListItem `json:"result"` +} + +// IPListItemDeleteResponse contains information about the deletion of an IP List Item. +type IPListItemDeleteResponse struct { + Response + Result struct { + OperationID string `json:"operation_id"` + } `json:"result"` +} + +// IPListItemsGetResponse contains information about a single IP List Item. +type IPListItemsGetResponse struct { + Response + Result IPListItem `json:"result"` +} + +// ListIPLists lists all IP Lists. +// +// API reference: https://api.cloudflare.com/#rules-lists-list-lists +// +// Deprecated: Use `ListLists` instead. +func (api *API) ListIPLists(ctx context.Context, accountID string) ([]IPList, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists", accountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []IPList{}, err + } + + result := IPListListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []IPList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// CreateIPList creates a new IP List. +// +// API reference: https://api.cloudflare.com/#rules-lists-create-list +// +// Deprecated: Use `CreateList` instead. +func (api *API) CreateIPList(ctx context.Context, accountID, name, description, kind string) (IPList, + error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists", accountID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, + IPListCreateRequest{Name: name, Description: description, Kind: kind}) + if err != nil { + return IPList{}, err + } + + result := IPListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetIPList returns a single IP List +// +// API reference: https://api.cloudflare.com/#rules-lists-get-list +// +// Deprecated: Use `GetList` instead. +func (api *API) GetIPList(ctx context.Context, accountID, ID string) (IPList, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return IPList{}, err + } + + result := IPListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateIPList updates the description of an existing IP List. +// +// API reference: https://api.cloudflare.com/#rules-lists-update-list +// +// Deprecated: Use `UpdateList` instead. +func (api *API) UpdateIPList(ctx context.Context, accountID, ID, description string) (IPList, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, IPListUpdateRequest{Description: description}) + if err != nil { + return IPList{}, err + } + + result := IPListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteIPList deletes an IP List. +// +// API reference: https://api.cloudflare.com/#rules-lists-delete-list +// +// Deprecated: Use `DeleteList` instead. +func (api *API) DeleteIPList(ctx context.Context, accountID, ID string) (IPListDeleteResponse, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return IPListDeleteResponse{}, err + } + + result := IPListDeleteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListDeleteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// ListIPListItems returns a list with all items in an IP List. +// +// API reference: https://api.cloudflare.com/#rules-lists-list-list-items +// +// Deprecated: Use `ListListItems` instead. +func (api *API) ListIPListItems(ctx context.Context, accountID, ID string) ([]IPListItem, error) { + var list []IPListItem + var cursor string + var cursorQuery string + + for { + if len(cursor) > 0 { + cursorQuery = fmt.Sprintf("?cursor=%s", cursor) + } + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items%s", accountID, ID, cursorQuery) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []IPListItem{}, err + } + + result := IPListItemsListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []IPListItem{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + list = append(list, result.Result...) + if cursor = result.ResultInfo.Cursors.After; cursor == "" { + break + } + } + + return list, nil +} + +// CreateIPListItemAsync creates a new IP List Item asynchronously. Users have +// to poll the operation status by using the operation_id returned by this +// function. +// +// API reference: https://api.cloudflare.com/#rules-lists-create-list-items +// +// Deprecated: Use `CreateListItemAsync` instead. +func (api *API) CreateIPListItemAsync(ctx context.Context, accountID, ID, ip, comment string) (IPListItemCreateResponse, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, []IPListItemCreateRequest{{IP: ip, Comment: comment}}) + if err != nil { + return IPListItemCreateResponse{}, err + } + + result := IPListItemCreateResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListItemCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// CreateIPListItem creates a new IP List Item synchronously and returns the +// current set of IP List Items. +// +// Deprecated: Use `CreateListItem` instead. +func (api *API) CreateIPListItem(ctx context.Context, accountID, ID, ip, comment string) ([]IPListItem, error) { + result, err := api.CreateIPListItemAsync(ctx, accountID, ID, ip, comment) + + if err != nil { + return []IPListItem{}, err + } + + err = api.pollIPListBulkOperation(ctx, accountID, result.Result.OperationID) + if err != nil { + return []IPListItem{}, err + } + + return api.ListIPListItems(ctx, accountID, ID) +} + +// CreateIPListItemsAsync bulk creates many IP List Items asynchronously. Users +// have to poll the operation status by using the operation_id returned by this +// function. +// +// API reference: https://api.cloudflare.com/#rules-lists-create-list-items +// +// Deprecated: Use `CreateListItemsAsync` instead. +func (api *API) CreateIPListItemsAsync(ctx context.Context, accountID, ID string, items []IPListItemCreateRequest) ( + IPListItemCreateResponse, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, items) + if err != nil { + return IPListItemCreateResponse{}, err + } + + result := IPListItemCreateResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListItemCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// CreateIPListItems bulk creates many IP List Items synchronously and returns +// the current set of IP List Items. +// +// Deprecated: Use `CreateListItems` instead. +func (api *API) CreateIPListItems(ctx context.Context, accountID, ID string, items []IPListItemCreateRequest) ( + []IPListItem, error) { + result, err := api.CreateIPListItemsAsync(ctx, accountID, ID, items) + if err != nil { + return []IPListItem{}, err + } + + err = api.pollIPListBulkOperation(ctx, accountID, result.Result.OperationID) + if err != nil { + return []IPListItem{}, err + } + + return api.ListIPListItems(ctx, accountID, ID) +} + +// ReplaceIPListItemsAsync replaces all IP List Items asynchronously. Users have +// to poll the operation status by using the operation_id returned by this +// function. +// +// API reference: https://api.cloudflare.com/#rules-lists-replace-list-items +// +// Deprecated: Use `ReplaceListItemsAsync` instead. +func (api *API) ReplaceIPListItemsAsync(ctx context.Context, accountID, ID string, items []IPListItemCreateRequest) ( + IPListItemCreateResponse, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, items) + if err != nil { + return IPListItemCreateResponse{}, err + } + + result := IPListItemCreateResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListItemCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// ReplaceIPListItems replaces all IP List Items synchronously and returns the +// current set of IP List Items. +// +// Deprecated: Use `ReplaceListItems` instead. +func (api *API) ReplaceIPListItems(ctx context.Context, accountID, ID string, items []IPListItemCreateRequest) ( + []IPListItem, error) { + result, err := api.ReplaceIPListItemsAsync(ctx, accountID, ID, items) + if err != nil { + return []IPListItem{}, err + } + + err = api.pollIPListBulkOperation(ctx, accountID, result.Result.OperationID) + if err != nil { + return []IPListItem{}, err + } + + return api.ListIPListItems(ctx, accountID, ID) +} + +// DeleteIPListItemsAsync removes specific Items of an IP List by their ID +// asynchronously. Users have to poll the operation status by using the +// operation_id returned by this function. +// +// API reference: https://api.cloudflare.com/#rules-lists-delete-list-items +// +// Deprecated: Use `DeleteListItemsAsync` instead. +func (api *API) DeleteIPListItemsAsync(ctx context.Context, accountID, ID string, items IPListItemDeleteRequest) ( + IPListItemDeleteResponse, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, items) + if err != nil { + return IPListItemDeleteResponse{}, err + } + + result := IPListItemDeleteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListItemDeleteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// DeleteIPListItems removes specific Items of an IP List by their ID +// synchronously and returns the current set of IP List Items. +// +// Deprecated: Use `DeleteListItems` instead. +func (api *API) DeleteIPListItems(ctx context.Context, accountID, ID string, items IPListItemDeleteRequest) ( + []IPListItem, error) { + result, err := api.DeleteIPListItemsAsync(ctx, accountID, ID, items) + if err != nil { + return []IPListItem{}, err + } + + err = api.pollIPListBulkOperation(ctx, accountID, result.Result.OperationID) + if err != nil { + return []IPListItem{}, err + } + + return api.ListIPListItems(ctx, accountID, ID) +} + +// GetIPListItem returns a single IP List Item. +// +// API reference: https://api.cloudflare.com/#rules-lists-get-list-item +// +// Deprecated: Use `GetListItem` instead. +func (api *API) GetIPListItem(ctx context.Context, accountID, listID, id string) (IPListItem, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items/%s", accountID, listID, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return IPListItem{}, err + } + + result := IPListItemsGetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListItem{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetIPListBulkOperation returns the status of a bulk operation. +// +// API reference: https://api.cloudflare.com/#rules-lists-get-bulk-operation +// +// Deprecated: Use `GetListBulkOperation` instead. +func (api *API) GetIPListBulkOperation(ctx context.Context, accountID, ID string) (IPListBulkOperation, error) { + uri := fmt.Sprintf("/accounts/%s/rules/lists/bulk_operations/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return IPListBulkOperation{}, err + } + + result := IPListBulkOperationResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return IPListBulkOperation{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// pollIPListBulkOperation implements synchronous behaviour for some +// asynchronous endpoints. bulk-operation status can be either pending, running, +// failed or completed. +func (api *API) pollIPListBulkOperation(ctx context.Context, accountID, ID string) error { + for i := uint8(0); i < 16; i++ { + sleepDuration := 1 << (i / 2) * time.Second + select { + case <-time.After(sleepDuration): + case <-ctx.Done(): + return fmt.Errorf("operation aborted during backoff: %w", ctx.Err()) + } + + bulkResult, err := api.GetIPListBulkOperation(ctx, accountID, ID) + if err != nil { + return err + } + + switch bulkResult.Status { + case "failed": + return errors.New(bulkResult.Error) + case "pending", "running": + continue + case "completed": + return nil + default: + return fmt.Errorf("%s: %s", errOperationUnexpectedStatus, bulkResult.Status) + } + } + + return errors.New(errOperationStillRunning) +} diff --git a/pkg/cloudflare-go/ip_list_test.go b/pkg/cloudflare-go/ip_list_test.go new file mode 100644 index 000000000..88369b8cd --- /dev/null +++ b/pkg/cloudflare-go/ip_list_test.go @@ -0,0 +1,474 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListIPLists(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This is a note.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := []IPList{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This is a note.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListIPLists(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateIPList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This is a note.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := IPList{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This is a note.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.CreateIPList(context.Background(), testAccountID, "list1", "This is a note.", "ip") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetIPList(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This is a note.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := IPList{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This is a note.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.GetIPList(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateIPList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This note was updated.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := IPList{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This note was updated.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.UpdateIPList(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", + "This note was updated.") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteIPList(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "34b12448945f11eaa1b71c4d701ab86e" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + want := IPListDeleteResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.ID = "34b12448945f11eaa1b71c4d701ab86e" + + actual, err := client.DeleteIPList(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListIPListsItems(t *testing.T) { + setup() + defer teardown() + + 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") + + if len(r.URL.Query().Get("cursor")) > 0 && r.URL.Query().Get("cursor") == "yyy" { + fmt.Fprint(w, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "ip": "192.0.2.2", + "comment": "Another Private IP address", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } else { + fmt.Fprint(w, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "ip": "192.0.2.1", + "comment": "Private IP address", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx", + "after": "yyy" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := []IPListItem{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + IP: "192.0.2.1", + Comment: "Private IP address", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + IP: "192.0.2.2", + Comment: "Another Private IP address", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListIPListItems(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateIPListItems(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := IPListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.CreateIPListItemsAsync(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", + []IPListItemCreateRequest{{ + IP: "192.0.2.1", + Comment: "Private IP", + }, { + IP: "192.0.2.2", + Comment: "Another Private IP", + }}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestReplaceIPListItems(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := IPListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.ReplaceIPListItemsAsync(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", + []IPListItemCreateRequest{{ + IP: "192.0.2.1", + Comment: "Private IP", + }, { + IP: "192.0.2.2", + Comment: "Another Private IP", + }}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteIPListItems(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := IPListItemDeleteResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.DeleteIPListItemsAsync(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", + IPListItemDeleteRequest{[]IPListItemDeleteItemRequest{{ + ID: "34b12448945f11eaa1b71c4d701ab86e", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetIPListItem(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "ip": "192.0.2.1", + "comment": "Private IP address", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items/"+ + "34b12448945f11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := IPListItem{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + IP: "192.0.2.1", + Comment: "Private IP address", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.GetIPListItem(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", + "34b12448945f11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestPollIPListTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + + start := time.Now() + err := client.pollIPListBulkOperation(ctx, testAccountID, "list1") + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.WithinDuration(t, start, time.Now(), time.Second, + "pollIPListBulkOperation took too much time with an expiring context") +} diff --git a/pkg/cloudflare-go/ips.go b/pkg/cloudflare-go/ips.go new file mode 100644 index 000000000..3f181a79b --- /dev/null +++ b/pkg/cloudflare-go/ips.go @@ -0,0 +1,72 @@ +package cloudflare + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/goccy/go-json" +) + +// IPRangesResponse contains the structure for the API response, not modified. +type IPRangesResponse struct { + IPv4CIDRs []string `json:"ipv4_cidrs"` + IPv6CIDRs []string `json:"ipv6_cidrs"` + ChinaColos []string `json:"china_colos"` +} + +// IPRanges contains lists of IPv4 and IPv6 CIDRs. +type IPRanges struct { + IPv4CIDRs []string `json:"ipv4_cidrs"` + IPv6CIDRs []string `json:"ipv6_cidrs"` + ChinaIPv4CIDRs []string `json:"china_ipv4_cidrs"` + ChinaIPv6CIDRs []string `json:"china_ipv6_cidrs"` +} + +// IPsResponse is the API response containing a list of IPs. +type IPsResponse struct { + Response + Result IPRangesResponse `json:"result"` +} + +// IPs gets a list of Cloudflare's IP ranges. +// +// This does not require logging in to the API. +// +// API reference: https://api.cloudflare.com/#cloudflare-ips +func IPs() (IPRanges, error) { + uri := fmt.Sprintf("%s/ips?china_colo=1", apiURL) + resp, err := http.Get(uri) //nolint:gosec + if err != nil { + return IPRanges{}, fmt.Errorf("HTTP request failed: %w", err) + } + if resp.StatusCode != http.StatusOK { + return IPRanges{}, errors.New("HTTP request failed: status is not HTTP 200") + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return IPRanges{}, fmt.Errorf("Response body could not be read: %w", err) + } + var r IPsResponse + err = json.Unmarshal(body, &r) + if err != nil { + return IPRanges{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + var ips IPRanges + ips.IPv4CIDRs = r.Result.IPv4CIDRs + ips.IPv6CIDRs = r.Result.IPv6CIDRs + + for _, ip := range r.Result.ChinaColos { + if strings.Contains(ip, ":") { + ips.ChinaIPv6CIDRs = append(ips.ChinaIPv6CIDRs, ip) + } else { + ips.ChinaIPv4CIDRs = append(ips.ChinaIPv4CIDRs, ip) + } + } + + return ips, nil +} diff --git a/pkg/cloudflare-go/ips_test.go b/pkg/cloudflare-go/ips_test.go new file mode 100644 index 000000000..cc3b95930 --- /dev/null +++ b/pkg/cloudflare-go/ips_test.go @@ -0,0 +1,72 @@ +package cloudflare + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +type MockTransport struct { + http.Transport + Server *httptest.Server + Path string +} + +func (m *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + url, err := url.Parse(m.Server.URL + m.Path) + if err != nil { + return nil, err + } + + req.URL = url + + return m.Transport.RoundTrip(req) +} + +func Test_IPs(t *testing.T) { + setup() + defer teardown() + + mux := http.NewServeMux() + server = httptest.NewServer(mux) + defer server.Close() + + defaultTransport := http.DefaultTransport + http.DefaultTransport = &MockTransport{ + Server: server, + Path: "/ips", + } + defer func() { http.DefaultTransport = defaultTransport }() + + mux.HandleFunc("/ips", 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ipv4_cidrs": ["198.51.100.0/24"], + "ipv6_cidrs": ["ffff:ffff::/32"], + "china_colos": ["42.81.6.0/25", "2408:871a:1801:7::/72"] + } +}`) + }) + + ipRanges, err := IPs() + + assert.NoError(t, err) + + assert.Len(t, ipRanges.IPv4CIDRs, 1) + assert.Equal(t, "198.51.100.0/24", ipRanges.IPv4CIDRs[0]) + assert.Len(t, ipRanges.IPv6CIDRs, 1) + assert.Equal(t, "ffff:ffff::/32", ipRanges.IPv6CIDRs[0]) + assert.Len(t, ipRanges.ChinaIPv4CIDRs, 1) + assert.Equal(t, "42.81.6.0/25", ipRanges.ChinaIPv4CIDRs[0]) + assert.Len(t, ipRanges.ChinaIPv6CIDRs, 1) + assert.Equal(t, "2408:871a:1801:7::/72", ipRanges.ChinaIPv6CIDRs[0]) +} diff --git a/pkg/cloudflare-go/keyless.go b/pkg/cloudflare-go/keyless.go new file mode 100644 index 000000000..39896af9b --- /dev/null +++ b/pkg/cloudflare-go/keyless.go @@ -0,0 +1,146 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// KeylessSSL represents Keyless SSL configuration. +type KeylessSSL struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + Permissions []string `json:"permissions"` + CreatedOn time.Time `json:"created_on"` + ModifiedOn time.Time `json:"modified_on"` +} + +// KeylessSSLCreateRequest represents the request format made for creating KeylessSSL. +type KeylessSSLCreateRequest struct { + Host string `json:"host"` + Port int `json:"port"` + Certificate string `json:"certificate"` + Name string `json:"name,omitempty"` + BundleMethod string `json:"bundle_method,omitempty"` +} + +// KeylessSSLDetailResponse is the API response, containing a single Keyless SSL. +type KeylessSSLDetailResponse struct { + Response + Result KeylessSSL `json:"result"` +} + +// KeylessSSLListResponse represents the response from the Keyless SSL list endpoint. +type KeylessSSLListResponse struct { + Response + Result []KeylessSSL `json:"result"` +} + +// KeylessSSLUpdateRequest represents the request for updating KeylessSSL. +type KeylessSSLUpdateRequest struct { + Host string `json:"host,omitempty"` + Name string `json:"name,omitempty"` + Port int `json:"port,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// CreateKeylessSSL creates a new Keyless SSL configuration for the zone. +// +// API reference: https://api.cloudflare.com/#keyless-ssl-for-a-zone-create-keyless-ssl-configuration +func (api *API) CreateKeylessSSL(ctx context.Context, zoneID string, keylessSSL KeylessSSLCreateRequest) (KeylessSSL, error) { + uri := fmt.Sprintf("/zones/%s/keyless_certificates", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, keylessSSL) + if err != nil { + return KeylessSSL{}, err + } + + var keylessSSLDetailResponse KeylessSSLDetailResponse + err = json.Unmarshal(res, &keylessSSLDetailResponse) + if err != nil { + return KeylessSSL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return keylessSSLDetailResponse.Result, nil +} + +// ListKeylessSSL lists Keyless SSL configurations for a zone. +// +// API reference: https://api.cloudflare.com/#keyless-ssl-for-a-zone-list-keyless-ssl-configurations +func (api *API) ListKeylessSSL(ctx context.Context, zoneID string) ([]KeylessSSL, error) { + uri := fmt.Sprintf("/zones/%s/keyless_certificates", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var keylessSSLListResponse KeylessSSLListResponse + err = json.Unmarshal(res, &keylessSSLListResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return keylessSSLListResponse.Result, nil +} + +// KeylessSSL provides the configuration for a given Keyless SSL identifier. +// +// API reference: https://api.cloudflare.com/#keyless-ssl-for-a-zone-keyless-ssl-details +func (api *API) KeylessSSL(ctx context.Context, zoneID, keylessSSLID string) (KeylessSSL, error) { + uri := fmt.Sprintf("/zones/%s/keyless_certificates/%s", zoneID, keylessSSLID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return KeylessSSL{}, err + } + + var keylessResponse KeylessSSLDetailResponse + err = json.Unmarshal(res, &keylessResponse) + if err != nil { + return KeylessSSL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return keylessResponse.Result, nil +} + +// UpdateKeylessSSL updates an existing Keyless SSL configuration. +// +// API reference: https://api.cloudflare.com/#keyless-ssl-for-a-zone-edit-keyless-ssl-configuration +func (api *API) UpdateKeylessSSL(ctx context.Context, zoneID, kelessSSLID string, keylessSSL KeylessSSLUpdateRequest) (KeylessSSL, error) { + uri := fmt.Sprintf("/zones/%s/keyless_certificates/%s", zoneID, kelessSSLID) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, keylessSSL) + if err != nil { + return KeylessSSL{}, err + } + + var keylessSSLDetailResponse KeylessSSLDetailResponse + err = json.Unmarshal(res, &keylessSSLDetailResponse) + if err != nil { + return KeylessSSL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return keylessSSLDetailResponse.Result, nil +} + +// DeleteKeylessSSL deletes an existing Keyless SSL configuration. +// +// API reference: https://api.cloudflare.com/#keyless-ssl-for-a-zone-delete-keyless-ssl-configuration +func (api *API) DeleteKeylessSSL(ctx context.Context, zoneID, keylessSSLID string) error { + uri := fmt.Sprintf("/zones/%s/keyless_certificates/%s", zoneID, keylessSSLID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/keyless_test.go b/pkg/cloudflare-go/keyless_test.go new file mode 100644 index 000000000..4b90601da --- /dev/null +++ b/pkg/cloudflare-go/keyless_test.go @@ -0,0 +1,262 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateKeylessSSL(t *testing.T) { + setup() + defer teardown() + + input := KeylessSSLCreateRequest{ + Host: "example.com", + Port: 24008, + Certificate: "-----BEGIN CERTIFICATE----- MIIDtTCCAp2g1v2tdw= -----END CERTIFICATE-----", + Name: "example.com Keyless SSL", + BundleMethod: "optimal", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + var v KeylessSSLCreateRequest + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "4d2844d2ce78891c34d0b6c0535a291e", + "name": "example.com Keyless SSL", + "host": "example.com", + "port": 24008, + "status": "active", + "enabled": false, + "permissions": [ + "#ssl:read", + "#ssl:edit" + ], + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/keyless_certificates", handler) + + actual, err := client.CreateKeylessSSL(context.Background(), testZoneID, input) + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := KeylessSSL{ + ID: "4d2844d2ce78891c34d0b6c0535a291e", + Name: input.Name, + Host: input.Host, + Port: input.Port, + Status: "active", + Enabled: false, + Permissions: []string{"#ssl:read", "#ssl:edit"}, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + } + assert.Equal(t, want, actual) +} + +func TestListKeylessSSL(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success":true, + "errors":[], + "messages":[], + "result":[ + { + "id":"4d2844d2ce78891c34d0b6c0535a291e", + "name":"example.com Keyless SSL", + "host":"example.com", + "port":24008, + "status":"active", + "enabled":false, + "permissions":[ + "#ssl:read", + "#ssl:edit" + ], + "created_on":"2014-01-01T05:20:00Z", + "modified_on":"2014-01-01T05:20:00Z" + } + ] + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/keyless_certificates", handler) + + actual, err := client.ListKeylessSSL(context.Background(), testZoneID) + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := []KeylessSSL{{ + ID: "4d2844d2ce78891c34d0b6c0535a291e", + Name: "example.com Keyless SSL", + Host: "example.com", + Port: 24008, + Status: "active", + Enabled: false, + Permissions: []string{"#ssl:read", "#ssl:edit"}, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + }} + assert.Equal(t, want, actual) +} + +func TestKeylessSSL(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"4d2844d2ce78891c34d0b6c0535a291e", + "name":"example.com Keyless SSL", + "host":"example.com", + "port":24008, + "status":"active", + "enabled":false, + "permissions":[ + "#ssl:read", + "#ssl:edit" + ], + "created_on":"2014-01-01T05:20:00Z", + "modified_on":"2014-01-01T05:20:00Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/keyless_certificates/"+"4d2844d2ce78891c34d0b6c0535a291e", handler) + + actual, err := client.KeylessSSL(context.Background(), testZoneID, "4d2844d2ce78891c34d0b6c0535a291e") + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := KeylessSSL{ + ID: "4d2844d2ce78891c34d0b6c0535a291e", + Name: "example.com Keyless SSL", + Host: "example.com", + Port: 24008, + Status: "active", + Enabled: false, + Permissions: []string{"#ssl:read", "#ssl:edit"}, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + } + assert.Equal(t, want, actual) +} + +func TestUpdateKeylessSSL(t *testing.T) { + setup() + defer teardown() + + enabled := false + input := KeylessSSLUpdateRequest{ + Host: "example.com", + Name: "example.com Keyless SSL", + Port: 24008, + Enabled: &enabled, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + var v KeylessSSLUpdateRequest + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, input, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"4d2844d2ce78891c34d0b6c0535a291e", + "name":"example.com Keyless SSL", + "host":"example.com", + "port":24008, + "status":"active", + "enabled":false, + "permissions":[ + "#ssl:read", + "#ssl:edit" + ], + "created_on":"2014-01-01T05:20:00Z", + "modified_on":"2014-01-01T05:20:00Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/keyless_certificates/"+"4d2844d2ce78891c34d0b6c0535a291e", handler) + + actual, err := client.UpdateKeylessSSL(context.Background(), testZoneID, "4d2844d2ce78891c34d0b6c0535a291e", input) + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := KeylessSSL{ + ID: "4d2844d2ce78891c34d0b6c0535a291e", + Name: input.Name, + Host: input.Host, + Port: input.Port, + Status: "active", + Enabled: enabled, + Permissions: []string{"#ssl:read", "#ssl:edit"}, + CreatedOn: createdOn, + ModifiedOn: modifiedOn, + } + assert.Equal(t, want, actual) +} + +func TestDeleteKeylessSSL(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success":true, + "errors":[], + "messages":[], + "result":{ + "id":"4d2844d2ce78891c34d0b6c0535a291e" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/keyless_certificates/"+"4d2844d2ce78891c34d0b6c0535a291e", handler) + + err := client.DeleteKeylessSSL(context.Background(), testZoneID, "4d2844d2ce78891c34d0b6c0535a291e") + require.NoError(t, err) +} diff --git a/pkg/cloudflare-go/list.go b/pkg/cloudflare-go/list.go new file mode 100644 index 000000000..f7cef4aca --- /dev/null +++ b/pkg/cloudflare-go/list.go @@ -0,0 +1,629 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +const ( + // ListTypeIP specifies a list containing IP addresses. + ListTypeIP = "ip" + // ListTypeRedirect specifies a list containing redirects. + ListTypeRedirect = "redirect" + // ListTypeHostname specifies a list containing hostnames. + ListTypeHostname = "hostname" + // ListTypeHostname specifies a list containing autonomous system numbers (ASNs). + ListTypeASN = "asn" +) + +// ListBulkOperation contains information about a Bulk Operation. +type ListBulkOperation struct { + ID string `json:"id"` + Status string `json:"status"` + Error string `json:"error"` + Completed *time.Time `json:"completed"` +} + +// List contains information about a List. +type List struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` + NumItems int `json:"num_items"` + NumReferencingFilters int `json:"num_referencing_filters"` + CreatedOn *time.Time `json:"created_on"` + ModifiedOn *time.Time `json:"modified_on"` +} + +// Redirect represents a redirect item in a List. +type Redirect struct { + SourceUrl string `json:"source_url"` + IncludeSubdomains *bool `json:"include_subdomains,omitempty"` + TargetUrl string `json:"target_url"` + StatusCode *int `json:"status_code,omitempty"` + PreserveQueryString *bool `json:"preserve_query_string,omitempty"` + SubpathMatching *bool `json:"subpath_matching,omitempty"` + PreservePathSuffix *bool `json:"preserve_path_suffix,omitempty"` +} + +type Hostname struct { + UrlHostname string `json:"url_hostname"` +} + +// ListItem contains information about a single List Item. +type ListItem struct { + ID string `json:"id"` + IP *string `json:"ip,omitempty"` + Redirect *Redirect `json:"redirect,omitempty"` + Hostname *Hostname `json:"hostname,omitempty"` + ASN *uint32 `json:"asn,omitempty"` + Comment string `json:"comment"` + CreatedOn *time.Time `json:"created_on"` + ModifiedOn *time.Time `json:"modified_on"` +} + +// ListCreateRequest contains data for a new List. +type ListCreateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` +} + +// ListItemCreateRequest contains data for a new List Item. +type ListItemCreateRequest struct { + IP *string `json:"ip,omitempty"` + Redirect *Redirect `json:"redirect,omitempty"` + Hostname *Hostname `json:"hostname,omitempty"` + ASN *uint32 `json:"asn,omitempty"` + Comment string `json:"comment"` +} + +// ListItemDeleteRequest wraps List Items that shall be deleted. +type ListItemDeleteRequest struct { + Items []ListItemDeleteItemRequest `json:"items"` +} + +// ListItemDeleteItemRequest contains single List Items that shall be deleted. +type ListItemDeleteItemRequest struct { + ID string `json:"id"` +} + +// ListUpdateRequest contains data for a List update. +type ListUpdateRequest struct { + Description string `json:"description"` +} + +// ListResponse contains a single List. +type ListResponse struct { + Response + Result List `json:"result"` +} + +// ListItemCreateResponse contains information about the creation of a List Item. +type ListItemCreateResponse struct { + Response + Result struct { + OperationID string `json:"operation_id"` + } `json:"result"` +} + +// ListListResponse contains a slice of Lists. +type ListListResponse struct { + Response + Result []List `json:"result"` +} + +// ListBulkOperationResponse contains information about a Bulk Operation. +type ListBulkOperationResponse struct { + Response + Result ListBulkOperation `json:"result"` +} + +// ListDeleteResponse contains information about the deletion of a List. +type ListDeleteResponse struct { + Response + Result struct { + ID string `json:"id"` + } `json:"result"` +} + +// ListItemsListResponse contains information about List Items. +type ListItemsListResponse struct { + Response + ResultInfo `json:"result_info"` + Result []ListItem `json:"result"` +} + +// ListItemDeleteResponse contains information about the deletion of a List Item. +type ListItemDeleteResponse struct { + Response + Result struct { + OperationID string `json:"operation_id"` + } `json:"result"` +} + +// ListItemsGetResponse contains information about a single List Item. +type ListItemsGetResponse struct { + Response + Result ListItem `json:"result"` +} + +type ListListsParams struct { +} + +type ListCreateParams struct { + Name string + Description string + Kind string +} + +type ListGetParams struct { + ID string +} + +type ListUpdateParams struct { + ID string + Description string +} + +type ListDeleteParams struct { + ID string +} + +type ListListItemsParams struct { + ID string `url:"-"` + Search string `url:"search,omitempty"` + PerPage int `url:"per_page,omitempty"` + Cursor string `url:"cursor,omitempty"` +} + +type ListCreateItemsParams struct { + ID string + Items []ListItemCreateRequest +} + +type ListCreateItemParams struct { + ID string + Item ListItemCreateRequest +} + +type ListReplaceItemsParams struct { + ID string + Items []ListItemCreateRequest +} + +type ListDeleteItemsParams struct { + ID string + Items ListItemDeleteRequest +} + +type ListGetItemParams struct { + ListID string + ID string +} + +type ListGetBulkOperationParams struct { + ID string +} + +// ListLists lists all Lists. +// +// API reference: https://api.cloudflare.com/#rules-lists-list-lists +func (api *API) ListLists(ctx context.Context, rc *ResourceContainer, params ListListsParams) ([]List, error) { + if rc.Identifier == "" { + return []List{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []List{}, err + } + + result := ListListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []List{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// CreateList creates a new List. +// +// API reference: https://api.cloudflare.com/#rules-lists-create-list +func (api *API) CreateList(ctx context.Context, rc *ResourceContainer, params ListCreateParams) (List, error) { + if rc.Identifier == "" { + return List{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, ListCreateRequest{Name: params.Name, Description: params.Description, Kind: params.Kind}) + if err != nil { + return List{}, err + } + + result := ListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return List{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetList returns a single List. +// +// API reference: https://api.cloudflare.com/#rules-lists-get-list +func (api *API) GetList(ctx context.Context, rc *ResourceContainer, listID string) (List, error) { + if rc.Identifier == "" { + return List{}, ErrMissingAccountID + } + + if listID == "" { + return List{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s", rc.Identifier, listID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return List{}, err + } + + result := ListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return List{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateList updates the description of an existing List. +// +// API reference: https://api.cloudflare.com/#rules-lists-update-list +func (api *API) UpdateList(ctx context.Context, rc *ResourceContainer, params ListUpdateParams) (List, error) { + if rc.Identifier == "" { + return List{}, ErrMissingAccountID + } + + if params.ID == "" { + return List{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, ListUpdateRequest{Description: params.Description}) + if err != nil { + return List{}, err + } + + result := ListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return List{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteList deletes a List. +// +// API reference: https://api.cloudflare.com/#rules-lists-delete-list +func (api *API) DeleteList(ctx context.Context, rc *ResourceContainer, listID string) (ListDeleteResponse, error) { + if rc.Identifier == "" { + return ListDeleteResponse{}, ErrMissingAccountID + } + + if listID == "" { + return ListDeleteResponse{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s", rc.Identifier, listID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return ListDeleteResponse{}, err + } + + result := ListDeleteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListDeleteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// ListListItems returns a list with all items in a List. +// +// API reference: https://api.cloudflare.com/#rules-lists-list-list-items +func (api *API) ListListItems(ctx context.Context, rc *ResourceContainer, params ListListItemsParams) ([]ListItem, error) { + var list []ListItem + + for { + uri := buildURI(fmt.Sprintf("/accounts/%s/rules/lists/%s/items", rc.Identifier, params.ID), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []ListItem{}, err + } + + result := ListItemsListResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []ListItem{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + list = append(list, result.Result...) + if cursor := result.ResultInfo.Cursors.After; cursor == "" { + break + } else { + params.Cursor = cursor + } + } + + return list, nil +} + +// CreateListItemAsync creates a new List Item asynchronously. Users have to poll the operation status by +// using the operation_id returned by this function. +// +// API reference: https://api.cloudflare.com/#rules-lists-create-list-items +func (api *API) CreateListItemAsync(ctx context.Context, rc *ResourceContainer, params ListCreateItemParams) (ListItemCreateResponse, error) { + if rc.Identifier == "" { + return ListItemCreateResponse{}, ErrMissingAccountID + } + + if params.ID == "" { + return ListItemCreateResponse{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, []ListItemCreateRequest{params.Item}) + if err != nil { + return ListItemCreateResponse{}, err + } + + result := ListItemCreateResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListItemCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// CreateListItem creates a new List Item synchronously and returns the current set of List Items. +func (api *API) CreateListItem(ctx context.Context, rc *ResourceContainer, params ListCreateItemParams) ([]ListItem, error) { + result, err := api.CreateListItemAsync(ctx, rc, params) + + if err != nil { + return []ListItem{}, err + } + + err = api.pollListBulkOperation(ctx, rc, result.Result.OperationID) + if err != nil { + return []ListItem{}, err + } + + return api.ListListItems(ctx, rc, ListListItemsParams{ID: params.ID}) +} + +// CreateListItemsAsync bulk creates multiple List Items asynchronously. Users +// have to poll the operation status by using the operation_id returned by this +// function. +// +// API reference: https://api.cloudflare.com/#rules-lists-create-list-items +func (api *API) CreateListItemsAsync(ctx context.Context, rc *ResourceContainer, params ListCreateItemsParams) (ListItemCreateResponse, error) { + if rc.Identifier == "" { + return ListItemCreateResponse{}, ErrMissingAccountID + } + + if params.ID == "" { + return ListItemCreateResponse{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Items) + if err != nil { + return ListItemCreateResponse{}, err + } + + result := ListItemCreateResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListItemCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// CreateListItems bulk creates multiple List Items synchronously and returns +// the current set of List Items. +func (api *API) CreateListItems(ctx context.Context, rc *ResourceContainer, params ListCreateItemsParams) ([]ListItem, error) { + result, err := api.CreateListItemsAsync(ctx, rc, params) + if err != nil { + return []ListItem{}, err + } + + err = api.pollListBulkOperation(ctx, rc, result.Result.OperationID) + if err != nil { + return []ListItem{}, err + } + + return api.ListListItems(ctx, rc, ListListItemsParams{ID: params.ID}) +} + +// ReplaceListItemsAsync replaces all List Items asynchronously. Users have to +// poll the operation status by using the operation_id returned by this +// function. +// +// API reference: https://api.cloudflare.com/#rules-lists-replace-list-items +func (api *API) ReplaceListItemsAsync(ctx context.Context, rc *ResourceContainer, params ListReplaceItemsParams) (ListItemCreateResponse, error) { + if rc.Identifier == "" { + return ListItemCreateResponse{}, ErrMissingAccountID + } + + if params.ID == "" { + return ListItemCreateResponse{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Items) + if err != nil { + return ListItemCreateResponse{}, err + } + + result := ListItemCreateResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListItemCreateResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// ReplaceListItems replaces all List Items synchronously and returns the +// current set of List Items. +func (api *API) ReplaceListItems(ctx context.Context, rc *ResourceContainer, params ListReplaceItemsParams) ( + []ListItem, error) { + result, err := api.ReplaceListItemsAsync(ctx, rc, params) + if err != nil { + return []ListItem{}, err + } + + err = api.pollListBulkOperation(ctx, rc, result.Result.OperationID) + if err != nil { + return []ListItem{}, err + } + + return api.ListListItems(ctx, rc, ListListItemsParams{ID: params.ID}) +} + +// DeleteListItemsAsync removes specific Items of a List by their ID +// asynchronously. Users have to poll the operation status by using the +// operation_id returned by this function. +// +// API reference: https://api.cloudflare.com/#rules-lists-delete-list-items +func (api *API) DeleteListItemsAsync(ctx context.Context, rc *ResourceContainer, params ListDeleteItemsParams) (ListItemDeleteResponse, error) { + if rc.Identifier == "" { + return ListItemDeleteResponse{}, ErrMissingAccountID + } + + if params.ID == "" { + return ListItemDeleteResponse{}, ErrMissingListID + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, params.Items) + if err != nil { + return ListItemDeleteResponse{}, err + } + + result := ListItemDeleteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListItemDeleteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, nil +} + +// DeleteListItems removes specific Items of a List by their ID synchronously +// and returns the current set of List Items. +func (api *API) DeleteListItems(ctx context.Context, rc *ResourceContainer, params ListDeleteItemsParams) ([]ListItem, error) { + result, err := api.DeleteListItemsAsync(ctx, rc, params) + if err != nil { + return []ListItem{}, err + } + + err = api.pollListBulkOperation(ctx, rc, result.Result.OperationID) + if err != nil { + return []ListItem{}, err + } + + return api.ListListItems(ctx, AccountIdentifier(rc.Identifier), ListListItemsParams{ID: params.ID}) +} + +// GetListItem returns a single List Item. +// +// API reference: https://api.cloudflare.com/#rules-lists-get-list-item +func (api *API) GetListItem(ctx context.Context, rc *ResourceContainer, listID, itemID string) (ListItem, error) { + if rc.Identifier == "" { + return ListItem{}, ErrMissingAccountID + } + + if listID == "" { + return ListItem{}, ErrMissingListID + } + + if itemID == "" { + return ListItem{}, ErrMissingResourceIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/%s/items/%s", rc.Identifier, listID, itemID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ListItem{}, err + } + + result := ListItemsGetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListItem{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetListBulkOperation returns the status of a bulk operation. +// +// API reference: https://api.cloudflare.com/#rules-lists-get-bulk-operation +func (api *API) GetListBulkOperation(ctx context.Context, rc *ResourceContainer, ID string) (ListBulkOperation, error) { + if rc.Identifier == "" { + return ListBulkOperation{}, ErrMissingAccountID + } + + if ID == "" { + return ListBulkOperation{}, ErrMissingResourceIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/rules/lists/bulk_operations/%s", rc.Identifier, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ListBulkOperation{}, err + } + + result := ListBulkOperationResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ListBulkOperation{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// pollListBulkOperation implements synchronous behaviour for some asynchronous +// endpoints. bulk-operation status can be either pending, running, failed or +// completed. +func (api *API) pollListBulkOperation(ctx context.Context, rc *ResourceContainer, ID string) error { + for i := uint8(0); i < 16; i++ { + sleepDuration := 1 << (i / 2) * time.Second + select { + case <-time.After(sleepDuration): + case <-ctx.Done(): + return fmt.Errorf("operation aborted during backoff: %w", ctx.Err()) + } + + bulkResult, err := api.GetListBulkOperation(ctx, rc, ID) + if err != nil { + return err + } + + switch bulkResult.Status { + case "failed": + return errors.New(bulkResult.Error) + case "pending", "running": + continue + case "completed": + return nil + default: + return fmt.Errorf("%s: %s", errOperationUnexpectedStatus, bulkResult.Status) + } + } + + return errors.New(errOperationStillRunning) +} diff --git a/pkg/cloudflare-go/list_test.go b/pkg/cloudflare-go/list_test.go new file mode 100644 index 000000000..0a622f8d1 --- /dev/null +++ b/pkg/cloudflare-go/list_test.go @@ -0,0 +1,1045 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListLists(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This is a note.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := []List{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This is a note.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListLists(context.Background(), AccountIdentifier(testAccountID), ListListsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This is a note.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := List{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This is a note.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.CreateList(context.Background(), AccountIdentifier(testAccountID), ListCreateParams{ + Name: "list1", Description: "This is a note.", Kind: "ip", + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetList(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This is a note.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := List{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This is a note.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.GetList(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "list1", + "description": "This note was updated.", + "kind": "ip", + "num_items": 10, + "num_referencing_filters": 2, + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := List{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "list1", + Description: "This note was updated.", + Kind: "ip", + NumItems: 10, + NumReferencingFilters: 2, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.UpdateList(context.Background(), AccountIdentifier(testAccountID), + ListUpdateParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", Description: "This note was updated.", + }, + ) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteList(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "34b12448945f11eaa1b71c4d701ab86e" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + want := ListDeleteResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.ID = "34b12448945f11eaa1b71c4d701ab86e" + + actual, err := client.DeleteList(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListsItemsIP(t *testing.T) { + setup() + defer teardown() + + 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") + + if len(r.URL.Query().Get("cursor")) > 0 && r.URL.Query().Get("cursor") == "yyy" { + fmt.Fprint(w, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "ip": "192.0.2.2", + "comment": "Another Private IP address", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } else { + fmt.Fprint(w, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "ip": "192.0.2.1", + "comment": "Private IP address", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx", + "after": "yyy" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := []ListItem{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + IP: StringPtr("192.0.2.1"), + Comment: "Private IP address", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + IP: StringPtr("192.0.2.2"), + Comment: "Another Private IP address", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListListItems(context.Background(), AccountIdentifier(testAccountID), ListListItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListsItemsRedirect(t *testing.T) { + setup() + defer teardown() + + 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") + + if len(r.URL.Query().Get("cursor")) > 0 && r.URL.Query().Get("cursor") == "yyy" { + fmt.Fprint(w, `{ + "result": [ + { + "id": "1c0fc9fa937b11eaa1b71c4d701ab86e", + "redirect": { + "source_url": "www.3fonteinen.be", + "target_url": "https://shop.3fonteinen.be" + }, + "comment": "3F redirect", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } else { + fmt.Fprint(w, `{ + "result": [ + { + "id": "0c0fc9fa937b11eaa1b71c4d701ab86e", + "redirect": { + "source_url": "http://cloudflare.com", + "include_subdomains": true, + "target_url": "https://cloudflare.com", + "status_code": 302, + "preserve_query_string": true, + "subpath_matching": true, + "preserve_path_suffix": false + }, + "comment": "Cloudflare http redirect", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx", + "after": "yyy" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/0c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := []ListItem{ + { + ID: "0c0fc9fa937b11eaa1b71c4d701ab86e", + Redirect: &Redirect{ + SourceUrl: "http://cloudflare.com", + IncludeSubdomains: BoolPtr(true), + TargetUrl: "https://cloudflare.com", + StatusCode: IntPtr(302), + PreserveQueryString: BoolPtr(true), + SubpathMatching: BoolPtr(true), + PreservePathSuffix: BoolPtr(false), + }, + Comment: "Cloudflare http redirect", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + { + ID: "1c0fc9fa937b11eaa1b71c4d701ab86e", + Redirect: &Redirect{ + SourceUrl: "www.3fonteinen.be", + TargetUrl: "https://shop.3fonteinen.be", + }, + Comment: "3F redirect", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListListItems( + context.Background(), + AccountIdentifier(testAccountID), + ListListItemsParams{ID: "0c0fc9fa937b11eaa1b71c4d701ab86e"}, + ) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListsItemsHostname(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "0c0fc9fa937b11eaa1b71c4d701ab86e", + "hostname": { + "url_hostname": "cloudflare.com" + }, + "comment": "CF hostname", + "created_on": "2023-01-01T08:00:00Z", + "modified_on": "2023-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/0c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2023-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2023-01-10T14:00:00Z") + + want := []ListItem{ + { + ID: "0c0fc9fa937b11eaa1b71c4d701ab86e", + Hostname: &Hostname{ + UrlHostname: "cloudflare.com", + }, + Comment: "CF hostname", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListListItems( + context.Background(), + AccountIdentifier(testAccountID), + ListListItemsParams{ID: "0c0fc9fa937b11eaa1b71c4d701ab86e"}, + ) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListsItemsASN(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "0c0fc9fa937b11eaa1b71c4d701ab86e", + "asn": 3456, + "comment": "ASN", + "created_on": "2023-01-01T08:00:00Z", + "modified_on": "2023-01-10T14:00:00Z" + } + ], + "result_info": { + "cursors": { + "before": "xxx" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/0c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2023-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2023-01-10T14:00:00Z") + + want := []ListItem{ + { + ID: "0c0fc9fa937b11eaa1b71c4d701ab86e", + ASN: Uint32Ptr(3456), + Comment: "ASN", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + + actual, err := client.ListListItems( + context.Background(), + AccountIdentifier(testAccountID), + ListListItemsParams{ID: "0c0fc9fa937b11eaa1b71c4d701ab86e"}, + ) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateListItemsIP(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.CreateListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListCreateItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + IP: StringPtr("192.0.2.1"), + Comment: "Private IP", + }, { + IP: StringPtr("192.0.2.2"), + Comment: "Another Private IP", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateListItemsRedirect(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/0c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.CreateListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListCreateItemsParams{ + ID: "0c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + Redirect: &Redirect{ + SourceUrl: "www.3fonteinen.be", + TargetUrl: "https://shop.3fonteinen.be", + }, + Comment: "redirect 3F", + }, { + Redirect: &Redirect{ + SourceUrl: "www.cf.com", + TargetUrl: "https://cloudflare.com", + }, + Comment: "Redirect cf", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateListItemsHostname(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/0c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.CreateListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListCreateItemsParams{ + ID: "0c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + Hostname: &Hostname{ + UrlHostname: "3fonteinen.be", // ie. only match 3fonteinen.be + }, + Comment: "hostname 3F", + }, { + Hostname: &Hostname{ + UrlHostname: "*.cf.com", // ie. match all subdomains of cf.com but not cf.com + }, + Comment: "Hostname cf", + }, { + Hostname: &Hostname{ + UrlHostname: "*.abc.com", // ie. equivalent to match all subdomains of abc.com excluding abc.com + }, + Comment: "Hostname abc", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateListItemsASN(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/0c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.CreateListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListCreateItemsParams{ + ID: "0c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + ASN: Uint32Ptr(458), + Comment: "ASN 458", + }, { + ASN: Uint32Ptr(789), + Comment: "ASN 789", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestReplaceListItemsIP(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.ReplaceListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListReplaceItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + IP: StringPtr("192.0.2.1"), + Comment: "Private IP", + }, { + IP: StringPtr("192.0.2.2"), + Comment: "Another Private IP", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestReplaceListItemsRedirect(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.ReplaceListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListReplaceItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + Redirect: &Redirect{ + SourceUrl: "www.3fonteinen.be", + TargetUrl: "https://shop.3fonteinen.be", + }, + Comment: "redirect 3F", + }, { + Redirect: &Redirect{ + SourceUrl: "www.cf.com", + TargetUrl: "https://cloudflare.com", + }, + Comment: "Redirect cf", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestReplaceListItemsHostname(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.ReplaceListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListReplaceItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + Hostname: &Hostname{ + UrlHostname: "3fonteinen.be", + }, + Comment: "hostname 3F", + }, { + Hostname: &Hostname{ + UrlHostname: "cf.com", + }, + Comment: "Hostname cf", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestReplaceListItemsASN(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemCreateResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.ReplaceListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListReplaceItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Items: []ListItemCreateRequest{{ + ASN: Uint32Ptr(4567), + Comment: "ASN 4567", + }, { + ASN: Uint32Ptr(8901), + Comment: "ASN 8901", + }}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteListItems(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "operation_id": "4da8780eeb215e6cb7f48dd981c4ea02" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items", handler) + + want := ListItemDeleteResponse{} + want.Success = true + want.Errors = []ResponseInfo{} + want.Messages = []ResponseInfo{} + want.Result.OperationID = "4da8780eeb215e6cb7f48dd981c4ea02" + + actual, err := client.DeleteListItemsAsync(context.Background(), AccountIdentifier(testAccountID), ListDeleteItemsParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Items: ListItemDeleteRequest{[]ListItemDeleteItemRequest{{ + ID: "34b12448945f11eaa1b71c4d701ab86e", + }}}}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetListItemIP(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "ip": "192.0.2.1", + "comment": "Private IP address", + "created_on": "2020-01-01T08:00:00Z", + "modified_on": "2020-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items/"+ + "34b12448945f11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2020-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2020-01-10T14:00:00Z") + + want := ListItem{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + IP: StringPtr("192.0.2.1"), + Comment: "Private IP address", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.GetListItem(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e", "34b12448945f11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetListItemHostname(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "hostname": { + "url_hostname": "cloudflare.com" + }, + "comment": "CF Hostname", + "created_on": "2023-01-01T08:00:00Z", + "modified_on": "2023-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items/"+ + "34b12448945f11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2023-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2023-01-10T14:00:00Z") + + want := ListItem{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Hostname: &Hostname{ + UrlHostname: "cloudflare.com", + }, + Comment: "CF Hostname", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.GetListItem(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e", "34b12448945f11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetListItemASN(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "asn": 5555, + "comment": "asn 5555", + "created_on": "2023-01-01T08:00:00Z", + "modified_on": "2023-01-10T14:00:00Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rules/lists/2c0fc9fa937b11eaa1b71c4d701ab86e/items/"+ + "34b12448945f11eaa1b71c4d701ab86e", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2023-01-01T08:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2023-01-10T14:00:00Z") + + want := ListItem{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + ASN: Uint32Ptr(5555), + Comment: "asn 5555", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.GetListItem(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e", "34b12448945f11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestPollListTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + + start := time.Now() + err := client.pollListBulkOperation(ctx, AccountIdentifier(testAccountID), "list1") + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.WithinDuration(t, start, time.Now(), time.Second, + "pollListBulkOperation took too much time with an expiring context") +} diff --git a/pkg/cloudflare-go/load_balancing.go b/pkg/cloudflare-go/load_balancing.go new file mode 100644 index 000000000..44d7fd9fa --- /dev/null +++ b/pkg/cloudflare-go/load_balancing.go @@ -0,0 +1,821 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// LoadBalancerPool represents a load balancer pool's properties. +type LoadBalancerPool struct { + ID string `json:"id,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Description string `json:"description"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + MinimumOrigins *int `json:"minimum_origins,omitempty"` + Monitor string `json:"monitor,omitempty"` + Origins []LoadBalancerOrigin `json:"origins"` + NotificationEmail string `json:"notification_email,omitempty"` + Latitude *float32 `json:"latitude,omitempty"` + Longitude *float32 `json:"longitude,omitempty"` + LoadShedding *LoadBalancerLoadShedding `json:"load_shedding,omitempty"` + OriginSteering *LoadBalancerOriginSteering `json:"origin_steering,omitempty"` + Healthy *bool `json:"healthy,omitempty"` + + // CheckRegions defines the geographic region(s) from where to run health-checks from - e.g. "WNAM", "WEU", "SAF", "SAM". + // Providing a null/empty value means "all regions", which may not be available to all plan types. + CheckRegions []string `json:"check_regions"` +} + +// LoadBalancerOrigin represents a Load Balancer origin's properties. +type LoadBalancerOrigin struct { + Name string `json:"name"` + Address string `json:"address"` + Enabled bool `json:"enabled"` + // Weight of this origin relative to other origins in the pool. + // Based on the configured weight the total traffic is distributed + // among origins within the pool. + // + // When LoadBalancerOriginSteering.Policy="least_outstanding_requests", this + // weight is used to scale the origin's outstanding requests. + // When LoadBalancerOriginSteering.Policy="least_connections", this + // weight is used to scale the origin's open connections. + Weight float64 `json:"weight"` + Header map[string][]string `json:"header"` + // The virtual network subnet ID the origin belongs in. + // Virtual network must also belong to the account. + VirtualNetworkID string `json:"virtual_network_id,omitempty"` +} + +// LoadBalancerOriginSteering controls origin selection for new sessions and traffic without session affinity. +type LoadBalancerOriginSteering struct { + // Policy determines the type of origin steering policy to use. + // It defaults to "random" (weighted) when empty or unspecified. + // + // "random": Select an origin randomly. + // + // "hash": Select an origin by computing a hash over the CF-Connecting-IP address. + // + // "least_outstanding_requests": Select an origin by taking into consideration origin weights, + // as well as each origin's number of outstanding requests. Origins with more pending requests + // are weighted proportionately less relative to others. + // + // "least_connections": Select an origin by taking into consideration origin weights, + // as well as each origin's number of open connections. Origins with more open connections + // are weighted proportionately less relative to others. Supported for HTTP/1 and HTTP/2 connections. + Policy string `json:"policy,omitempty"` +} + +// LoadBalancerMonitor represents a load balancer monitor's properties. +type LoadBalancerMonitor struct { + ID string `json:"id,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Type string `json:"type"` + Description string `json:"description"` + Method string `json:"method"` + Path string `json:"path"` + Header map[string][]string `json:"header"` + Timeout int `json:"timeout"` + Retries int `json:"retries"` + Interval int `json:"interval"` + ConsecutiveUp int `json:"consecutive_up"` + ConsecutiveDown int `json:"consecutive_down"` + Port uint16 `json:"port,omitempty"` + ExpectedBody string `json:"expected_body"` + ExpectedCodes string `json:"expected_codes"` + FollowRedirects bool `json:"follow_redirects"` + AllowInsecure bool `json:"allow_insecure"` + ProbeZone string `json:"probe_zone"` +} + +// LoadBalancer represents a load balancer's properties. +type LoadBalancer struct { + ID string `json:"id,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Description string `json:"description"` + Name string `json:"name"` + TTL int `json:"ttl,omitempty"` + FallbackPool string `json:"fallback_pool"` + DefaultPools []string `json:"default_pools"` + RegionPools map[string][]string `json:"region_pools"` + PopPools map[string][]string `json:"pop_pools"` + CountryPools map[string][]string `json:"country_pools"` + Proxied bool `json:"proxied"` + Enabled *bool `json:"enabled,omitempty"` + Persistence string `json:"session_affinity,omitempty"` + PersistenceTTL int `json:"session_affinity_ttl,omitempty"` + SessionAffinityAttributes *SessionAffinityAttributes `json:"session_affinity_attributes,omitempty"` + Rules []*LoadBalancerRule `json:"rules,omitempty"` + RandomSteering *RandomSteering `json:"random_steering,omitempty"` + AdaptiveRouting *AdaptiveRouting `json:"adaptive_routing,omitempty"` + LocationStrategy *LocationStrategy `json:"location_strategy,omitempty"` + + // SteeringPolicy controls pool selection logic. + // + // "off": Select pools in DefaultPools order. + // + // "geo": Select pools based on RegionPools/PopPools/CountryPools. + // For non-proxied requests, the country for CountryPools is determined by LocationStrategy. + // + // "dynamic_latency": Select pools based on RTT (requires health checks). + // + // "random": Selects pools in a random order. + // + // "proximity": Use the pools' latitude and longitude to select the closest pool using + // the Cloudflare PoP location for proxied requests or the location determined by + // LocationStrategy for non-proxied requests. + // + // "least_outstanding_requests": Select a pool by taking into consideration + // RandomSteering weights, as well as each pool's number of outstanding requests. + // Pools with more pending requests are weighted proportionately less relative to others. + // + // "least_connections": Select a pool by taking into consideration + // RandomSteering weights, as well as each pool's number of open connections. + // Pools with more open connections are weighted proportionately less relative to others. + // Supported for HTTP/1 and HTTP/2 connections. + // + // "": Maps to "geo" if RegionPools or PopPools or CountryPools have entries otherwise "off". + SteeringPolicy string `json:"steering_policy,omitempty"` +} + +// LoadBalancerLoadShedding contains the settings for controlling load shedding. +type LoadBalancerLoadShedding struct { + DefaultPercent float32 `json:"default_percent,omitempty"` + DefaultPolicy string `json:"default_policy,omitempty"` + SessionPercent float32 `json:"session_percent,omitempty"` + SessionPolicy string `json:"session_policy,omitempty"` +} + +// LoadBalancerRule represents a single rule entry for a Load Balancer. Each rules +// is run one after the other in priority order. Disabled rules are skipped. +type LoadBalancerRule struct { + Overrides LoadBalancerRuleOverrides `json:"overrides"` + + // Name is required but is only used for human readability + Name string `json:"name"` + + Condition string `json:"condition"` + + // Priority controls the order of rule execution the lowest value will be invoked first + Priority int `json:"priority"` + + // FixedResponse if set and the condition is true we will not run + // routing logic but rather directly respond with the provided fields. + // FixedResponse implies terminates. + FixedResponse *LoadBalancerFixedResponseData `json:"fixed_response,omitempty"` + + Disabled bool `json:"disabled"` + + // Terminates flag this rule as 'terminating'. No further rules will + // be executed after this one. + Terminates bool `json:"terminates,omitempty"` +} + +// LoadBalancerFixedResponseData contains all the data needed to generate +// a fixed response from a Load Balancer. This behavior can be enabled via Rules. +type LoadBalancerFixedResponseData struct { + // MessageBody data to write into the http body + MessageBody string `json:"message_body,omitempty"` + // StatusCode the http status code to response with + StatusCode int `json:"status_code,omitempty"` + // ContentType value of the http 'content-type' header + ContentType string `json:"content_type,omitempty"` + // Location value of the http 'location' header + Location string `json:"location,omitempty"` +} + +// LoadBalancerRuleOverrides are the set of field overridable by the rules system. +type LoadBalancerRuleOverrides struct { + // session affinity + Persistence string `json:"session_affinity,omitempty"` + PersistenceTTL *uint `json:"session_affinity_ttl,omitempty"` + + SessionAffinityAttrs *LoadBalancerRuleOverridesSessionAffinityAttrs `json:"session_affinity_attributes,omitempty"` + + TTL uint `json:"ttl,omitempty"` + + SteeringPolicy string `json:"steering_policy,omitempty"` + FallbackPool string `json:"fallback_pool,omitempty"` + + DefaultPools []string `json:"default_pools,omitempty"` + PoPPools map[string][]string `json:"pop_pools,omitempty"` + RegionPools map[string][]string `json:"region_pools,omitempty"` + CountryPools map[string][]string `json:"country_pools,omitempty"` + + RandomSteering *RandomSteering `json:"random_steering,omitempty"` + AdaptiveRouting *AdaptiveRouting `json:"adaptive_routing,omitempty"` + LocationStrategy *LocationStrategy `json:"location_strategy,omitempty"` +} + +// RandomSteering configures pool weights. +// +// SteeringPolicy="random": A random pool is selected with probability +// proportional to pool weights. +// +// SteeringPolicy="least_outstanding_requests": Use pool weights to +// scale each pool's outstanding requests. +// +// SteeringPolicy="least_connections": Use pool weights to +// scale each pool's open connections. +type RandomSteering struct { + DefaultWeight float64 `json:"default_weight,omitempty"` + PoolWeights map[string]float64 `json:"pool_weights,omitempty"` +} + +// AdaptiveRouting controls features that modify the routing of requests +// to pools and origins in response to dynamic conditions, such as during +// the interval between active health monitoring requests. +// For example, zero-downtime failover occurs immediately when an origin +// becomes unavailable due to HTTP 521, 522, or 523 response codes. +// If there is another healthy origin in the same pool, the request is +// retried once against this alternate origin. +type AdaptiveRouting struct { + // FailoverAcrossPools extends zero-downtime failover of requests to healthy origins + // from alternate pools, when no healthy alternate exists in the same pool, according + // to the failover order defined by traffic and origin steering. + // When set false (the default) zero-downtime failover will only occur between origins + // within the same pool. See SessionAffinityAttributes for control over when sessions + // are broken or reassigned. + FailoverAcrossPools *bool `json:"failover_across_pools,omitempty"` +} + +// LocationStrategy controls location-based steering for non-proxied requests. +// See SteeringPolicy to learn how steering is affected. +type LocationStrategy struct { + // PreferECS determines whether the EDNS Client Subnet (ECS) GeoIP should + // be preferred as the authoritative location. + // + // "always": Always prefer ECS. + // + // "never": Never prefer ECS. + // + // "proximity": (default) Prefer ECS only when SteeringPolicy="proximity". + // + // "geo": Prefer ECS only when SteeringPolicy="geo". + PreferECS string `json:"prefer_ecs,omitempty"` + // Mode determines the authoritative location when ECS is not preferred, + // does not exist in the request, or its GeoIP lookup is unsuccessful. + // + // "pop": (default) Use the Cloudflare PoP location. + // + // "resolver_ip": Use the DNS resolver GeoIP location. + // If the GeoIP lookup is unsuccessful, use the Cloudflare PoP location. + Mode string `json:"mode,omitempty"` +} + +// LoadBalancerRuleOverridesSessionAffinityAttrs mimics SessionAffinityAttributes without the +// DrainDuration field as that field can not be overwritten via rules. +type LoadBalancerRuleOverridesSessionAffinityAttrs struct { + SameSite string `json:"samesite,omitempty"` + Secure string `json:"secure,omitempty"` + ZeroDowntimeFailover string `json:"zero_downtime_failover,omitempty"` + Headers []string `json:"headers,omitempty"` + RequireAllHeaders *bool `json:"require_all_headers,omitempty"` +} + +// SessionAffinityAttributes represents additional configuration options for session affinity. +type SessionAffinityAttributes struct { + SameSite string `json:"samesite,omitempty"` + Secure string `json:"secure,omitempty"` + DrainDuration int `json:"drain_duration,omitempty"` + ZeroDowntimeFailover string `json:"zero_downtime_failover,omitempty"` + Headers []string `json:"headers,omitempty"` + RequireAllHeaders bool `json:"require_all_headers,omitempty"` +} + +// LoadBalancerOriginHealth represents the health of the origin. +type LoadBalancerOriginHealth struct { + Healthy bool `json:"healthy,omitempty"` + RTT Duration `json:"rtt,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` + ResponseCode int `json:"response_code,omitempty"` +} + +// LoadBalancerPoolPopHealth represents the health of the pool for given PoP. +type LoadBalancerPoolPopHealth struct { + Healthy bool `json:"healthy,omitempty"` + Origins []map[string]LoadBalancerOriginHealth `json:"origins,omitempty"` +} + +// LoadBalancerPoolHealth represents the healthchecks from different PoPs for a pool. +type LoadBalancerPoolHealth struct { + ID string `json:"pool_id,omitempty"` + PopHealth map[string]LoadBalancerPoolPopHealth `json:"pop_health,omitempty"` +} + +// loadBalancerPoolResponse represents the response from the load balancer pool endpoints. +type loadBalancerPoolResponse struct { + Response + Result LoadBalancerPool `json:"result"` +} + +// loadBalancerPoolListResponse represents the response from the List Pools endpoint. +type loadBalancerPoolListResponse struct { + Response + Result []LoadBalancerPool `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// loadBalancerMonitorResponse represents the response from the load balancer monitor endpoints. +type loadBalancerMonitorResponse struct { + Response + Result LoadBalancerMonitor `json:"result"` +} + +// loadBalancerMonitorListResponse represents the response from the List Monitors endpoint. +type loadBalancerMonitorListResponse struct { + Response + Result []LoadBalancerMonitor `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// loadBalancerResponse represents the response from the load balancer endpoints. +type loadBalancerResponse struct { + Response + Result LoadBalancer `json:"result"` +} + +// loadBalancerListResponse represents the response from the List Load Balancers endpoint. +type loadBalancerListResponse struct { + Response + Result []LoadBalancer `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// loadBalancerPoolHealthResponse represents the response from the Pool Health Details endpoint. +type loadBalancerPoolHealthResponse struct { + Response + Result LoadBalancerPoolHealth `json:"result"` +} + +type CreateLoadBalancerPoolParams struct { + LoadBalancerPool LoadBalancerPool +} + +type ListLoadBalancerPoolParams struct { + PaginationOptions +} + +type UpdateLoadBalancerPoolParams struct { + LoadBalancer LoadBalancerPool +} + +type CreateLoadBalancerMonitorParams struct { + LoadBalancerMonitor LoadBalancerMonitor +} + +type ListLoadBalancerMonitorParams struct { + PaginationOptions +} + +type UpdateLoadBalancerMonitorParams struct { + LoadBalancerMonitor LoadBalancerMonitor +} + +type CreateLoadBalancerParams struct { + LoadBalancer LoadBalancer +} + +type ListLoadBalancerParams struct { + PaginationOptions +} + +type UpdateLoadBalancerParams struct { + LoadBalancer LoadBalancer +} + +var ( + ErrMissingPoolID = errors.New("missing required pool ID") + ErrMissingMonitorID = errors.New("missing required monitor ID") + ErrMissingLoadBalancerID = errors.New("missing required load balancer ID") +) + +// CreateLoadBalancerPool creates a new load balancer pool. +// +// API reference: https://api.cloudflare.com/#load-balancer-pools-create-pool +func (api *API) CreateLoadBalancerPool(ctx context.Context, rc *ResourceContainer, params CreateLoadBalancerPoolParams) (LoadBalancerPool, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerPool{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + var uri string + if rc.Level == UserRouteLevel { + uri = "/user/load_balancers/pools" + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/pools", rc.Identifier) + } + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.LoadBalancerPool) + if err != nil { + return LoadBalancerPool{}, err + } + var r loadBalancerPoolResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerPool{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListLoadBalancerPools lists load balancer pools connected to an account. +// +// API reference: https://api.cloudflare.com/#load-balancer-pools-list-pools +func (api *API) ListLoadBalancerPools(ctx context.Context, rc *ResourceContainer, params ListLoadBalancerPoolParams) ([]LoadBalancerPool, error) { + if rc.Level == ZoneRouteLevel { + return []LoadBalancerPool{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + var uri string + if rc.Level == UserRouteLevel { + uri = "/user/load_balancers/pools" + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/pools", rc.Identifier) + } + + uri = buildURI(uri, params.PaginationOptions) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r loadBalancerPoolListResponse + if err := json.Unmarshal(res, &r); err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetLoadBalancerPool returns the details for a load balancer pool. +// +// API reference: https://api.cloudflare.com/#load-balancer-pools-pool-details +func (api *API) GetLoadBalancerPool(ctx context.Context, rc *ResourceContainer, poolID string) (LoadBalancerPool, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerPool{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if poolID == "" { + return LoadBalancerPool{}, ErrMissingPoolID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/pools/%s", poolID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/pools/%s", rc.Identifier, poolID) + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return LoadBalancerPool{}, err + } + var r loadBalancerPoolResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerPool{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteLoadBalancerPool disables and deletes a load balancer pool. +// +// API reference: https://api.cloudflare.com/#load-balancer-pools-delete-pool +func (api *API) DeleteLoadBalancerPool(ctx context.Context, rc *ResourceContainer, poolID string) error { + if rc.Level == ZoneRouteLevel { + return fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if poolID == "" { + return ErrMissingPoolID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/pools/%s", poolID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/pools/%s", rc.Identifier, poolID) + } + + if _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil); err != nil { + return err + } + + return nil +} + +// UpdateLoadBalancerPool modifies a configured load balancer pool. +// +// API reference: https://api.cloudflare.com/#load-balancer-pools-update-pool +func (api *API) UpdateLoadBalancerPool(ctx context.Context, rc *ResourceContainer, params UpdateLoadBalancerPoolParams) (LoadBalancerPool, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerPool{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if params.LoadBalancer.ID == "" { + return LoadBalancerPool{}, ErrMissingPoolID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/pools/%s", params.LoadBalancer.ID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/pools/%s", rc.Identifier, params.LoadBalancer.ID) + } + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.LoadBalancer) + if err != nil { + return LoadBalancerPool{}, err + } + var r loadBalancerPoolResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerPool{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateLoadBalancerMonitor creates a new load balancer monitor. +// +// API reference: https://api.cloudflare.com/#load-balancer-monitors-create-monitor +func (api *API) CreateLoadBalancerMonitor(ctx context.Context, rc *ResourceContainer, params CreateLoadBalancerMonitorParams) (LoadBalancerMonitor, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerMonitor{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + var uri string + if rc.Level == UserRouteLevel { + uri = "/user/load_balancers/monitors" + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/monitors", rc.Identifier) + } + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.LoadBalancerMonitor) + if err != nil { + return LoadBalancerMonitor{}, err + } + var r loadBalancerMonitorResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerMonitor{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListLoadBalancerMonitors lists load balancer monitors connected to an account. +// +// API reference: https://api.cloudflare.com/#load-balancer-monitors-list-monitors +func (api *API) ListLoadBalancerMonitors(ctx context.Context, rc *ResourceContainer, params ListLoadBalancerMonitorParams) ([]LoadBalancerMonitor, error) { + if rc.Level == ZoneRouteLevel { + return []LoadBalancerMonitor{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + var uri string + if rc.Level == UserRouteLevel { + uri = "/user/load_balancers/monitors" + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/monitors", rc.Identifier) + } + + uri = buildURI(uri, params.PaginationOptions) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r loadBalancerMonitorListResponse + if err := json.Unmarshal(res, &r); err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetLoadBalancerMonitor returns the details for a load balancer monitor. +// +// API reference: https://api.cloudflare.com/#load-balancer-monitors-monitor-details +func (api *API) GetLoadBalancerMonitor(ctx context.Context, rc *ResourceContainer, monitorID string) (LoadBalancerMonitor, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerMonitor{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if monitorID == "" { + return LoadBalancerMonitor{}, ErrMissingMonitorID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/monitors/%s", monitorID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/monitors/%s", rc.Identifier, monitorID) + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return LoadBalancerMonitor{}, err + } + var r loadBalancerMonitorResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerMonitor{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteLoadBalancerMonitor disables and deletes a load balancer monitor. +// +// API reference: https://api.cloudflare.com/#load-balancer-monitors-delete-monitor +func (api *API) DeleteLoadBalancerMonitor(ctx context.Context, rc *ResourceContainer, monitorID string) error { + if rc.Level == ZoneRouteLevel { + return fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if monitorID == "" { + return ErrMissingMonitorID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/monitors/%s", monitorID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/monitors/%s", rc.Identifier, monitorID) + } + + if _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil); err != nil { + return err + } + return nil +} + +// UpdateLoadBalancerMonitor modifies a configured load balancer monitor. +// +// API reference: https://api.cloudflare.com/#load-balancer-monitors-update-monitor +func (api *API) UpdateLoadBalancerMonitor(ctx context.Context, rc *ResourceContainer, params UpdateLoadBalancerMonitorParams) (LoadBalancerMonitor, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerMonitor{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if params.LoadBalancerMonitor.ID == "" { + return LoadBalancerMonitor{}, ErrMissingMonitorID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/monitors/%s", params.LoadBalancerMonitor.ID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/monitors/%s", rc.Identifier, params.LoadBalancerMonitor.ID) + } + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.LoadBalancerMonitor) + if err != nil { + return LoadBalancerMonitor{}, err + } + var r loadBalancerMonitorResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerMonitor{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateLoadBalancer creates a new load balancer. +// +// API reference: https://api.cloudflare.com/#load-balancers-create-load-balancer +func (api *API) CreateLoadBalancer(ctx context.Context, rc *ResourceContainer, params CreateLoadBalancerParams) (LoadBalancer, error) { + if rc.Level != ZoneRouteLevel { + return LoadBalancer{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/zones/%s/load_balancers", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.LoadBalancer) + if err != nil { + return LoadBalancer{}, err + } + var r loadBalancerResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancer{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListLoadBalancers lists load balancers configured on a zone. +// +// API reference: https://api.cloudflare.com/#load-balancers-list-load-balancers +func (api *API) ListLoadBalancers(ctx context.Context, rc *ResourceContainer, params ListLoadBalancerParams) ([]LoadBalancer, error) { + if rc.Level != ZoneRouteLevel { + return []LoadBalancer{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := buildURI(fmt.Sprintf("/zones/%s/load_balancers", rc.Identifier), params.PaginationOptions) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r loadBalancerListResponse + if err := json.Unmarshal(res, &r); err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetLoadBalancer returns the details for a load balancer. +// +// API reference: https://api.cloudflare.com/#load-balancers-load-balancer-details +func (api *API) GetLoadBalancer(ctx context.Context, rc *ResourceContainer, loadbalancerID string) (LoadBalancer, error) { + if rc.Level != ZoneRouteLevel { + return LoadBalancer{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if loadbalancerID == "" { + return LoadBalancer{}, ErrMissingLoadBalancerID + } + + uri := fmt.Sprintf("/zones/%s/load_balancers/%s", rc.Identifier, loadbalancerID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return LoadBalancer{}, err + } + var r loadBalancerResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancer{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteLoadBalancer disables and deletes a load balancer. +// +// API reference: https://api.cloudflare.com/#load-balancers-delete-load-balancer +func (api *API) DeleteLoadBalancer(ctx context.Context, rc *ResourceContainer, loadbalancerID string) error { + if rc.Level != ZoneRouteLevel { + return fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if loadbalancerID == "" { + return ErrMissingLoadBalancerID + } + + uri := fmt.Sprintf("/zones/%s/load_balancers/%s", rc.Identifier, loadbalancerID) + + if _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil); err != nil { + return err + } + return nil +} + +// UpdateLoadBalancer modifies a configured load balancer. +// +// API reference: https://api.cloudflare.com/#load-balancers-update-load-balancer +func (api *API) UpdateLoadBalancer(ctx context.Context, rc *ResourceContainer, params UpdateLoadBalancerParams) (LoadBalancer, error) { + if rc.Level != ZoneRouteLevel { + return LoadBalancer{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if params.LoadBalancer.ID == "" { + return LoadBalancer{}, ErrMissingLoadBalancerID + } + + uri := fmt.Sprintf("/zones/%s/load_balancers/%s", rc.Identifier, params.LoadBalancer.ID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.LoadBalancer) + if err != nil { + return LoadBalancer{}, err + } + var r loadBalancerResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancer{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetLoadBalancerPoolHealth fetches the latest healtcheck details for a single +// pool. +// +// API reference: https://api.cloudflare.com/#load-balancer-pools-pool-health-details +func (api *API) GetLoadBalancerPoolHealth(ctx context.Context, rc *ResourceContainer, poolID string) (LoadBalancerPoolHealth, error) { + if rc.Level == ZoneRouteLevel { + return LoadBalancerPoolHealth{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if poolID == "" { + return LoadBalancerPoolHealth{}, ErrMissingPoolID + } + + var uri string + if rc.Level == UserRouteLevel { + uri = fmt.Sprintf("/user/load_balancers/pools/%s/health", poolID) + } else { + uri = fmt.Sprintf("/accounts/%s/load_balancers/pools/%s/health", rc.Identifier, poolID) + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return LoadBalancerPoolHealth{}, err + } + var r loadBalancerPoolHealthResponse + if err := json.Unmarshal(res, &r); err != nil { + return LoadBalancerPoolHealth{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/load_balancing_example_test.go b/pkg/cloudflare-go/load_balancing_example_test.go new file mode 100644 index 000000000..2aaa8956a --- /dev/null +++ b/pkg/cloudflare-go/load_balancing_example_test.go @@ -0,0 +1,42 @@ +package cloudflare_test + +import ( + context "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ListLoadBalancers() { + // Construct a new API object. + api, err := cloudflare.New("deadbeef", "test@example.com") + if err != nil { + log.Fatal(err) + } + + // List LBs configured in zone. + lbList, err := api.ListLoadBalancers(context.Background(), cloudflare.ZoneIdentifier("d56084adb405e0b7e32c52321bf07be6"), cloudflare.ListLoadBalancerParams{}) + if err != nil { + log.Fatal(err) + } + + for _, lb := range lbList { + fmt.Println(lb) + } +} + +func ExampleAPI_GetLoadBalancerPoolHealth() { + // Construct a new API object. + api, err := cloudflare.New("deadbeef", "test@example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch pool health details. + healthInfo, err := api.GetLoadBalancerPoolHealth(context.Background(), cloudflare.AccountIdentifier("01a7362d577a6c3019a474fd6f485823"), "example-pool-id") + if err != nil { + log.Fatal(err) + } + fmt.Println(healthInfo) +} diff --git a/pkg/cloudflare-go/load_balancing_test.go b/pkg/cloudflare-go/load_balancing_test.go new file mode 100644 index 000000000..3a9ab5afb --- /dev/null +++ b/pkg/cloudflare-go/load_balancing_test.go @@ -0,0 +1,2147 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCreateLoadBalancerPool(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "description": "Primary data center - Provider XYZ", + "name": "primary-dc-1", + "enabled": true, + "monitor": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "latitude": 55, + "longitude": -12.5, + "load_shedding": { + "default_percent": 50, + "default_policy": "random", + "session_percent": 10, + "session_policy": "hash" + }, + "origin_steering": { + "policy": "random" + }, + "origins": [ + { + "name": "app-server-1", + "address": "198.51.100.1", + "enabled": true, + "weight": 1, + "header": { + "Host": [ + "example.com" + ] + }, + "virtual_network_id":"a5624d4e-044a-4ff0-b3e1-e2465353d4b4" + } + ], + "notification_email": "someone@example.com", + "check_regions": [ + "WEU" + ] + }`, string(b)) + } + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "17b5962d775c646f3f9725cbc7a53df4", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Primary data center - Provider XYZ", + "name": "primary-dc-1", + "enabled": true, + "minimum_origins": 1, + "monitor": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "latitude": 55, + "longitude": -12.5, + "load_shedding": { + "default_percent": 50, + "default_policy": "random", + "session_percent": 10, + "session_policy": "hash" + }, + "origin_steering": { + "policy": "random" + }, + "origins": [ + { + "name": "app-server-1", + "address": "198.51.100.1", + "enabled": true, + "weight": 1, + "header": { + "Host": [ + "example.com" + ] + }, + "virtual_network_id":"a5624d4e-044a-4ff0-b3e1-e2465353d4b4" + } + ], + "notification_email": "someone@example.com", + "check_regions": [ + "WEU" + ], + "healthy": true + } + }`) + } + + fptr := func(f float32) *float32 { + return &f + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancerPool{ + ID: "17b5962d775c646f3f9725cbc7a53df4", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Primary data center - Provider XYZ", + Name: "primary-dc-1", + Enabled: true, + MinimumOrigins: IntPtr(1), + Monitor: "f1aba936b94213e5b8dca0c0dbf1f9cc", + Latitude: fptr(55), + Longitude: fptr(-12.5), + LoadShedding: &LoadBalancerLoadShedding{ + DefaultPercent: 50, + DefaultPolicy: "random", + SessionPercent: 10, + SessionPolicy: "hash", + }, + OriginSteering: &LoadBalancerOriginSteering{ + Policy: "random", + }, + Origins: []LoadBalancerOrigin{ + { + Name: "app-server-1", + Address: "198.51.100.1", + Enabled: true, + Weight: 1, + Header: map[string][]string{ + "Host": {"example.com"}, + }, + VirtualNetworkID: "a5624d4e-044a-4ff0-b3e1-e2465353d4b4", + }, + }, + NotificationEmail: "someone@example.com", + CheckRegions: []string{ + "WEU", + }, + Healthy: BoolPtr(true), + } + request := LoadBalancerPool{ + Description: "Primary data center - Provider XYZ", + Name: "primary-dc-1", + Enabled: true, + Monitor: "f1aba936b94213e5b8dca0c0dbf1f9cc", + Latitude: fptr(55), + Longitude: fptr(-12.5), + LoadShedding: &LoadBalancerLoadShedding{ + DefaultPercent: 50, + DefaultPolicy: "random", + SessionPercent: 10, + SessionPolicy: "hash", + }, + OriginSteering: &LoadBalancerOriginSteering{ + Policy: "random", + }, + Origins: []LoadBalancerOrigin{ + { + Name: "app-server-1", + Address: "198.51.100.1", + Enabled: true, + Weight: 1, + Header: map[string][]string{ + "Host": {"example.com"}, + }, + VirtualNetworkID: "a5624d4e-044a-4ff0-b3e1-e2465353d4b4", + }, + }, + NotificationEmail: "someone@example.com", + CheckRegions: []string{ + "WEU", + }, + } + + actual, err := client.CreateLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), CreateLoadBalancerPoolParams{LoadBalancerPool: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateLoadBalancerPool_MinimumOriginsZero(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "description": "Primary data center - Provider XYZ", + "name": "primary-dc-2", + "minimum_origins": 0, + "enabled": true, + "check_regions": null, + "origins": null + }`, string(b)) + } + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "description": "Primary data center - Provider XYZ", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "id": "f6fea70e5154b4c563bd549ef405b7d7", + "enabled": true, + "minimum_origins": 0, + "name": "primary-dc-2", + "notification_email": "", + "check_regions": null, + "origins": [] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancerPool{ + ID: "f6fea70e5154b4c563bd549ef405b7d7", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Primary data center - Provider XYZ", + Name: "primary-dc-2", + Enabled: true, + MinimumOrigins: IntPtr(0), + Origins: []LoadBalancerOrigin{}, + NotificationEmail: "", + } + request := LoadBalancerPool{ + Description: "Primary data center - Provider XYZ", + Name: "primary-dc-2", + Enabled: true, + MinimumOrigins: IntPtr(0), + } + + actual, err := client.CreateLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), CreateLoadBalancerPoolParams{LoadBalancerPool: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateLoadBalancerPool_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.CreateLoadBalancerPool(context.Background(), ZoneIdentifier(testZoneID), CreateLoadBalancerPoolParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestListLoadBalancerPools(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "17b5962d775c646f3f9725cbc7a53df4", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Primary data center - Provider XYZ", + "name": "primary-dc-1", + "enabled": true, + "monitor": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "origin_steering": { + "policy": "random" + }, + "origins": [ + { + "name": "app-server-1", + "address": "198.51.100.1", + "enabled": true, + "weight": 1, + "virtual_network_id":"a5624d4e-044a-4ff0-b3e1-e2465353d4b4" + } + ], + "notification_email": "someone@example.com" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := []LoadBalancerPool{ + { + ID: "17b5962d775c646f3f9725cbc7a53df4", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Primary data center - Provider XYZ", + Name: "primary-dc-1", + Enabled: true, + Monitor: "f1aba936b94213e5b8dca0c0dbf1f9cc", + OriginSteering: &LoadBalancerOriginSteering{ + Policy: "random", + }, + Origins: []LoadBalancerOrigin{ + { + Name: "app-server-1", + Address: "198.51.100.1", + Enabled: true, + Weight: 1, + VirtualNetworkID: "a5624d4e-044a-4ff0-b3e1-e2465353d4b4", + }, + }, + NotificationEmail: "someone@example.com", + }, + } + + actual, err := client.ListLoadBalancerPools(context.Background(), AccountIdentifier(testAccountID), ListLoadBalancerPoolParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListLoadBalancerPool_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.ListLoadBalancerPools(context.Background(), ZoneIdentifier(testZoneID), ListLoadBalancerPoolParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestGetLoadBalancerPool(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "17b5962d775c646f3f9725cbc7a53df4", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Primary data center - Provider XYZ", + "name": "primary-dc-1", + "enabled": true, + "monitor": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "origin_steering": { + "policy": "random" + }, + "origins": [ + { + "name": "app-server-1", + "address": "198.51.100.1", + "enabled": true, + "weight": 1, + "virtual_network_id":"a5624d4e-044a-4ff0-b3e1-e2465353d4b4" + } + ], + "notification_email": "someone@example.com" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools/17b5962d775c646f3f9725cbc7a53df4", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancerPool{ + ID: "17b5962d775c646f3f9725cbc7a53df4", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Primary data center - Provider XYZ", + Name: "primary-dc-1", + Enabled: true, + Monitor: "f1aba936b94213e5b8dca0c0dbf1f9cc", + OriginSteering: &LoadBalancerOriginSteering{ + Policy: "random", + }, + Origins: []LoadBalancerOrigin{ + { + Name: "app-server-1", + Address: "198.51.100.1", + Enabled: true, + Weight: 1, + VirtualNetworkID: "a5624d4e-044a-4ff0-b3e1-e2465353d4b4", + }, + }, + NotificationEmail: "someone@example.com", + } + + actual, err := client.GetLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), "17b5962d775c646f3f9725cbc7a53df4") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.GetLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), "bar") + assert.Error(t, err) +} + +func TestGetLoadBalancerPool_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetLoadBalancerPool(context.Background(), ZoneIdentifier(testZoneID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestDeleteLoadBalancerPool(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "17b5962d775c646f3f9725cbc7a53df4" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools/17b5962d775c646f3f9725cbc7a53df4", handler) + assert.NoError(t, client.DeleteLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), "17b5962d775c646f3f9725cbc7a53df4")) + assert.Error(t, client.DeleteLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), "bar")) +} + +func TestDeleteLoadBalancerPool_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + err := client.DeleteLoadBalancerPool(context.Background(), ZoneIdentifier(testZoneID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestUpdateLoadBalancerPool(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "id": "17b5962d775c646f3f9725cbc7a53df4", + "description": "Primary data center - Provider XYZZY", + "name": "primary-dc-2", + "enabled": false, + "origin_steering": { + "policy": "random" + }, + "origins": [ + { + "name": "app-server-2", + "address": "198.51.100.2", + "enabled": false, + "weight": 1, + "header": { + "Host": [ + "example.com" + ] + }, + "virtual_network_id":"a5624d4e-044a-4ff0-b3e1-e2465353d4b4" + } + ], + "notification_email": "nobody@example.com", + "check_regions": [ + "WEU" + ] + }`, string(b)) + } + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "17b5962d775c646f3f9725cbc7a53df4", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2017-02-01T05:20:00.12345Z", + "description": "Primary data center - Provider XYZZY", + "name": "primary-dc-2", + "enabled": false, + "origin_steering": { + "policy": "random" + }, + "origins": [ + { + "name": "app-server-2", + "address": "198.51.100.2", + "enabled": false, + "weight": 1, + "header": { + "Host": [ + "example.com" + ] + }, + "virtual_network_id":"a5624d4e-044a-4ff0-b3e1-e2465353d4b4" + } + ], + "notification_email": "nobody@example.com", + "check_regions": [ + "WEU" + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools/17b5962d775c646f3f9725cbc7a53df4", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-02-01T05:20:00.12345Z") + want := LoadBalancerPool{ + ID: "17b5962d775c646f3f9725cbc7a53df4", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Primary data center - Provider XYZZY", + Name: "primary-dc-2", + Enabled: false, + OriginSteering: &LoadBalancerOriginSteering{ + Policy: "random", + }, + Origins: []LoadBalancerOrigin{ + { + Name: "app-server-2", + Address: "198.51.100.2", + Enabled: false, + Weight: 1, + Header: map[string][]string{ + "Host": {"example.com"}, + }, + VirtualNetworkID: "a5624d4e-044a-4ff0-b3e1-e2465353d4b4", + }, + }, + NotificationEmail: "nobody@example.com", + CheckRegions: []string{ + "WEU", + }, + } + request := LoadBalancerPool{ + ID: "17b5962d775c646f3f9725cbc7a53df4", + Description: "Primary data center - Provider XYZZY", + Name: "primary-dc-2", + Enabled: false, + OriginSteering: &LoadBalancerOriginSteering{ + Policy: "random", + }, + Origins: []LoadBalancerOrigin{ + { + Name: "app-server-2", + Address: "198.51.100.2", + Enabled: false, + Weight: 1, + Header: map[string][]string{ + "Host": {"example.com"}, + }, + VirtualNetworkID: "a5624d4e-044a-4ff0-b3e1-e2465353d4b4", + }, + }, + NotificationEmail: "nobody@example.com", + CheckRegions: []string{ + "WEU", + }, + } + + actual, err := client.UpdateLoadBalancerPool(context.Background(), AccountIdentifier(testAccountID), UpdateLoadBalancerPoolParams{LoadBalancer: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateLoadBalancerPool_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateLoadBalancerPool(context.Background(), ZoneIdentifier(testZoneID), UpdateLoadBalancerPoolParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestCreateLoadBalancerMonitor(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "type": "https", + "description": "Login page monitor", + "method": "GET", + "path": "/health", + "header": { + "Host": [ + "example.com" + ], + "X-App-ID": [ + "abc123" + ] + }, + "timeout": 3, + "retries": 0, + "interval": 90, + "consecutive_up": 2, + "consecutive_down": 2, + "expected_body": "alive", + "expected_codes": "2xx", + "follow_redirects": true, + "allow_insecure": true, + "probe_zone": "" + }`, string(b)) + } + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "type": "https", + "description": "Login page monitor", + "method": "GET", + "path": "/health", + "header": { + "Host": [ + "example.com" + ], + "X-App-ID": [ + "abc123" + ] + }, + "timeout": 3, + "retries": 0, + "interval": 90, + "consecutive_up": 2, + "consecutive_down": 2, + "expected_body": "alive", + "expected_codes": "2xx", + "follow_redirects": true, + "allow_insecure": true, + "probe_zone": "" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/monitors", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancerMonitor{ + ID: "f1aba936b94213e5b8dca0c0dbf1f9cc", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Type: "https", + Description: "Login page monitor", + Method: http.MethodGet, + Path: "/health", + Header: map[string][]string{ + "Host": {"example.com"}, + "X-App-ID": {"abc123"}, + }, + Timeout: 3, + Retries: 0, + Interval: 90, + ConsecutiveUp: 2, + ConsecutiveDown: 2, + ExpectedBody: "alive", + ExpectedCodes: "2xx", + + FollowRedirects: true, + AllowInsecure: true, + } + request := LoadBalancerMonitor{ + Type: "https", + Description: "Login page monitor", + Method: http.MethodGet, + Path: "/health", + Header: map[string][]string{ + "Host": {"example.com"}, + "X-App-ID": {"abc123"}, + }, + Timeout: 3, + Retries: 0, + Interval: 90, + ConsecutiveUp: 2, + ConsecutiveDown: 2, + ExpectedBody: "alive", + ExpectedCodes: "2xx", + + FollowRedirects: true, + AllowInsecure: true, + } + + actual, err := client.CreateLoadBalancerMonitor(context.Background(), AccountIdentifier(testAccountID), CreateLoadBalancerMonitorParams{LoadBalancerMonitor: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateLoadBalancerMonitor_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.CreateLoadBalancerMonitor(context.Background(), ZoneIdentifier(testZoneID), CreateLoadBalancerMonitorParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestListLoadBalancerMonitors(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "type": "https", + "description": "Login page monitor", + "method": "GET", + "path": "/health", + "header": { + "Host": [ + "example.com" + ], + "X-App-ID": [ + "abc123" + ] + }, + "timeout": 3, + "retries": 0, + "interval": 90, + "consecutive_up": 2, + "consecutive_down": 2, + "expected_body": "alive", + "expected_codes": "2xx" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/monitors", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := []LoadBalancerMonitor{ + { + ID: "f1aba936b94213e5b8dca0c0dbf1f9cc", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Type: "https", + Description: "Login page monitor", + Method: http.MethodGet, + Path: "/health", + Header: map[string][]string{ + "Host": {"example.com"}, + "X-App-ID": {"abc123"}, + }, + Timeout: 3, + Retries: 0, + Interval: 90, + ConsecutiveUp: 2, + ConsecutiveDown: 2, + ExpectedBody: "alive", + ExpectedCodes: "2xx", + }, + } + + actual, err := client.ListLoadBalancerMonitors(context.Background(), AccountIdentifier(testAccountID), ListLoadBalancerMonitorParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListLoadBalancerMonitors_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.ListLoadBalancerMonitors(context.Background(), ZoneIdentifier(testZoneID), ListLoadBalancerMonitorParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestGetLoadBalancerMonitor(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "type": "https", + "description": "Login page monitor", + "method": "GET", + "path": "/health", + "header": { + "Host": [ + "example.com" + ], + "X-App-ID": [ + "abc123" + ] + }, + "timeout": 3, + "retries": 0, + "interval": 90, + "consecutive_up": 2, + "consecutive_down": 2, + "expected_body": "alive", + "expected_codes": "2xx", + "follow_redirects": true, + "allow_insecure": true, + "probe_zone": "" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/monitors/f1aba936b94213e5b8dca0c0dbf1f9cc", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancerMonitor{ + ID: "f1aba936b94213e5b8dca0c0dbf1f9cc", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Type: "https", + Description: "Login page monitor", + Method: http.MethodGet, + Path: "/health", + Header: map[string][]string{ + "Host": {"example.com"}, + "X-App-ID": {"abc123"}, + }, + Timeout: 3, + Retries: 0, + Interval: 90, + ConsecutiveUp: 2, + ConsecutiveDown: 2, + ExpectedBody: "alive", + ExpectedCodes: "2xx", + + FollowRedirects: true, + AllowInsecure: true, + } + + actual, err := client.GetLoadBalancerMonitor(context.Background(), AccountIdentifier(testAccountID), "f1aba936b94213e5b8dca0c0dbf1f9cc") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.GetLoadBalancerMonitor(context.Background(), AccountIdentifier(testAccountID), "bar") + assert.Error(t, err) +} + +func TestGetLoadBalancerMonitor_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetLoadBalancerMonitor(context.Background(), ZoneIdentifier(testZoneID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestDeleteLoadBalancerMonitor(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f1aba936b94213e5b8dca0c0dbf1f9cc" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/monitors/f1aba936b94213e5b8dca0c0dbf1f9cc", handler) + assert.NoError(t, client.DeleteLoadBalancerMonitor(context.Background(), AccountIdentifier(testAccountID), "f1aba936b94213e5b8dca0c0dbf1f9cc")) + assert.Error(t, client.DeleteLoadBalancerMonitor(context.Background(), AccountIdentifier(testAccountID), "bar")) +} + +func TestDeleteLoadBalancerMonitor_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + err := client.DeleteLoadBalancerMonitor(context.Background(), ZoneIdentifier(testZoneID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestUpdateLoadBalancerMonitor(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "id": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "type": "http", + "description": "Login page monitor", + "method": "GET", + "path": "/status", + "header": { + "Host": [ + "example.com" + ], + "X-App-ID": [ + "easy" + ] + }, + "timeout": 3, + "retries": 0, + "interval": 90, + "consecutive_up": 2, + "consecutive_down": 2, + "expected_body": "kicking", + "expected_codes": "200", + "follow_redirects": true, + "allow_insecure": true, + "probe_zone": "" + }`, string(b)) + } + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f1aba936b94213e5b8dca0c0dbf1f9cc", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2017-02-01T05:20:00.12345Z", + "type": "http", + "description": "Login page monitor", + "method": "GET", + "path": "/status", + "header": { + "Host": [ + "example.com" + ], + "X-App-ID": [ + "easy" + ] + }, + "timeout": 3, + "retries": 0, + "interval": 90, + "consecutive_up": 2, + "consecutive_down": 2, + "expected_body": "kicking", + "expected_codes": "200", + "follow_redirects": true, + "allow_insecure": true, + "probe_zone": "" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/monitors/f1aba936b94213e5b8dca0c0dbf1f9cc", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-02-01T05:20:00.12345Z") + want := LoadBalancerMonitor{ + ID: "f1aba936b94213e5b8dca0c0dbf1f9cc", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Type: "http", + Description: "Login page monitor", + Method: http.MethodGet, + Path: "/status", + Header: map[string][]string{ + "Host": {"example.com"}, + "X-App-ID": {"easy"}, + }, + Timeout: 3, + Retries: 0, + Interval: 90, + ConsecutiveUp: 2, + ConsecutiveDown: 2, + ExpectedBody: "kicking", + ExpectedCodes: "200", + + FollowRedirects: true, + AllowInsecure: true, + } + request := LoadBalancerMonitor{ + ID: "f1aba936b94213e5b8dca0c0dbf1f9cc", + Type: "http", + Description: "Login page monitor", + Method: http.MethodGet, + Path: "/status", + Header: map[string][]string{ + "Host": {"example.com"}, + "X-App-ID": {"easy"}, + }, + Timeout: 3, + Retries: 0, + Interval: 90, + ConsecutiveUp: 2, + ConsecutiveDown: 2, + ExpectedBody: "kicking", + ExpectedCodes: "200", + + FollowRedirects: true, + AllowInsecure: true, + } + + actual, err := client.UpdateLoadBalancerMonitor(context.Background(), AccountIdentifier(testAccountID), UpdateLoadBalancerMonitorParams{LoadBalancerMonitor: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateLoadBalancerMonitor_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateLoadBalancerMonitor(context.Background(), ZoneIdentifier(testZoneID), UpdateLoadBalancerMonitorParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} + +func TestCreateLoadBalancer(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "description": "Load Balancer for www.example.com", + "name": "www.example.com", + "ttl": 30, + "fallback_pool": "17b5962d775c646f3f9725cbc7a53df4", + "default_pools": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "region_pools": { + "WNAM": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "ENAM": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "country_pools": { + "US": [ + "de90f38ced07c2e2f4df50b1f61d4194" + ], + "GB": [ + "abd90f38ced07c2e2f4df50b1f61d4194" + ] + }, + "pop_pools": { + "LAX": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "LHR": [ + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196" + ], + "SJC": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "random_steering": { + "default_weight": 0.2, + "pool_weights": { + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4 + } + }, + "adaptive_routing": { + "failover_across_pools": true + }, + "location_strategy": { + "prefer_ecs": "always", + "mode": "resolver_ip" + }, + "rules": [ + { + "name": "example rule", + "condition": "cf.load_balancer.region == \"SAF\"", + "disabled": false, + "priority": 0, + "overrides": { + "region_pools": { + "SAF": ["de90f38ced07c2e2f4df50b1f61d4194"] + }, + "adaptive_routing": { + "failover_across_pools": false + }, + "location_strategy": { + "prefer_ecs": "never", + "mode": "pop" + } + } + } + ], + "proxied": true, + "session_affinity": "cookie", + "session_affinity_ttl": 5000, + "session_affinity_attributes": { + "samesite": "Strict", + "secure": "Always", + "drain_duration": 60, + "zero_downtime_failover": "sticky" + } + }`, string(b)) + } + + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Load Balancer for www.example.com", + "name": "www.example.com", + "ttl": 30, + "fallback_pool": "17b5962d775c646f3f9725cbc7a53df4", + "default_pools": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "region_pools": { + "WNAM": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "ENAM": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "country_pools": { + "US": [ + "de90f38ced07c2e2f4df50b1f61d4194" + ], + "GB": [ + "abd90f38ced07c2e2f4df50b1f61d4194" + ] + }, + "pop_pools": { + "LAX": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "LHR": [ + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196" + ], + "SJC": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "random_steering": { + "default_weight": 0.2, + "pool_weights": { + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4 + } + }, + "adaptive_routing": { + "failover_across_pools": true + }, + "location_strategy": { + "prefer_ecs": "always", + "mode": "resolver_ip" + }, + "rules": [ + { + "name": "example rule", + "condition": "cf.load_balancer.region == \"SAF\"", + "overrides": { + "region_pools": { + "SAF": ["de90f38ced07c2e2f4df50b1f61d4194"] + }, + "adaptive_routing": { + "failover_across_pools": false + }, + "location_strategy": { + "prefer_ecs": "never", + "mode": "pop" + } + } + } + ], + "proxied": true, + "session_affinity": "cookie", + "session_affinity_ttl": 5000, + "session_affinity_attributes": { + "samesite": "Strict", + "secure": "Always", + "drain_duration": 60, + "zero_downtime_failover": "sticky" + } + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/load_balancers", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancer{ + ID: "699d98642c564d2e855e9661899b7252", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Load Balancer for www.example.com", + Name: "www.example.com", + TTL: 30, + FallbackPool: "17b5962d775c646f3f9725cbc7a53df4", + DefaultPools: []string{ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + RegionPools: map[string][]string{ + "WNAM": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "ENAM": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + CountryPools: map[string][]string{ + "US": { + "de90f38ced07c2e2f4df50b1f61d4194", + }, + "GB": { + "abd90f38ced07c2e2f4df50b1f61d4194", + }, + }, + PopPools: map[string][]string{ + "LAX": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "LHR": { + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196", + }, + "SJC": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + RandomSteering: &RandomSteering{ + DefaultWeight: 0.2, + PoolWeights: map[string]float64{ + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4, + }, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(true), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "always", + Mode: "resolver_ip", + }, + Rules: []*LoadBalancerRule{ + { + Name: "example rule", + Condition: "cf.load_balancer.region == \"SAF\"", + Overrides: LoadBalancerRuleOverrides{ + RegionPools: map[string][]string{ + "SAF": {"de90f38ced07c2e2f4df50b1f61d4194"}, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(false), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "never", + Mode: "pop", + }, + }, + }, + }, + Proxied: true, + Persistence: "cookie", + PersistenceTTL: 5000, + SessionAffinityAttributes: &SessionAffinityAttributes{ + SameSite: "Strict", + Secure: "Always", + DrainDuration: 60, + ZeroDowntimeFailover: "sticky", + }, + } + request := LoadBalancer{ + Description: "Load Balancer for www.example.com", + Name: "www.example.com", + TTL: 30, + FallbackPool: "17b5962d775c646f3f9725cbc7a53df4", + DefaultPools: []string{ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + RegionPools: map[string][]string{ + "WNAM": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "ENAM": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + CountryPools: map[string][]string{ + "US": { + "de90f38ced07c2e2f4df50b1f61d4194", + }, + "GB": { + "abd90f38ced07c2e2f4df50b1f61d4194", + }, + }, + PopPools: map[string][]string{ + "LAX": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "LHR": { + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196", + }, + "SJC": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + RandomSteering: &RandomSteering{ + DefaultWeight: 0.2, + PoolWeights: map[string]float64{ + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4, + }, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(true), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "always", + Mode: "resolver_ip", + }, + Rules: []*LoadBalancerRule{ + { + Name: "example rule", + Condition: "cf.load_balancer.region == \"SAF\"", + Overrides: LoadBalancerRuleOverrides{ + RegionPools: map[string][]string{ + "SAF": {"de90f38ced07c2e2f4df50b1f61d4194"}, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(false), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "never", + Mode: "pop", + }, + }, + }, + }, + Proxied: true, + Persistence: "cookie", + PersistenceTTL: 5000, + SessionAffinityAttributes: &SessionAffinityAttributes{ + SameSite: "Strict", + Secure: "Always", + DrainDuration: 60, + ZeroDowntimeFailover: "sticky", + }, + } + + actual, err := client.CreateLoadBalancer(context.Background(), ZoneIdentifier(testZoneID), CreateLoadBalancerParams{LoadBalancer: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateLoadBalancer_AccountIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.CreateLoadBalancer(context.Background(), AccountIdentifier(testAccountID), CreateLoadBalancerParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, AccountRouteLevel), err.Error()) + } +} + +func TestListLoadBalancers(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "699d98642c564d2e855e9661899b7252", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Load Balancer for www.example.com", + "name": "www.example.com", + "ttl": 30, + "fallback_pool": "17b5962d775c646f3f9725cbc7a53df4", + "default_pools": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "region_pools": { + "WNAM": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "ENAM": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "country_pools": { + "US": [ + "de90f38ced07c2e2f4df50b1f61d4194" + ], + "GB": [ + "abd90f38ced07c2e2f4df50b1f61d4194" + ] + }, + "pop_pools": { + "LAX": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "LHR": [ + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196" + ], + "SJC": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "random_steering": { + "default_weight": 0.2, + "pool_weights": { + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4 + } + }, + "adaptive_routing": { + "failover_across_pools": true + }, + "location_strategy": { + "prefer_ecs": "always", + "mode": "resolver_ip" + }, + "proxied": true + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/load_balancers", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := []LoadBalancer{ + { + ID: "699d98642c564d2e855e9661899b7252", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Load Balancer for www.example.com", + Name: "www.example.com", + TTL: 30, + FallbackPool: "17b5962d775c646f3f9725cbc7a53df4", + DefaultPools: []string{ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + RegionPools: map[string][]string{ + "WNAM": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "ENAM": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + CountryPools: map[string][]string{ + "US": { + "de90f38ced07c2e2f4df50b1f61d4194", + }, + "GB": { + "abd90f38ced07c2e2f4df50b1f61d4194", + }, + }, + PopPools: map[string][]string{ + "LAX": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "LHR": { + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196", + }, + "SJC": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + RandomSteering: &RandomSteering{ + DefaultWeight: 0.2, + PoolWeights: map[string]float64{ + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4, + }, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(true), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "always", + Mode: "resolver_ip", + }, + Proxied: true, + }, + } + + actual, err := client.ListLoadBalancers(context.Background(), ZoneIdentifier(testZoneID), ListLoadBalancerParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListLoadBalancer_AccountIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.ListLoadBalancers(context.Background(), AccountIdentifier(testAccountID), ListLoadBalancerParams{}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, AccountRouteLevel), err.Error()) + } +} + +func TestGetLoadBalancer(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-02-01T05:20:00.12345Z", + "description": "Load Balancer for www.example.com", + "name": "www.example.com", + "ttl": 30, + "fallback_pool": "17b5962d775c646f3f9725cbc7a53df4", + "default_pools": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "region_pools": { + "WNAM": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "ENAM": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "country_pools": { + "US": [ + "de90f38ced07c2e2f4df50b1f61d4194" + ], + "GB": [ + "abd90f38ced07c2e2f4df50b1f61d4194" + ] + }, + "pop_pools": { + "LAX": [ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "LHR": [ + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196" + ], + "SJC": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "random_steering": { + "default_weight": 0.2, + "pool_weights": { + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4 + } + }, + "adaptive_routing": { + "failover_across_pools": true + }, + "location_strategy": { + "prefer_ecs": "always", + "mode": "resolver_ip" + }, + "proxied": true + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/load_balancers/699d98642c564d2e855e9661899b7252", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + want := LoadBalancer{ + ID: "699d98642c564d2e855e9661899b7252", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Load Balancer for www.example.com", + Name: "www.example.com", + TTL: 30, + FallbackPool: "17b5962d775c646f3f9725cbc7a53df4", + DefaultPools: []string{ + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + RegionPools: map[string][]string{ + "WNAM": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "ENAM": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + CountryPools: map[string][]string{ + "US": { + "de90f38ced07c2e2f4df50b1f61d4194", + }, + "GB": { + "abd90f38ced07c2e2f4df50b1f61d4194", + }, + }, + PopPools: map[string][]string{ + "LAX": { + "de90f38ced07c2e2f4df50b1f61d4194", + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "LHR": { + "abd90f38ced07c2e2f4df50b1f61d4194", + "f9138c5d07c2e2f4df57b1f61d4196", + }, + "SJC": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + RandomSteering: &RandomSteering{ + DefaultWeight: 0.2, + PoolWeights: map[string]float64{ + "9290f38c5d07c2e2f4df57b1f61d4196": 0.6, + "de90f38ced07c2e2f4df50b1f61d4194": 0.4, + }, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(true), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "always", + Mode: "resolver_ip", + }, + Proxied: true, + } + + actual, err := client.GetLoadBalancer(context.Background(), ZoneIdentifier(testZoneID), "699d98642c564d2e855e9661899b7252") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.GetLoadBalancer(context.Background(), ZoneIdentifier(testZoneID), "bar") + assert.Error(t, err) +} + +func TestGetLoadBalancer_AccountIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetLoadBalancer(context.Background(), AccountIdentifier(testAccountID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, AccountRouteLevel), err.Error()) + } +} + +func TestDeleteLoadBalancer(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/load_balancers/699d98642c564d2e855e9661899b7252", handler) + assert.NoError(t, client.DeleteLoadBalancer(context.Background(), ZoneIdentifier(testZoneID), "699d98642c564d2e855e9661899b7252")) + assert.Error(t, client.DeleteLoadBalancer(context.Background(), ZoneIdentifier(testZoneID), "bar")) +} + +func TestDeleteLoadBalancer_AccountIsNotSupported(t *testing.T) { + setup() + defer teardown() + + err := client.DeleteLoadBalancer(context.Background(), AccountIdentifier(testAccountID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, AccountRouteLevel), err.Error()) + } +} + +func TestUpdateLoadBalancer(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "id": "699d98642c564d2e855e9661899b7252", + "description": "Load Balancer for www.example.com", + "name": "www.example.com", + "ttl": 30, + "fallback_pool": "17b5962d775c646f3f9725cbc7a53df4", + "default_pools": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "region_pools": { + "WNAM": [ + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "ENAM": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "country_pools": { + "US": [ + "de90f38ced07c2e2f4df50b1f61d4194" + ], + "GB": [ + "f9138c5d07c2e2f4df57b1f61d4196" + ] + }, + "pop_pools": { + "LAX": [ + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "LHR": [ + "f9138c5d07c2e2f4df57b1f61d4196" + ], + "SJC": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "random_steering": { + "default_weight": 0.5, + "pool_weights": { + "9290f38c5d07c2e2f4df57b1f61d4196": 0.2 + } + }, + "adaptive_routing": { + "failover_across_pools": false + }, + "location_strategy": { + "prefer_ecs": "never", + "mode": "pop" + }, + "proxied": true, + "session_affinity": "none", + "session_affinity_attributes": { + "samesite": "Strict", + "secure": "Always", + "zero_downtime_failover": "sticky" + } + }`, string(b)) + } + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2017-02-01T05:20:00.12345Z", + "description": "Load Balancer for www.example.com", + "name": "www.example.com", + "ttl": 30, + "fallback_pool": "17b5962d775c646f3f9725cbc7a53df4", + "default_pools": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "region_pools": { + "WNAM": [ + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "ENAM": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "country_pools": { + "US": [ + "de90f38ced07c2e2f4df50b1f61d4194" + ], + "GB": [ + "f9138c5d07c2e2f4df57b1f61d4196" + ] + }, + "pop_pools": { + "LAX": [ + "9290f38c5d07c2e2f4df57b1f61d4196" + ], + "LHR": [ + "f9138c5d07c2e2f4df57b1f61d4196" + ], + "SJC": [ + "00920f38ce07c2e2f4df50b1f61d4194" + ] + }, + "random_steering": { + "default_weight": 0.5, + "pool_weights": { + "9290f38c5d07c2e2f4df57b1f61d4196": 0.2 + } + }, + "adaptive_routing": { + "failover_across_pools": false + }, + "location_strategy": { + "prefer_ecs": "never", + "mode": "pop" + }, + "proxied": true, + "session_affinity": "none", + "session_affinity_attributes": { + "samesite": "Strict", + "secure": "Always", + "zero_downtime_failover": "sticky" + } + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/load_balancers/699d98642c564d2e855e9661899b7252", handler) + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-02-01T05:20:00.12345Z") + want := LoadBalancer{ + ID: "699d98642c564d2e855e9661899b7252", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Description: "Load Balancer for www.example.com", + Name: "www.example.com", + TTL: 30, + FallbackPool: "17b5962d775c646f3f9725cbc7a53df4", + DefaultPools: []string{ + "00920f38ce07c2e2f4df50b1f61d4194", + }, + RegionPools: map[string][]string{ + "WNAM": { + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "ENAM": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + CountryPools: map[string][]string{ + "US": { + "de90f38ced07c2e2f4df50b1f61d4194", + }, + "GB": { + "f9138c5d07c2e2f4df57b1f61d4196", + }, + }, + PopPools: map[string][]string{ + "LAX": { + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "LHR": { + "f9138c5d07c2e2f4df57b1f61d4196", + }, + "SJC": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + RandomSteering: &RandomSteering{ + DefaultWeight: 0.5, + PoolWeights: map[string]float64{ + "9290f38c5d07c2e2f4df57b1f61d4196": 0.2, + }, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(false), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "never", + Mode: "pop", + }, + Proxied: true, + Persistence: "none", + SessionAffinityAttributes: &SessionAffinityAttributes{ + SameSite: "Strict", + Secure: "Always", + ZeroDowntimeFailover: "sticky", + }, + } + request := LoadBalancer{ + ID: "699d98642c564d2e855e9661899b7252", + Description: "Load Balancer for www.example.com", + Name: "www.example.com", + TTL: 30, + FallbackPool: "17b5962d775c646f3f9725cbc7a53df4", + DefaultPools: []string{ + "00920f38ce07c2e2f4df50b1f61d4194", + }, + RegionPools: map[string][]string{ + "WNAM": { + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "ENAM": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + CountryPools: map[string][]string{ + "US": { + "de90f38ced07c2e2f4df50b1f61d4194", + }, + "GB": { + "f9138c5d07c2e2f4df57b1f61d4196", + }, + }, + PopPools: map[string][]string{ + "LAX": { + "9290f38c5d07c2e2f4df57b1f61d4196", + }, + "LHR": { + "f9138c5d07c2e2f4df57b1f61d4196", + }, + "SJC": { + "00920f38ce07c2e2f4df50b1f61d4194", + }, + }, + RandomSteering: &RandomSteering{ + DefaultWeight: 0.5, + PoolWeights: map[string]float64{ + "9290f38c5d07c2e2f4df57b1f61d4196": 0.2, + }, + }, + AdaptiveRouting: &AdaptiveRouting{ + FailoverAcrossPools: BoolPtr(false), + }, + LocationStrategy: &LocationStrategy{ + PreferECS: "never", + Mode: "pop", + }, + Proxied: true, + Persistence: "none", + SessionAffinityAttributes: &SessionAffinityAttributes{ + SameSite: "Strict", + Secure: "Always", + ZeroDowntimeFailover: "sticky", + }, + } + + actual, err := client.UpdateLoadBalancer(context.Background(), ZoneIdentifier(testZoneID), UpdateLoadBalancerParams{LoadBalancer: request}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateLoadBalancer_AccountIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateLoadBalancer(context.Background(), AccountIdentifier(testAccountID), UpdateLoadBalancerParams{LoadBalancer: LoadBalancer{}}) + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, AccountRouteLevel), err.Error()) + } +} + +func TestLoadBalancerPoolHealthDetails(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "pool_id": "699d98642c564d2e855e9661899b7252", + "pop_health": { + "Amsterdam, NL": { + "healthy": true, + "origins": [ + { + "2001:DB8::5": { + "healthy": true, + "rtt": "12.1ms", + "failure_reason": "No failures", + "response_code": 401 + } + } + ] + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/load_balancers/pools/699d98642c564d2e855e9661899b7252/health", handler) + want := LoadBalancerPoolHealth{ + ID: "699d98642c564d2e855e9661899b7252", + PopHealth: map[string]LoadBalancerPoolPopHealth{ + "Amsterdam, NL": { + Healthy: true, + Origins: []map[string]LoadBalancerOriginHealth{ + { + "2001:DB8::5": { + Healthy: true, + RTT: Duration{12*time.Millisecond + 100*time.Microsecond}, + FailureReason: "No failures", + ResponseCode: 401, + }, + }, + }, + }, + }, + } + + actual, err := client.GetLoadBalancerPoolHealth(context.Background(), AccountIdentifier(testAccountID), "699d98642c564d2e855e9661899b7252") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestLoadBalancerPoolHealthDetails_ZoneIsNotSupported(t *testing.T) { + setup() + defer teardown() + + _, err := client.GetLoadBalancerPoolHealth(context.Background(), ZoneIdentifier(testZoneID), "foo") + if assert.Error(t, err) { + assert.Equal(t, fmt.Sprintf(errInvalidResourceContainerAccess, ZoneRouteLevel), err.Error()) + } +} diff --git a/pkg/cloudflare-go/lockdown.go b/pkg/cloudflare-go/lockdown.go new file mode 100644 index 000000000..b7c76320e --- /dev/null +++ b/pkg/cloudflare-go/lockdown.go @@ -0,0 +1,192 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// ZoneLockdown represents a Zone Lockdown rule. A rule only permits access to +// the provided URL pattern(s) from the given IP address(es) or subnet(s). +type ZoneLockdown struct { + ID string `json:"id"` + Description string `json:"description"` + URLs []string `json:"urls"` + Configurations []ZoneLockdownConfig `json:"configurations"` + Paused bool `json:"paused"` + Priority int `json:"priority,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` +} + +// ZoneLockdownConfig represents a Zone Lockdown config, which comprises +// a Target ("ip" or "ip_range") and a Value (an IP address or IP+mask, +// respectively.) +type ZoneLockdownConfig struct { + Target string `json:"target"` + Value string `json:"value"` +} + +// ZoneLockdownResponse represents a response from the Zone Lockdown endpoint. +type ZoneLockdownResponse struct { + Result ZoneLockdown `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// ZoneLockdownListResponse represents a response from the List Zone Lockdown +// endpoint. +type ZoneLockdownListResponse struct { + Result []ZoneLockdown `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// ZoneLockdownCreateParams contains required and optional params +// for creating a zone lockdown. +type ZoneLockdownCreateParams struct { + Description string `json:"description"` + URLs []string `json:"urls"` + Configurations []ZoneLockdownConfig `json:"configurations"` + Paused bool `json:"paused"` + Priority int `json:"priority,omitempty"` +} + +// ZoneLockdownUpdateParams contains required and optional params +// for updating a zone lockdown. +type ZoneLockdownUpdateParams struct { + ID string `json:"id"` + Description string `json:"description"` + URLs []string `json:"urls"` + Configurations []ZoneLockdownConfig `json:"configurations"` + Paused bool `json:"paused"` + Priority int `json:"priority,omitempty"` +} + +type LockdownListParams struct { + ResultInfo +} + +// CreateZoneLockdown creates a Zone ZoneLockdown rule for the given zone ID. +// +// API reference: https://api.cloudflare.com/#zone-ZoneLockdown-create-a-ZoneLockdown-rule +func (api *API) CreateZoneLockdown(ctx context.Context, rc *ResourceContainer, params ZoneLockdownCreateParams) (ZoneLockdown, error) { + uri := fmt.Sprintf("/zones/%s/firewall/lockdowns", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return ZoneLockdown{}, err + } + + response := &ZoneLockdownResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneLockdown{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// UpdateZoneLockdown updates a Zone ZoneLockdown rule (based on the ID) for the given zone ID. +// +// API reference: https://api.cloudflare.com/#zone-ZoneLockdown-update-ZoneLockdown-rule +func (api *API) UpdateZoneLockdown(ctx context.Context, rc *ResourceContainer, params ZoneLockdownUpdateParams) (ZoneLockdown, error) { + uri := fmt.Sprintf("/zones/%s/firewall/lockdowns/%s", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return ZoneLockdown{}, err + } + + response := &ZoneLockdownResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneLockdown{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// DeleteZoneLockdown deletes a Zone ZoneLockdown rule (based on the ID) for the given zone ID. +// +// API reference: https://api.cloudflare.com/#zone-ZoneLockdown-delete-ZoneLockdown-rule +func (api *API) DeleteZoneLockdown(ctx context.Context, rc *ResourceContainer, id string) (ZoneLockdown, error) { + uri := fmt.Sprintf("/zones/%s/firewall/lockdowns/%s", rc.Identifier, id) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return ZoneLockdown{}, err + } + + response := &ZoneLockdownResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneLockdown{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// ZoneLockdown retrieves a Zone ZoneLockdown rule (based on the ID) for the given zone ID. +// +// API reference: https://api.cloudflare.com/#zone-ZoneLockdown-ZoneLockdown-rule-details +func (api *API) ZoneLockdown(ctx context.Context, rc *ResourceContainer, id string) (ZoneLockdown, error) { + uri := fmt.Sprintf("/zones/%s/firewall/lockdowns/%s", rc.Identifier, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneLockdown{}, err + } + + response := &ZoneLockdownResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneLockdown{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// ListZoneLockdowns retrieves every Zone ZoneLockdown rules for a given zone ID. +// +// Automatically paginates all results unless `params.PerPage` and `params.Page` +// is set. +// +// API reference: https://api.cloudflare.com/#zone-ZoneLockdown-list-ZoneLockdown-rules +func (api *API) ListZoneLockdowns(ctx context.Context, rc *ResourceContainer, params LockdownListParams) ([]ZoneLockdown, *ResultInfo, error) { + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var zoneLockdowns []ZoneLockdown + var zResponse ZoneLockdownListResponse + for { + zResponse = ZoneLockdownListResponse{} + uri := buildURI(fmt.Sprintf("/zones/%s/firewall/lockdowns", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []ZoneLockdown{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &zResponse) + if err != nil { + return []ZoneLockdown{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal filters JSON data: %w", err) + } + + zoneLockdowns = append(zoneLockdowns, zResponse.Result...) + params.ResultInfo = zResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return zoneLockdowns, &zResponse.ResultInfo, nil +} diff --git a/pkg/cloudflare-go/lockdown_example_test.go b/pkg/cloudflare-go/lockdown_example_test.go new file mode 100644 index 000000000..ffb1002f9 --- /dev/null +++ b/pkg/cloudflare-go/lockdown_example_test.go @@ -0,0 +1,64 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + "strings" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ListZoneLockdowns_all() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch all Zone Lockdown rules for a zone, by page. + rules, _, err := api.ListZoneLockdowns(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.LockdownListParams{}) + if err != nil { + log.Fatal(err) + } + + for _, r := range rules { + fmt.Printf("%s: %s\n", strings.Join(r.URLs, ", "), r.Configurations) + } +} + +func ExampleAPI_CreateZoneLockdown() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.org") + if err != nil { + log.Fatal(err) + } + + newZoneLockdown := cloudflare.ZoneLockdownCreateParams{ + Description: "Test Zone Lockdown Rule", + URLs: []string{ + "*.example.org/test", + }, + Configurations: []cloudflare.ZoneLockdownConfig{ + { + Target: "ip", + Value: "198.51.100.1", + }, + }, + Paused: false, + } + + response, err := api.CreateZoneLockdown(context.Background(), cloudflare.ZoneIdentifier(zoneID), newZoneLockdown) + if err != nil { + log.Fatal(err) + } + fmt.Println("Response: ", response) +} diff --git a/pkg/cloudflare-go/lockdown_test.go b/pkg/cloudflare-go/lockdown_test.go new file mode 100644 index 000000000..470fca6bf --- /dev/null +++ b/pkg/cloudflare-go/lockdown_test.go @@ -0,0 +1,303 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateZoneLockdown(t *testing.T) { + setup() + defer teardown() + + input := ZoneLockdownCreateParams{ + Description: "Restrict access to these endpoints to requests from a known IP address", + URLs: []string{"api.mysite.com/some/endpoint*"}, + Configurations: []ZoneLockdownConfig{{ + Target: "ip", + Value: "198.51.100.4", + }}, + Paused: false, + } + + want := ZoneLockdown{ + Description: "Restrict access to these endpoints to requests from a known IP address", + URLs: []string{"api.mysite.com/some/endpoint*"}, + Configurations: []ZoneLockdownConfig{{ + Target: "ip", + Value: "198.51.100.4", + }}, + Paused: false, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s") + + var v ZoneLockdown + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, want, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "paused": false, + "description": "Restrict access to these endpoints to requests from a known IP address", + "urls": [ + "api.mysite.com/some/endpoint*" + ], + "configurations": [ + { + "target": "ip", + "value": "198.51.100.4" + } + ] + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/lockdowns", handler) + + actual, err := client.CreateZoneLockdown(context.Background(), ZoneIdentifier(testZoneID), input) + require.NoError(t, err) + + want.ID = "372e67954025e0ba6aaa6d586b9e0b59" + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want.CreatedOn = &createdOn + want.ModifiedOn = &modifiedOn + + assert.Equal(t, want, actual) +} + +func TestUpdateZoneLockdown(t *testing.T) { + setup() + defer teardown() + + input := ZoneLockdownUpdateParams{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Description: "Restrict access to these endpoints to requests from a known IP address", + URLs: []string{"api.mysite.com/some/endpoint*"}, + Configurations: []ZoneLockdownConfig{{ + Target: "ip", + Value: "198.51.100.4", + }}, + Paused: false, + } + + want := ZoneLockdown{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Description: "Restrict access to these endpoints to requests from a known IP address", + URLs: []string{"api.mysite.com/some/endpoint*"}, + Configurations: []ZoneLockdownConfig{{ + Target: "ip", + Value: "198.51.100.4", + }}, + Paused: false, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s") + + var v ZoneLockdown + err := json.NewDecoder(r.Body).Decode(&v) + require.NoError(t, err) + assert.Equal(t, want, v) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "paused": false, + "description": "Restrict access to these endpoints to requests from a known IP address", + "urls": [ + "api.mysite.com/some/endpoint*" + ], + "configurations": [ + { + "target": "ip", + "value": "198.51.100.4" + } + ] + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/lockdowns/372e67954025e0ba6aaa6d586b9e0b59", handler) + + actual, err := client.UpdateZoneLockdown(context.Background(), ZoneIdentifier(testZoneID), input) + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want.CreatedOn = &createdOn + want.ModifiedOn = &modifiedOn + + assert.Equal(t, want, actual) +} + +func TestDeleteZoneLockdown(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s") + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59" + } + }`) + } + + zoneLockdownID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/lockdowns/"+zoneLockdownID, handler) + + actual, err := client.DeleteZoneLockdown(context.Background(), ZoneIdentifier(testZoneID), zoneLockdownID) + require.NoError(t, err) + + want := ZoneLockdown{ + ID: zoneLockdownID, + } + assert.Equal(t, want, actual) +} + +func TestZoneLockdown(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s") + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "paused": false, + "description": "Restrict access to these endpoints to requests from a known IP address", + "urls": [ + "api.mysite.com/some/endpoint*" + ], + "configurations": [ + { + "target": "ip", + "value": "198.51.100.4" + } + ] + } + }`) + } + + zoneLockdownID := "372e67954025e0ba6aaa6d586b9e0b59" + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/lockdowns/"+zoneLockdownID, handler) + + actual, err := client.ZoneLockdown(context.Background(), ZoneIdentifier(testZoneID), zoneLockdownID) + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := ZoneLockdown{ + ID: zoneLockdownID, + Description: "Restrict access to these endpoints to requests from a known IP address", + URLs: []string{"api.mysite.com/some/endpoint*"}, + Configurations: []ZoneLockdownConfig{{ + Target: "ip", + Value: "198.51.100.4", + }}, + Paused: false, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + assert.Equal(t, want, actual) +} + +func TestListZoneLockdowns(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s") + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "paused": false, + "description": "Restrict access to these endpoints to requests from a known IP address", + "urls": [ + "api.mysite.com/some/endpoint*" + ], + "configurations": [ + { + "target": "ip", + "value": "198.51.100.4" + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/lockdowns", handler) + + actual, _, err := client.ListZoneLockdowns(context.Background(), ZoneIdentifier(testZoneID), LockdownListParams{}) + require.NoError(t, err) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + want := []ZoneLockdown{{ + ID: "372e67954025e0ba6aaa6d586b9e0b59", + Description: "Restrict access to these endpoints to requests from a known IP address", + URLs: []string{"api.mysite.com/some/endpoint*"}, + Configurations: []ZoneLockdownConfig{{ + Target: "ip", + Value: "198.51.100.4", + }}, + Paused: false, + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + }, + } + assert.Equal(t, want, actual) +} diff --git a/pkg/cloudflare-go/logger.go b/pkg/cloudflare-go/logger.go new file mode 100644 index 000000000..b1bd9b4ef --- /dev/null +++ b/pkg/cloudflare-go/logger.go @@ -0,0 +1,130 @@ +package cloudflare + +import ( + "fmt" + "io" + "log" + "os" +) + +// silentRetryLogger is the logger provided with retryable client to stop it +// displaying the retry attempts. +var silentRetryLogger = log.New(io.Discard, "", log.LstdFlags) + +const ( + // LevelNull sets a logger to show no messages at all. + LevelNull Level = 0 + + // LevelError sets a logger to show error messages only. + LevelError Level = 1 + + // LevelWarn sets a logger to show warning messages or anything more + // severe. + LevelWarn Level = 2 + + // LevelInfo sets a logger to show informational messages or anything more + // severe. + LevelInfo Level = 3 + + // LevelDebug sets a logger to show informational messages or anything more + // severe. + LevelDebug Level = 4 +) + +// DefaultLeveledLogger is the default logger that the library will use to log +// errors, warnings, and informational messages. +var DefaultLeveledLogger LeveledLoggerInterface = &LeveledLogger{ + Level: LevelError, +} + +// SilentLeveledLogger is a logger for disregarding all logs written. +var SilentLeveledLogger LeveledLoggerInterface = &LeveledLogger{ + Level: LevelNull, +} + +// Level represents a logging level. +type Level uint32 + +// LeveledLogger is a leveled logger implementation. +// +// It prints warnings and errors to `os.Stderr` and other messages to +// `os.Stdout`. +type LeveledLogger struct { + // Level is the minimum logging level that will be emitted by this logger. + // + // For example, a Level set to LevelWarn will emit warnings and errors, but + // not informational or debug messages. + // + // Always set this with a constant like LevelWarn because the individual + // values are not guaranteed to be stable. + Level Level + + // Internal testing use only. + stderrOverride io.Writer + stdoutOverride io.Writer +} + +// Debugf logs a debug message using Printf conventions. +func (l *LeveledLogger) Debugf(format string, v ...interface{}) { + if l.Level >= LevelDebug { + fmt.Fprintf(l.stdout(), "[debug] "+format, v...) + } +} + +// Errorf logs a warning message using Printf conventions. +func (l *LeveledLogger) Errorf(format string, v ...interface{}) { + // Infof logs a debug message using Printf conventions. + if l.Level >= LevelError { + fmt.Fprintf(l.stderr(), "[error] "+format, v...) + } +} + +// Infof logs an informational message using Printf conventions. +func (l *LeveledLogger) Infof(format string, v ...interface{}) { + if l.Level >= LevelInfo { + fmt.Fprintf(l.stdout(), "[info] "+format, v...) + } +} + +// Warnf logs a warning message using Printf conventions. +func (l *LeveledLogger) Warnf(format string, v ...interface{}) { + if l.Level >= LevelWarn { + fmt.Fprintf(l.stderr(), "[warn] "+format, v...) + } +} + +func (l *LeveledLogger) stderr() io.Writer { + if l.stderrOverride != nil { + return l.stderrOverride + } + + return os.Stderr +} + +func (l *LeveledLogger) stdout() io.Writer { + if l.stdoutOverride != nil { + return l.stdoutOverride + } + + return os.Stdout +} + +// LeveledLoggerInterface provides a basic leveled logging interface for +// printing debug, informational, warning, and error messages. +// +// It's implemented by LeveledLogger and also provides out-of-the-box +// compatibility with a Logrus Logger, but may require a thin shim for use with +// other logging libraries that you use less standard conventions like Zap. +type LeveledLoggerInterface interface { + // Debugf logs a debug message using Printf conventions. + Debugf(format string, v ...interface{}) + + // Errorf logs a warning message using Printf conventions. + Errorf(format string, v ...interface{}) + + // Infof logs an informational message using Printf conventions. + Infof(format string, v ...interface{}) + + // Warnf logs a warning message using Printf conventions. + Warnf(format string, v ...interface{}) +} diff --git a/pkg/cloudflare-go/logpull.go b/pkg/cloudflare-go/logpull.go new file mode 100644 index 000000000..d36911595 --- /dev/null +++ b/pkg/cloudflare-go/logpull.go @@ -0,0 +1,58 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// LogpullRetentionConfiguration describes a the structure of a Logpull Retention +// payload. +type LogpullRetentionConfiguration struct { + Flag bool `json:"flag"` +} + +// LogpullRetentionConfigurationResponse is the API response, containing the +// Logpull retention result. +type LogpullRetentionConfigurationResponse struct { + Response + Result LogpullRetentionConfiguration `json:"result"` +} + +// GetLogpullRetentionFlag gets the current setting flag. +// +// API reference: https://developers.cloudflare.com/logs/logpull-api/enabling-log-retention/ +func (api *API) GetLogpullRetentionFlag(ctx context.Context, zoneID string) (*LogpullRetentionConfiguration, error) { + uri := fmt.Sprintf("/zones/%s/logs/control/retention/flag", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return &LogpullRetentionConfiguration{}, err + } + var r LogpullRetentionConfigurationResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +// SetLogpullRetentionFlag updates the retention flag to the defined boolean. +// +// API reference: https://developers.cloudflare.com/logs/logpull-api/enabling-log-retention/ +func (api *API) SetLogpullRetentionFlag(ctx context.Context, zoneID string, enabled bool) (*LogpullRetentionConfiguration, error) { + uri := fmt.Sprintf("/zones/%s/logs/control/retention/flag", zoneID) + flagPayload := LogpullRetentionConfiguration{Flag: enabled} + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, flagPayload) + if err != nil { + return &LogpullRetentionConfiguration{}, err + } + var r LogpullRetentionConfigurationResponse + err = json.Unmarshal(res, &r) + if err != nil { + return &LogpullRetentionConfiguration{}, err + } + return &r.Result, nil +} diff --git a/pkg/cloudflare-go/logpull_test.go b/pkg/cloudflare-go/logpull_test.go new file mode 100644 index 000000000..ce9275d96 --- /dev/null +++ b/pkg/cloudflare-go/logpull_test.go @@ -0,0 +1,62 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetLogpullRetentionFlag(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": { + "flag": true + }, + "success": true + }`) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/logs/control/retention/flag", handler) + want := &LogpullRetentionConfiguration{Flag: true} + + actual, err := client.GetLogpullRetentionFlag(context.Background(), "d56084adb405e0b7e32c52321bf07be6") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSetLogpullRetentionFlag(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "errors": [], + "messages": [], + "result": { + "flag": false + }, + "success": true + }`) + } + + mux.HandleFunc("/zones/d56084adb405e0b7e32c52321bf07be6/logs/control/retention/flag", handler) + want := &LogpullRetentionConfiguration{Flag: false} + + actual, err := client.SetLogpullRetentionFlag(context.Background(), "d56084adb405e0b7e32c52321bf07be6", false) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/logpush.go b/pkg/cloudflare-go/logpush.go new file mode 100644 index 000000000..34e32e9cb --- /dev/null +++ b/pkg/cloudflare-go/logpush.go @@ -0,0 +1,572 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// LogpushJob describes a Logpush job. +type LogpushJob struct { + ID int `json:"id,omitempty"` + Dataset string `json:"dataset"` + Enabled bool `json:"enabled"` + Kind string `json:"kind,omitempty"` + Name string `json:"name"` + LogpullOptions string `json:"logpull_options,omitempty"` + OutputOptions *LogpushOutputOptions `json:"output_options,omitempty"` + DestinationConf string `json:"destination_conf"` + OwnershipChallenge string `json:"ownership_challenge,omitempty"` + LastComplete *time.Time `json:"last_complete,omitempty"` + LastError *time.Time `json:"last_error,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Frequency string `json:"frequency,omitempty"` + Filter *LogpushJobFilters `json:"filter,omitempty"` + MaxUploadBytes int `json:"max_upload_bytes,omitempty"` + MaxUploadRecords int `json:"max_upload_records,omitempty"` + MaxUploadIntervalSeconds int `json:"max_upload_interval_seconds,omitempty"` +} + +type LogpushJobFilters struct { + Where LogpushJobFilter `json:"where"` +} + +type Operator string + +const ( + Equal Operator = "eq" + NotEqual Operator = "!eq" + LessThan Operator = "lt" + LessThanOrEqual Operator = "leq" + GreaterThan Operator = "gt" + GreaterThanOrEqual Operator = "geq" + StartsWith Operator = "startsWith" + EndsWith Operator = "endsWith" + NotStartsWith Operator = "!startsWith" + NotEndsWith Operator = "!endsWith" + Contains Operator = "contains" + NotContains Operator = "!contains" + ValueIsIn Operator = "in" + ValueIsNotIn Operator = "!in" +) + +type LogpushJobFilter struct { + // either this + And []LogpushJobFilter `json:"and,omitempty"` + Or []LogpushJobFilter `json:"or,omitempty"` + // or this + Key string `json:"key,omitempty"` + Operator Operator `json:"operator,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +type LogpushOutputOptions struct { + FieldNames []string `json:"field_names"` + OutputType string `json:"output_type,omitempty"` + BatchPrefix string `json:"batch_prefix,omitempty"` + BatchSuffix string `json:"batch_suffix,omitempty"` + RecordPrefix string `json:"record_prefix,omitempty"` + RecordSuffix string `json:"record_suffix,omitempty"` + RecordTemplate string `json:"record_template,omitempty"` + RecordDelimiter string `json:"record_delimiter,omitempty"` + FieldDelimiter string `json:"field_delimiter,omitempty"` + TimestampFormat string `json:"timestamp_format,omitempty"` + SampleRate float64 `json:"sample_rate,omitempty"` + CVE202144228 *bool `json:"CVE-2021-44228,omitempty"` +} + +// LogpushJobsResponse is the API response, containing an array of Logpush Jobs. +type LogpushJobsResponse struct { + Response + Result []LogpushJob `json:"result"` +} + +// LogpushJobDetailsResponse is the API response, containing a single Logpush Job. +type LogpushJobDetailsResponse struct { + Response + Result LogpushJob `json:"result"` +} + +// LogpushFieldsResponse is the API response for a datasets fields. +type LogpushFieldsResponse struct { + Response + Result LogpushFields `json:"result"` +} + +// LogpushFields is a map of available Logpush field names & descriptions. +type LogpushFields map[string]string + +// LogpushGetOwnershipChallenge describes a ownership validation. +type LogpushGetOwnershipChallenge struct { + Filename string `json:"filename"` + Valid bool `json:"valid"` + Message string `json:"message"` +} + +// LogpushGetOwnershipChallengeResponse is the API response, containing a ownership challenge. +type LogpushGetOwnershipChallengeResponse struct { + Response + Result LogpushGetOwnershipChallenge `json:"result"` +} + +// LogpushGetOwnershipChallengeRequest is the API request for get ownership challenge. +type LogpushGetOwnershipChallengeRequest struct { + DestinationConf string `json:"destination_conf"` +} + +// LogpushOwnershipChallengeValidationResponse is the API response, +// containing a ownership challenge validation result. +type LogpushOwnershipChallengeValidationResponse struct { + Response + Result struct { + Valid bool `json:"valid"` + } +} + +// LogpushValidateOwnershipChallengeRequest is the API request for validate ownership challenge. +type LogpushValidateOwnershipChallengeRequest struct { + DestinationConf string `json:"destination_conf"` + OwnershipChallenge string `json:"ownership_challenge"` +} + +// LogpushDestinationExistsResponse is the API response, +// containing a destination exists check result. +type LogpushDestinationExistsResponse struct { + Response + Result struct { + Exists bool `json:"exists"` + } +} + +// LogpushDestinationExistsRequest is the API request for check destination exists. +type LogpushDestinationExistsRequest struct { + DestinationConf string `json:"destination_conf"` +} + +// Custom Marshaller for LogpushJob filter key. +func (f LogpushJob) MarshalJSON() ([]byte, error) { + type Alias LogpushJob + + var filter string + + if f.Filter != nil { + b, err := json.Marshal(f.Filter) + + if err != nil { + return nil, err + } + + filter = string(b) + } + + return json.Marshal(&struct { + Filter string `json:"filter,omitempty"` + Alias + }{ + Filter: filter, + Alias: (Alias)(f), + }) +} + +// Custom Unmarshaller for LogpushJob filter key. +func (f *LogpushJob) UnmarshalJSON(data []byte) error { + type Alias LogpushJob + aux := &struct { + Filter string `json:"filter,omitempty"` + *Alias + }{ + Alias: (*Alias)(f), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if aux != nil && aux.Filter != "" { + var filter LogpushJobFilters + if err := json.Unmarshal([]byte(aux.Filter), &filter); err != nil { + return err + } + if err := filter.Where.Validate(); err != nil { + return err + } + f.Filter = &filter + } + return nil +} + +func (f CreateLogpushJobParams) MarshalJSON() ([]byte, error) { + type Alias CreateLogpushJobParams + + var filter string + + if f.Filter != nil { + b, err := json.Marshal(f.Filter) + + if err != nil { + return nil, err + } + + filter = string(b) + } + + return json.Marshal(&struct { + Filter string `json:"filter,omitempty"` + Alias + }{ + Filter: filter, + Alias: (Alias)(f), + }) +} + +// Custom Unmarshaller for CreateLogpushJobParams filter key. +func (f *CreateLogpushJobParams) UnmarshalJSON(data []byte) error { + type Alias CreateLogpushJobParams + aux := &struct { + Filter string `json:"filter,omitempty"` + *Alias + }{ + Alias: (*Alias)(f), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if aux != nil && aux.Filter != "" { + var filter LogpushJobFilters + if err := json.Unmarshal([]byte(aux.Filter), &filter); err != nil { + return err + } + if err := filter.Where.Validate(); err != nil { + return err + } + f.Filter = &filter + } + return nil +} + +func (f UpdateLogpushJobParams) MarshalJSON() ([]byte, error) { + type Alias UpdateLogpushJobParams + + var filter string + + if f.Filter != nil { + b, err := json.Marshal(f.Filter) + + if err != nil { + return nil, err + } + + filter = string(b) + } + + return json.Marshal(&struct { + Filter string `json:"filter,omitempty"` + Alias + }{ + Filter: filter, + Alias: (Alias)(f), + }) +} + +// Custom Unmarshaller for UpdateLogpushJobParams filter key. +func (f *UpdateLogpushJobParams) UnmarshalJSON(data []byte) error { + type Alias UpdateLogpushJobParams + aux := &struct { + Filter string `json:"filter,omitempty"` + *Alias + }{ + Alias: (*Alias)(f), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if aux != nil && aux.Filter != "" { + var filter LogpushJobFilters + if err := json.Unmarshal([]byte(aux.Filter), &filter); err != nil { + return err + } + if err := filter.Where.Validate(); err != nil { + return err + } + f.Filter = &filter + } + return nil +} + +func (filter *LogpushJobFilter) Validate() error { + if filter.And != nil { + if filter.Or != nil || filter.Key != "" || filter.Operator != "" || filter.Value != nil { + return errors.New("And can't be set with Or, Key, Operator or Value") + } + for i, element := range filter.And { + err := element.Validate() + if err != nil { + return fmt.Errorf("element %v in And is invalid: %w", i, err) + } + } + return nil + } + if filter.Or != nil { + if filter.And != nil || filter.Key != "" || filter.Operator != "" || filter.Value != nil { + return errors.New("Or can't be set with And, Key, Operator or Value") + } + for i, element := range filter.Or { + err := element.Validate() + if err != nil { + return fmt.Errorf("element %v in Or is invalid: %w", i, err) + } + } + return nil + } + if filter.Key == "" { + return errors.New("Key is missing") + } + + if filter.Operator == "" { + return errors.New("Operator is missing") + } + + if filter.Value == nil { + return errors.New("Value is missing") + } + + return nil +} + +type CreateLogpushJobParams struct { + Dataset string `json:"dataset"` + Enabled bool `json:"enabled"` + Kind string `json:"kind,omitempty"` + Name string `json:"name"` + LogpullOptions string `json:"logpull_options,omitempty"` + OutputOptions *LogpushOutputOptions `json:"output_options,omitempty"` + DestinationConf string `json:"destination_conf"` + OwnershipChallenge string `json:"ownership_challenge,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Frequency string `json:"frequency,omitempty"` + Filter *LogpushJobFilters `json:"filter,omitempty"` + MaxUploadBytes int `json:"max_upload_bytes,omitempty"` + MaxUploadRecords int `json:"max_upload_records,omitempty"` + MaxUploadIntervalSeconds int `json:"max_upload_interval_seconds,omitempty"` +} + +type ListLogpushJobsParams struct{} + +type ListLogpushJobsForDatasetParams struct { + Dataset string `json:"-"` +} + +type GetLogpushFieldsParams struct { + Dataset string `json:"-"` +} + +type UpdateLogpushJobParams struct { + ID int `json:"-"` + Dataset string `json:"dataset"` + Enabled bool `json:"enabled"` + Kind string `json:"kind,omitempty"` + Name string `json:"name"` + LogpullOptions string `json:"logpull_options,omitempty"` + OutputOptions *LogpushOutputOptions `json:"output_options,omitempty"` + DestinationConf string `json:"destination_conf"` + OwnershipChallenge string `json:"ownership_challenge,omitempty"` + LastComplete *time.Time `json:"last_complete,omitempty"` + LastError *time.Time `json:"last_error,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Frequency string `json:"frequency,omitempty"` + Filter *LogpushJobFilters `json:"filter,omitempty"` + MaxUploadBytes int `json:"max_upload_bytes,omitempty"` + MaxUploadRecords int `json:"max_upload_records,omitempty"` + MaxUploadIntervalSeconds int `json:"max_upload_interval_seconds,omitempty"` +} + +type ValidateLogpushOwnershipChallengeParams struct { + DestinationConf string `json:"destination_conf"` + OwnershipChallenge string `json:"ownership_challenge"` +} + +type GetLogpushOwnershipChallengeParams struct { + DestinationConf string `json:"destination_conf"` +} + +// CreateLogpushJob creates a new zone-level Logpush Job. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-create-logpush-job +func (api *API) CreateLogpushJob(ctx context.Context, rc *ResourceContainer, params CreateLogpushJobParams) (*LogpushJob, error) { + uri := fmt.Sprintf("/%s/%s/logpush/jobs", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return nil, err + } + var r LogpushJobDetailsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +// ListAccountLogpushJobs returns all Logpush Jobs for all datasets. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-list-logpush-jobs +func (api *API) ListLogpushJobs(ctx context.Context, rc *ResourceContainer, params ListLogpushJobsParams) ([]LogpushJob, error) { + uri := fmt.Sprintf("/%s/%s/logpush/jobs", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []LogpushJob{}, err + } + var r LogpushJobsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []LogpushJob{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// LogpushJobsForDataset returns all Logpush Jobs for a dataset. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-list-logpush-jobs-for-a-dataset +func (api *API) ListLogpushJobsForDataset(ctx context.Context, rc *ResourceContainer, params ListLogpushJobsForDatasetParams) ([]LogpushJob, error) { + uri := fmt.Sprintf("/%s/%s/logpush/datasets/%s/jobs", rc.Level, rc.Identifier, params.Dataset) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []LogpushJob{}, err + } + var r LogpushJobsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []LogpushJob{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// LogpushFields returns fields for a given dataset. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-list-logpush-jobs +func (api *API) GetLogpushFields(ctx context.Context, rc *ResourceContainer, params GetLogpushFieldsParams) (LogpushFields, error) { + uri := fmt.Sprintf("/%s/%s/logpush/datasets/%s/fields", rc.Level, rc.Identifier, params.Dataset) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return LogpushFields{}, err + } + var r LogpushFieldsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return LogpushFields{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// LogpushJob fetches detail about one Logpush Job for a zone. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-logpush-job-details +func (api *API) GetLogpushJob(ctx context.Context, rc *ResourceContainer, jobID int) (LogpushJob, error) { + uri := fmt.Sprintf("/%s/%s/logpush/jobs/%d", rc.Level, rc.Identifier, jobID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return LogpushJob{}, err + } + var r LogpushJobDetailsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return LogpushJob{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateLogpushJob lets you update a Logpush Job. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-update-logpush-job +func (api *API) UpdateLogpushJob(ctx context.Context, rc *ResourceContainer, params UpdateLogpushJobParams) error { + uri := fmt.Sprintf("/%s/%s/logpush/jobs/%d", rc.Level, rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return err + } + var r LogpushJobDetailsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// DeleteLogpushJob deletes a Logpush Job for a zone. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-delete-logpush-job +func (api *API) DeleteLogpushJob(ctx context.Context, rc *ResourceContainer, jobID int) error { + uri := fmt.Sprintf("/%s/%s/logpush/jobs/%d", rc.Level, rc.Identifier, jobID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r LogpushJobDetailsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// GetLogpushOwnershipChallenge returns ownership challenge. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-get-ownership-challenge +func (api *API) GetLogpushOwnershipChallenge(ctx context.Context, rc *ResourceContainer, params GetLogpushOwnershipChallengeParams) (*LogpushGetOwnershipChallenge, error) { + uri := fmt.Sprintf("/%s/%s/logpush/ownership", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return nil, err + } + var r LogpushGetOwnershipChallengeResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !r.Result.Valid { + return nil, errors.New(r.Result.Message) + } + + return &r.Result, nil +} + +// ValidateLogpushOwnershipChallenge returns zone-level ownership challenge validation result. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-validate-ownership-challenge +func (api *API) ValidateLogpushOwnershipChallenge(ctx context.Context, rc *ResourceContainer, params ValidateLogpushOwnershipChallengeParams) (bool, error) { + uri := fmt.Sprintf("/%s/%s/logpush/ownership/validate", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return false, err + } + var r LogpushGetOwnershipChallengeResponse + err = json.Unmarshal(res, &r) + if err != nil { + return false, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result.Valid, nil +} + +// CheckLogpushDestinationExists returns zone-level destination exists check result. +// +// API reference: https://api.cloudflare.com/#logpush-jobs-check-destination-exists +func (api *API) CheckLogpushDestinationExists(ctx context.Context, rc *ResourceContainer, destinationConf string) (bool, error) { + uri := fmt.Sprintf("/%s/%s/logpush/validate/destination/exists", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, LogpushDestinationExistsRequest{ + DestinationConf: destinationConf, + }) + if err != nil { + return false, err + } + var r LogpushDestinationExistsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return false, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result.Exists, nil +} diff --git a/pkg/cloudflare-go/logpush_example_test.go b/pkg/cloudflare-go/logpush_example_test.go new file mode 100644 index 000000000..3714ce74c --- /dev/null +++ b/pkg/cloudflare-go/logpush_example_test.go @@ -0,0 +1,201 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + "github.com/goccy/go-json" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_CreateLogpushJob() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + job, err := api.CreateLogpushJob(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.CreateLogpushJobParams{ + Enabled: false, + Name: "example.com", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "s3://mybucket/logs?region=us-west-2", + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", job) +} + +func ExampleAPI_UpdateLogpushJob() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + err = api.UpdateLogpushJob(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.UpdateLogpushJobParams{ + ID: 1, + Enabled: true, + Name: "updated.com", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp", + DestinationConf: "gs://mybucket/logs", + }) + if err != nil { + log.Fatal(err) + } +} + +func ExampleAPI_ListLogpushJobs() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + jobs, err := api.ListLogpushJobs(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ListLogpushJobsParams{}) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", jobs) + for _, r := range jobs { + fmt.Printf("%+v\n", r) + } +} + +func ExampleAPI_GetLogpushJob() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + job, err := api.GetLogpushJob(context.Background(), cloudflare.ZoneIdentifier(zoneID), 1) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", job) +} + +func ExampleAPI_DeleteLogpushJob() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + err = api.DeleteLogpushJob(context.Background(), cloudflare.ZoneIdentifier(zoneID), 1) + if err != nil { + log.Fatal(err) + } +} + +func ExampleAPI_GetLogpushOwnershipChallenge() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + ownershipChallenge, err := api.GetLogpushOwnershipChallenge(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.GetLogpushOwnershipChallengeParams{DestinationConf: "destination_conf"}) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", ownershipChallenge) +} + +func ExampleAPI_ValidateLogpushOwnershipChallenge() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + isValid, err := api.ValidateLogpushOwnershipChallenge(context.Background(), cloudflare.ZoneIdentifier(zoneID), cloudflare.ValidateLogpushOwnershipChallengeParams{ + DestinationConf: "destination_conf", + OwnershipChallenge: "ownership_challenge", + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", isValid) +} + +func ExampleAPI_CheckLogpushDestinationExists() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + exists, err := api.CheckLogpushDestinationExists(context.Background(), cloudflare.ZoneIdentifier(zoneID), "destination_conf") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", exists) +} + +func ExampleLogpushJob_MarshalJSON() { + job := cloudflare.LogpushJob{ + Name: "example.com static assets", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339&CVE-2021-44228=true", + Dataset: "http_requests", + DestinationConf: "s3://?region=us-west-2/", + Filter: &cloudflare.LogpushJobFilters{ + Where: cloudflare.LogpushJobFilter{ + And: []cloudflare.LogpushJobFilter{ + {Key: "ClientRequestPath", Operator: cloudflare.Contains, Value: "/static\\"}, + {Key: "ClientRequestHost", Operator: cloudflare.Equal, Value: "example.com"}, + }, + }, + }, + } + + jobstring, err := json.Marshal(job) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%s", jobstring) + // Output: {"filter":"{\"where\":{\"and\":[{\"key\":\"ClientRequestPath\",\"operator\":\"contains\",\"value\":\"/static\\\\\"},{\"key\":\"ClientRequestHost\",\"operator\":\"eq\",\"value\":\"example.com\"}]}}","dataset":"http_requests","enabled":false,"name":"example.com static assets","logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp\u0026timestamps=rfc3339\u0026CVE-2021-44228=true","destination_conf":"s3://\u003cBUCKET_PATH\u003e?region=us-west-2/"} +} diff --git a/pkg/cloudflare-go/logpush_test.go b/pkg/cloudflare-go/logpush_test.go new file mode 100644 index 000000000..f0872c624 --- /dev/null +++ b/pkg/cloudflare-go/logpush_test.go @@ -0,0 +1,686 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strconv" + "testing" + + "github.com/goccy/go-json" + + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + jobID = 1 + serverLogpushJobDescription = `{ + "id": %d, + "dataset": "http_requests", + "kind": "", + "enabled": false, + "name": "example.com", + "logpull_options": "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf": "s3://mybucket/logs?region=us-west-2", + "last_complete": "%[2]s", + "last_error": "%[2]s", + "error_message": "test", + "frequency": "high", + "max_upload_bytes": 5000000 + } +` + serverLogpushJobWithOutputOptionsDescription = `{ + "id": %d, + "dataset": "http_requests", + "kind": "", + "enabled": false, + "name": "example.com", + "output_options": { + "field_names":[ + "RayID", + "ClientIP", + "EdgeStartTimestamp" + ], + "timestamp_format": "rfc3339" + }, + "destination_conf": "s3://mybucket/logs?region=us-west-2", + "last_complete": "%[2]s", + "last_error": "%[2]s", + "error_message": "test", + "frequency": "high", + "max_upload_bytes": 5000000 + } +` + serverEdgeLogpushJobDescription = `{ + "id": %d, + "dataset": "http_requests", + "kind": "edge", + "enabled": true, + "name": "example.com", + "logpull_options": "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf": "s3://mybucket/logs?region=us-west-2", + "last_complete": "%[2]s", + "last_error": "%[2]s", + "error_message": "test", + "frequency": "high" + } +` + serverLogpushGetOwnershipChallengeDescription = `{ + "filename": "logs/challenge-filename.txt", + "valid": true, + "message": "" + } +` + serverLogpushGetOwnershipChallengeInvalidResponseDescription = `{ + "filename": "logs/challenge-filename.txt", + "valid": false, + "message": "destination is invalid" + } +` +) + +var ( + testLogpushTimestamp = time.Now().UTC() + expectedLogpushJobStruct = LogpushJob{ + ID: jobID, + Dataset: "http_requests", + Enabled: false, + Name: "example.com", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "s3://mybucket/logs?region=us-west-2", + LastComplete: &testLogpushTimestamp, + LastError: &testLogpushTimestamp, + ErrorMessage: "test", + Frequency: "high", + MaxUploadBytes: 5000000, + } + expectedLogpushJobWithOutputOptionsStruct = LogpushJob{ + ID: jobID, + Dataset: "http_requests", + Enabled: false, + Name: "example.com", + OutputOptions: &LogpushOutputOptions{ + FieldNames: []string{ + "RayID", + "ClientIP", + "EdgeStartTimestamp", + }, + TimestampFormat: "rfc3339", + }, + DestinationConf: "s3://mybucket/logs?region=us-west-2", + LastComplete: &testLogpushTimestamp, + LastError: &testLogpushTimestamp, + ErrorMessage: "test", + Frequency: "high", + MaxUploadBytes: 5000000, + } + expectedEdgeLogpushJobStruct = LogpushJob{ + ID: jobID, + Dataset: "http_requests", + Kind: "edge", + Enabled: true, + Name: "example.com", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "s3://mybucket/logs?region=us-west-2", + LastComplete: &testLogpushTimestamp, + LastError: &testLogpushTimestamp, + ErrorMessage: "test", + Frequency: "high", + } + expectedLogpushGetOwnershipChallengeStruct = LogpushGetOwnershipChallenge{ + Filename: "logs/challenge-filename.txt", + Valid: true, + Message: "", + } +) + +func TestLogpushJobs(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 1, + "per_page": 25, + "count": 1, + "total_count": 1 + } + } + `, fmt.Sprintf(serverLogpushJobDescription, jobID, testLogpushTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/jobs", handler) + want := []LogpushJob{expectedLogpushJobStruct} + + actual, err := client.ListLogpushJobs(context.Background(), ZoneIdentifier(testZoneID), ListLogpushJobsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetLogpushJob(t *testing.T) { + testCases := map[string]struct { + result string + want LogpushJob + }{ + "core logpush job": { + result: serverLogpushJobDescription, + want: expectedLogpushJobStruct, + }, + "core logpush job with output options": { + result: serverLogpushJobWithOutputOptionsDescription, + want: expectedLogpushJobWithOutputOptionsStruct, + }, + "edge logpush job": { + result: serverEdgeLogpushJobDescription, + want: expectedEdgeLogpushJobStruct, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(tc.result, jobID, testLogpushTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/jobs/"+strconv.Itoa(jobID), handler) + + actual, err := client.GetLogpushJob(context.Background(), ZoneIdentifier(testZoneID), jobID) + if assert.NoError(t, err) { + assert.Equal(t, tc.want, actual) + } + }) + } +} + +func TestCreateLogpushJob(t *testing.T) { + testCases := map[string]struct { + newJob CreateLogpushJobParams + payload string + result string + want LogpushJob + }{ + "core logpush job": { + newJob: CreateLogpushJobParams{ + Dataset: "http_requests", + Enabled: false, + Name: "example.com", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "s3://mybucket/logs?region=us-west-2", + MaxUploadRecords: 1000, + }, + payload: `{ + "dataset": "http_requests", + "enabled":false, + "name":"example.com", + "logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf":"s3://mybucket/logs?region=us-west-2", + "max_upload_records": 1000 + }`, + result: serverLogpushJobDescription, + want: expectedLogpushJobStruct, + }, + "core logpush job with output options": { + newJob: CreateLogpushJobParams{ + Dataset: "http_requests", + Enabled: false, + Name: "example.com", + OutputOptions: &LogpushOutputOptions{ + FieldNames: []string{ + "RayID", + "ClientIP", + "EdgeStartTimestamp", + }, + TimestampFormat: "rfc3339", + }, + DestinationConf: "s3://mybucket/logs?region=us-west-2", + MaxUploadRecords: 1000, + }, + payload: `{ + "dataset": "http_requests", + "enabled":false, + "name":"example.com", + "output_options": { + "field_names":[ + "RayID", + "ClientIP", + "EdgeStartTimestamp" + ], + "timestamp_format": "rfc3339" + }, + "destination_conf":"s3://mybucket/logs?region=us-west-2", + "max_upload_records": 1000 + }`, + result: serverLogpushJobWithOutputOptionsDescription, + want: expectedLogpushJobWithOutputOptionsStruct, + }, + "edge logpush job": { + newJob: CreateLogpushJobParams{ + Dataset: "http_requests", + Enabled: true, + Name: "example.com", + Kind: "edge", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "s3://mybucket/logs?region=us-west-2", + }, + payload: `{ + "dataset": "http_requests", + "enabled":true, + "name":"example.com", + "kind":"edge", + "logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf":"s3://mybucket/logs?region=us-west-2" + }`, + result: serverEdgeLogpushJobDescription, + want: expectedEdgeLogpushJobStruct, + }, + "filtered edge logpush job": { + newJob: CreateLogpushJobParams{ + Dataset: "http_requests", + Enabled: true, + Name: "example.com", + Kind: "edge", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "s3://mybucket/logs?region=us-west-2", + Filter: &LogpushJobFilters{ + Where: LogpushJobFilter{Key: "ClientRequestHost", Operator: "eq", Value: "example.com"}, + }, + }, + payload: `{ + "dataset": "http_requests", + "enabled":true, + "name":"example.com", + "kind":"edge", + "logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf":"s3://mybucket/logs?region=us-west-2", + "filter":"{\"where\":{\"key\":\"ClientRequestHost\",\"operator\":\"eq\",\"value\":\"example.com\"}}" + }`, + result: serverEdgeLogpushJobDescription, + want: expectedEdgeLogpushJobStruct, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if assert.NoError(t, err) { + assert.JSONEq(t, tc.payload, string(b), "JSON payload not equal") + } + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(tc.result, jobID, testLogpushTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/jobs", handler) + + actual, err := client.CreateLogpushJob(context.Background(), ZoneIdentifier(testZoneID), tc.newJob) + if assert.NoError(t, err) { + assert.Equal(t, tc.want, *actual) + } + }) + } +} + +func TestUpdateLogpushJob(t *testing.T) { + setup() + defer teardown() + updatedJob := UpdateLogpushJobParams{ + ID: jobID, + Enabled: true, + Name: "updated.com", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp", + DestinationConf: "gs://mybucket/logs", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(serverLogpushJobDescription, jobID, testLogpushTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/jobs/"+strconv.Itoa(jobID), handler) + + err := client.UpdateLogpushJob(context.Background(), ZoneIdentifier(testZoneID), updatedJob) + assert.NoError(t, err) +} + +func TestDeleteLogpushJob(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": null, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/jobs/"+strconv.Itoa(jobID), handler) + + err := client.DeleteLogpushJob(context.Background(), ZoneIdentifier(testZoneID), jobID) + assert.NoError(t, err) +} + +func TestGetLogpushOwnershipChallenge(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, serverLogpushGetOwnershipChallengeDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/ownership", handler) + + want := &expectedLogpushGetOwnershipChallengeStruct + + actual, err := client.GetLogpushOwnershipChallenge(context.Background(), ZoneIdentifier(testZoneID), GetLogpushOwnershipChallengeParams{DestinationConf: "destination_conf"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetLogpushOwnershipChallengeWithInvalidResponse(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, serverLogpushGetOwnershipChallengeInvalidResponseDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/ownership", handler) + _, err := client.GetLogpushOwnershipChallenge(context.Background(), ZoneIdentifier(testZoneID), GetLogpushOwnershipChallengeParams{DestinationConf: "destination_conf"}) + + assert.Error(t, err) +} + +func TestValidateLogpushOwnershipChallenge(t *testing.T) { + testCases := map[string]struct { + isValid bool + }{ + "ownership is valid": { + isValid: true, + }, + "ownership is not valid": { + isValid: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "valid": %v + }, + "success": true, + "errors": null, + "messages": null + } + `, tc.isValid) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/ownership/validate", handler) + + actual, err := client.ValidateLogpushOwnershipChallenge(context.Background(), ZoneIdentifier(testZoneID), ValidateLogpushOwnershipChallengeParams{ + DestinationConf: "destination_conf", + OwnershipChallenge: "ownership_challenge", + }) + if assert.NoError(t, err) { + assert.Equal(t, tc.isValid, actual) + } + }) + } +} + +func TestCheckLogpushDestinationExists(t *testing.T) { + testCases := map[string]struct { + exists bool + }{ + "destination exists": { + exists: true, + }, + "destination does not exists": { + exists: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "exists": %v + }, + "success": true, + "errors": null, + "messages": null + } + `, tc.exists) + } + + mux.HandleFunc("/zones/"+testZoneID+"/logpush/validate/destination/exists", handler) + + actual, err := client.CheckLogpushDestinationExists(context.Background(), ZoneIdentifier(testZoneID), "destination_conf") + if assert.NoError(t, err) { + assert.Equal(t, tc.exists, actual) + } + }) + } +} + +var ( + validFilter LogpushJobFilter = LogpushJobFilter{Key: "ClientRequestPath", Operator: Contains, Value: "static"} +) + +var logpushJobFiltersTest = []struct { + name string + input LogpushJobFilter + haserror bool + expectedErrorMessage string +}{ + // Tests without And or Or + {"Empty Filter", LogpushJobFilter{}, true, "Key is missing"}, + {"Missing Operator", LogpushJobFilter{Key: "ClientRequestPath"}, true, "Operator is missing"}, + {"Missing Value", LogpushJobFilter{Key: "ClientRequestPath", Operator: Contains}, true, "Value is missing"}, + {"Valid Basic Filter", validFilter, false, ""}, + // Tests with And + {"Valid And Filter", LogpushJobFilter{And: []LogpushJobFilter{validFilter}}, false, ""}, + {"And and Or", LogpushJobFilter{And: []LogpushJobFilter{validFilter}, Or: []LogpushJobFilter{validFilter}}, true, "And can't be set with Or, Key, Operator or Value"}, + {"And and Key", LogpushJobFilter{And: []LogpushJobFilter{validFilter}, Key: "Key"}, true, "And can't be set with Or, Key, Operator or Value"}, + {"And and Operator", LogpushJobFilter{And: []LogpushJobFilter{validFilter}, Operator: Contains}, true, "And can't be set with Or, Key, Operator or Value"}, + {"And and Value", LogpushJobFilter{And: []LogpushJobFilter{validFilter}, Value: "Value"}, true, "And can't be set with Or, Key, Operator or Value"}, + {"And with nested error", LogpushJobFilter{And: []LogpushJobFilter{validFilter, {}}}, true, "element 1 in And is invalid: Key is missing"}, + // Tests with Or + {"Valid Or Filter", LogpushJobFilter{Or: []LogpushJobFilter{validFilter}}, false, ""}, + {"Or and Key", LogpushJobFilter{Or: []LogpushJobFilter{validFilter}, Key: "Key"}, true, "Or can't be set with And, Key, Operator or Value"}, + {"Or and Operator", LogpushJobFilter{Or: []LogpushJobFilter{validFilter}, Operator: Contains}, true, "Or can't be set with And, Key, Operator or Value"}, + {"Or and Value", LogpushJobFilter{Or: []LogpushJobFilter{validFilter}, Value: "Value"}, true, "Or can't be set with And, Key, Operator or Value"}, + {"Or with nested error", LogpushJobFilter{Or: []LogpushJobFilter{validFilter, {}}}, true, "element 1 in Or is invalid: Key is missing"}, +} + +func TestLogpushJobFilter_Validate(t *testing.T) { + for _, tt := range logpushJobFiltersTest { + t.Run(tt.name, func(t *testing.T) { + got := tt.input.Validate() + if tt.haserror { + assert.ErrorContains(t, got, tt.expectedErrorMessage) + } else { + assert.NoError(t, got) + } + }) + } +} + +func TestLogpushJob_Unmarshall(t *testing.T) { + t.Run("Valid Filter", func(t *testing.T) { + jsonstring := `{"filter":"{\"where\":{\"and\":[{\"key\":\"ClientRequestPath\",\"operator\":\"contains\",\"value\":\"/static\\\\\"},{\"key\":\"ClientRequestHost\",\"operator\":\"eq\",\"value\":\"example.com\"}]}}","dataset":"http_requests","enabled":false,"name":"example.com static assets","logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp\u0026timestamps=rfc3339\u0026CVE-2021-44228=true","destination_conf":"s3://\u003cBUCKET_PATH\u003e?region=us-west-2/"}` + var job LogpushJob + if err := json.Unmarshal([]byte(jsonstring), &job); err != nil { + log.Fatal(err) + } + + assert.Equal(t, LogpushJob{ + Name: "example.com static assets", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339&CVE-2021-44228=true", + Dataset: "http_requests", + DestinationConf: "s3://?region=us-west-2/", + Filter: &LogpushJobFilters{ + Where: LogpushJobFilter{ + And: []LogpushJobFilter{ + {Key: "ClientRequestPath", Operator: Contains, Value: "/static\\"}, + {Key: "ClientRequestHost", Operator: Equal, Value: "example.com"}, + }, + }, + }, + }, job) + }) + + t.Run("Invalid Filter", func(t *testing.T) { + jsonstring := `{"filter":"{\"where\":{\"and\":[{\"key\":\"ClientRequestPath\",\"operator\":\"contains\"},{\"key\":\"ClientRequestHost\",\"operator\":\"eq\",\"value\":\"example.com\"}]}}","dataset":"http_requests","enabled":false,"name":"example.com static assets","logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp\u0026timestamps=rfc3339\u0026CVE-2021-44228=true","destination_conf":"s3://\u003cBUCKET_PATH\u003e?region=us-west-2/"}` + var job LogpushJob + err := json.Unmarshal([]byte(jsonstring), &job) + + assert.ErrorContains(t, err, "element 0 in And is invalid: Value is missing") + }) + + t.Run("No Filter", func(t *testing.T) { + jsonstring := `{"dataset":"http_requests","enabled":false,"name":"example.com static assets","logpull_options":"fields=RayID,ClientIP,EdgeStartTimestamp\u0026timestamps=rfc3339\u0026CVE-2021-44228=true","destination_conf":"s3://\u003cBUCKET_PATH\u003e?region=us-west-2/"}` + var job LogpushJob + if err := json.Unmarshal([]byte(jsonstring), &job); err != nil { + log.Fatal(err) + } + + assert.Equal(t, LogpushJob{ + Name: "example.com static assets", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339&CVE-2021-44228=true", + Dataset: "http_requests", + DestinationConf: "s3://?region=us-west-2/", + }, job) + }) +} + +func TestLogPushJob_Marshall(t *testing.T) { + testCases := []struct { + job LogpushJob + want string + }{ + { + job: LogpushJob{ + Dataset: "http_requests", + Name: "valid filter", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "https://example.com", + Filter: &LogpushJobFilters{ + Where: LogpushJobFilter{Key: "ClientRequestHost", Operator: Equal, Value: "example.com"}, + }, + }, + want: `{ + "dataset": "http_requests", + "enabled": false, + "name": "valid filter", + "logpull_options": "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf": "https://example.com", + "filter":"{\"where\":{\"key\":\"ClientRequestHost\",\"operator\":\"eq\",\"value\":\"example.com\"}}" + }`, + }, + { + job: LogpushJob{ + Dataset: "http_requests", + Name: "no filter", + LogpullOptions: "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + DestinationConf: "https://example.com", + }, + want: `{ + "dataset": "http_requests", + "enabled": false, + "name": "no filter", + "logpull_options": "fields=RayID,ClientIP,EdgeStartTimestamp×tamps=rfc3339", + "destination_conf": "https://example.com" + }`, + }, + } + + for _, tc := range testCases { + t.Run(tc.job.Name, func(t *testing.T) { + got, err := json.Marshal(tc.job) + + if assert.NoError(t, err) { + assert.JSONEq(t, tc.want, string(got)) + } + }) + } +} diff --git a/pkg/cloudflare-go/magic_firewall_rulesets.go b/pkg/cloudflare-go/magic_firewall_rulesets.go new file mode 100644 index 000000000..1851198be --- /dev/null +++ b/pkg/cloudflare-go/magic_firewall_rulesets.go @@ -0,0 +1,206 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +const ( + // MagicFirewallRulesetKindRoot specifies a root Ruleset. + MagicFirewallRulesetKindRoot = "root" + + // MagicFirewallRulesetPhaseMagicTransit specifies the Magic Transit Ruleset phase. + MagicFirewallRulesetPhaseMagicTransit = "magic_transit" + + // MagicFirewallRulesetRuleActionSkip specifies a skip (allow) action. + MagicFirewallRulesetRuleActionSkip MagicFirewallRulesetRuleAction = "skip" + + // MagicFirewallRulesetRuleActionBlock specifies a block action. + MagicFirewallRulesetRuleActionBlock MagicFirewallRulesetRuleAction = "block" +) + +// MagicFirewallRulesetRuleAction specifies the action for a Firewall rule. +type MagicFirewallRulesetRuleAction string + +// MagicFirewallRuleset contains information about a Firewall Ruleset. +type MagicFirewallRuleset struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` + Version string `json:"version,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Phase string `json:"phase"` + Rules []MagicFirewallRulesetRule `json:"rules"` +} + +// MagicFirewallRulesetRuleActionParameters specifies the action parameters for a Firewall rule. +type MagicFirewallRulesetRuleActionParameters struct { + Ruleset string `json:"ruleset,omitempty"` +} + +// MagicFirewallRulesetRule contains information about a single Magic Firewall rule. +type MagicFirewallRulesetRule struct { + ID string `json:"id,omitempty"` + Version string `json:"version,omitempty"` + Action MagicFirewallRulesetRuleAction `json:"action"` + ActionParameters *MagicFirewallRulesetRuleActionParameters `json:"action_parameters,omitempty"` + Expression string `json:"expression"` + Description string `json:"description"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Ref string `json:"ref,omitempty"` + Enabled bool `json:"enabled"` +} + +// CreateMagicFirewallRulesetRequest contains data for a new Firewall ruleset. +type CreateMagicFirewallRulesetRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Kind string `json:"kind"` + Phase string `json:"phase"` + Rules []MagicFirewallRulesetRule `json:"rules"` +} + +// UpdateMagicFirewallRulesetRequest contains data for a Magic Firewall ruleset update. +type UpdateMagicFirewallRulesetRequest struct { + Description string `json:"description"` + Rules []MagicFirewallRulesetRule `json:"rules"` +} + +// ListMagicFirewallRulesetResponse contains a list of Magic Firewall rulesets. +type ListMagicFirewallRulesetResponse struct { + Response + Result []MagicFirewallRuleset `json:"result"` +} + +// GetMagicFirewallRulesetResponse contains a single Magic Firewall Ruleset. +type GetMagicFirewallRulesetResponse struct { + Response + Result MagicFirewallRuleset `json:"result"` +} + +// CreateMagicFirewallRulesetResponse contains response data when creating a new Magic Firewall ruleset. +type CreateMagicFirewallRulesetResponse struct { + Response + Result MagicFirewallRuleset `json:"result"` +} + +// UpdateMagicFirewallRulesetResponse contains response data when updating an existing Magic Firewall ruleset. +type UpdateMagicFirewallRulesetResponse struct { + Response + Result MagicFirewallRuleset `json:"result"` +} + +// ListMagicFirewallRulesets lists all Rulesets for a given account +// +// API reference: https://api.cloudflare.com/#rulesets-list-rulesets +// +// Deprecated: Use `ListZoneRuleset` or `ListAccountRuleset` instead. +func (api *API) ListMagicFirewallRulesets(ctx context.Context, accountID string) ([]MagicFirewallRuleset, error) { + uri := fmt.Sprintf("/accounts/%s/rulesets", accountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []MagicFirewallRuleset{}, err + } + + result := ListMagicFirewallRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicFirewallRuleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetMagicFirewallRuleset returns a specific Magic Firewall Ruleset +// +// API reference: https://api.cloudflare.com/#rulesets-get-a-ruleset +// +// Deprecated: Use `GetZoneRuleset` or `GetAccountRuleset` instead. +func (api *API) GetMagicFirewallRuleset(ctx context.Context, accountID, ID string) (MagicFirewallRuleset, error) { + uri := fmt.Sprintf("/accounts/%s/rulesets/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return MagicFirewallRuleset{}, err + } + + result := GetMagicFirewallRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicFirewallRuleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// CreateMagicFirewallRuleset creates a Magic Firewall ruleset +// +// API reference: https://api.cloudflare.com/#rulesets-list-rulesets +// +// Deprecated: Use `CreateZoneRuleset` or `CreateAccountRuleset` instead. +func (api *API) CreateMagicFirewallRuleset(ctx context.Context, accountID, name, description string, rules []MagicFirewallRulesetRule) (MagicFirewallRuleset, error) { + uri := fmt.Sprintf("/accounts/%s/rulesets", accountID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, + CreateMagicFirewallRulesetRequest{ + Name: name, + Description: description, + Kind: MagicFirewallRulesetKindRoot, + Phase: MagicFirewallRulesetPhaseMagicTransit, + Rules: rules}) + if err != nil { + return MagicFirewallRuleset{}, err + } + + result := CreateMagicFirewallRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicFirewallRuleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteMagicFirewallRuleset deletes a Magic Firewall ruleset +// +// API reference: https://api.cloudflare.com/#rulesets-delete-ruleset +// +// Deprecated: Use `DeleteZoneRuleset` or `DeleteAccountRuleset` instead. +func (api *API) DeleteMagicFirewallRuleset(ctx context.Context, accountID, ID string) error { + uri := fmt.Sprintf("/accounts/%s/rulesets/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return err + } + + // Firewall API is not implementing the standard response blob but returns an empty response (204) in case + // of a success. So we are checking for the response body size here + if len(res) > 0 { + return fmt.Errorf(errMakeRequestError+": %w", errors.New(string(res))) + } + + return nil +} + +// UpdateMagicFirewallRuleset updates a Magic Firewall ruleset +// +// API reference: https://api.cloudflare.com/#rulesets-update-ruleset +// +// Deprecated: Use `UpdateZoneRuleset` or `UpdateAccountRuleset` instead. +func (api *API) UpdateMagicFirewallRuleset(ctx context.Context, accountID, ID string, description string, rules []MagicFirewallRulesetRule) (MagicFirewallRuleset, error) { + uri := fmt.Sprintf("/accounts/%s/rulesets/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, + UpdateMagicFirewallRulesetRequest{Description: description, Rules: rules}) + if err != nil { + return MagicFirewallRuleset{}, err + } + + result := UpdateMagicFirewallRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicFirewallRuleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} diff --git a/pkg/cloudflare-go/magic_firewall_rulesets_test.go b/pkg/cloudflare-go/magic_firewall_rulesets_test.go new file mode 100644 index 000000000..990da0e0a --- /dev/null +++ b/pkg/cloudflare-go/magic_firewall_rulesets_test.go @@ -0,0 +1,318 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListMagicFirewallRulesets(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "ruleset1", + "description": "Test Firewall Ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + want := []MagicFirewallRuleset{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "ruleset1", + Description: "Test Firewall Ruleset", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: MagicFirewallRulesetPhaseMagicTransit, + }, + } + + actual, err := client.ListMagicFirewallRulesets(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetMagicFirewallRuleset(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "ruleset1", + "description": "Test Firewall Ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + rules := []MagicFirewallRulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: "1", + Action: MagicFirewallRulesetRuleActionSkip, + ActionParameters: &MagicFirewallRulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }} + + want := MagicFirewallRuleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "ruleset1", + Description: "Test Firewall Ruleset", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: MagicFirewallRulesetPhaseMagicTransit, + Rules: rules, + } + + actual, err := client.GetMagicFirewallRuleset(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMagicFirewallRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "ruleset1", + "description": "Test Firewall Ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + rules := []MagicFirewallRulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: "1", + Action: MagicFirewallRulesetRuleActionSkip, + ActionParameters: &MagicFirewallRulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }} + + want := MagicFirewallRuleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "ruleset1", + Description: "Test Firewall Ruleset", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: MagicFirewallRulesetPhaseMagicTransit, + Rules: rules, + } + + actual, err := client.CreateMagicFirewallRuleset(context.Background(), testAccountID, "ruleset1", "Test Firewall Ruleset", rules) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateMagicFirewallRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "ruleset1", + "description": "Test Firewall Ruleset Update", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + }, + { + "id": "62449e2e0de149619edb35e59c10d802", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "udp.dstport in { 32768..65535 }", + "description": "Allow UDP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + rules := []MagicFirewallRulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: "1", + Action: MagicFirewallRulesetRuleActionSkip, + ActionParameters: &MagicFirewallRulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }, { + ID: "62449e2e0de149619edb35e59c10d802", + Version: "1", + Action: MagicFirewallRulesetRuleActionSkip, + ActionParameters: &MagicFirewallRulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "udp.dstport in { 32768..65535 }", + Description: "Allow UDP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: true, + }} + + want := MagicFirewallRuleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "ruleset1", + Description: "Test Firewall Ruleset Update", + Kind: "root", + Version: "1", + LastUpdated: &lastUpdated, + Phase: MagicFirewallRulesetPhaseMagicTransit, + Rules: rules, + } + + actual, err := client.UpdateMagicFirewallRuleset(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e", "Test Firewall Ruleset Update", rules) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +// Firewall API is not implementing the standard response blob but returns an empty response (204) in case +// of a success. So we are checking for the response body size here +// TODO, This is going to be changed by MFW-63. +func TestDeleteMagicFirewallRuleset(t *testing.T) { + setup() + defer teardown() + + 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, ``) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + err := client.DeleteMagicFirewallRuleset(context.Background(), testAccountID, "2c0fc9fa937b11eaa1b71c4d701ab86e") + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/magic_transit_gre_tunnel.go b/pkg/cloudflare-go/magic_transit_gre_tunnel.go new file mode 100644 index 000000000..52e24d5f1 --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_gre_tunnel.go @@ -0,0 +1,181 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// Magic Transit GRE Tunnel Error messages. +const ( + errMagicTransitGRETunnelNotModified = "When trying to modify GRE tunnel, API returned modified: false" + errMagicTransitGRETunnelNotDeleted = "When trying to delete GRE tunnel, API returned deleted: false" +) + +// MagicTransitGRETunnel contains information about a GRE tunnel. +type MagicTransitGRETunnel struct { + ID string `json:"id,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Name string `json:"name"` + CustomerGREEndpoint string `json:"customer_gre_endpoint"` + CloudflareGREEndpoint string `json:"cloudflare_gre_endpoint"` + InterfaceAddress string `json:"interface_address"` + Description string `json:"description,omitempty"` + TTL uint8 `json:"ttl,omitempty"` + MTU uint16 `json:"mtu,omitempty"` + HealthCheck *MagicTransitGRETunnelHealthcheck `json:"health_check,omitempty"` +} + +// MagicTransitGRETunnelHealthcheck contains information about a GRE tunnel health check. +type MagicTransitGRETunnelHealthcheck struct { + Enabled bool `json:"enabled"` + Target string `json:"target,omitempty"` + Type string `json:"type,omitempty"` +} + +// ListMagicTransitGRETunnelsResponse contains a response including GRE tunnels. +type ListMagicTransitGRETunnelsResponse struct { + Response + Result struct { + GRETunnels []MagicTransitGRETunnel `json:"gre_tunnels"` + } `json:"result"` +} + +// GetMagicTransitGRETunnelResponse contains a response including zero or one GRE tunnels. +type GetMagicTransitGRETunnelResponse struct { + Response + Result struct { + GRETunnel MagicTransitGRETunnel `json:"gre_tunnel"` + } `json:"result"` +} + +// CreateMagicTransitGRETunnelsRequest is an array of GRE tunnels to create. +type CreateMagicTransitGRETunnelsRequest struct { + GRETunnels []MagicTransitGRETunnel `json:"gre_tunnels"` +} + +// UpdateMagicTransitGRETunnelResponse contains a response after updating a GRE Tunnel. +type UpdateMagicTransitGRETunnelResponse struct { + Response + Result struct { + Modified bool `json:"modified"` + ModifiedGRETunnel MagicTransitGRETunnel `json:"modified_gre_tunnel"` + } `json:"result"` +} + +// DeleteMagicTransitGRETunnelResponse contains a response after deleting a GRE Tunnel. +type DeleteMagicTransitGRETunnelResponse struct { + Response + Result struct { + Deleted bool `json:"deleted"` + DeletedGRETunnel MagicTransitGRETunnel `json:"deleted_gre_tunnel"` + } `json:"result"` +} + +// ListMagicTransitGRETunnels lists all GRE tunnels for a given account. +// +// API reference: https://api.cloudflare.com/#magic-gre-tunnels-list-gre-tunnels +func (api *API) ListMagicTransitGRETunnels(ctx context.Context, accountID string) ([]MagicTransitGRETunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/gre_tunnels", accountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []MagicTransitGRETunnel{}, err + } + + result := ListMagicTransitGRETunnelsResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicTransitGRETunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.GRETunnels, nil +} + +// GetMagicTransitGRETunnel returns zero or one GRE tunnel. +// +// API reference: https://api.cloudflare.com/#magic-gre-tunnels-gre-tunnel-details +func (api *API) GetMagicTransitGRETunnel(ctx context.Context, accountID string, id string) (MagicTransitGRETunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/gre_tunnels/%s", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return MagicTransitGRETunnel{}, err + } + + result := GetMagicTransitGRETunnelResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitGRETunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.GRETunnel, nil +} + +// CreateMagicTransitGRETunnels creates one or more GRE tunnels. +// +// API reference: https://api.cloudflare.com/#magic-gre-tunnels-create-gre-tunnels +func (api *API) CreateMagicTransitGRETunnels(ctx context.Context, accountID string, tunnels []MagicTransitGRETunnel) ([]MagicTransitGRETunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/gre_tunnels", accountID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, CreateMagicTransitGRETunnelsRequest{ + GRETunnels: tunnels, + }) + + if err != nil { + return []MagicTransitGRETunnel{}, err + } + + result := ListMagicTransitGRETunnelsResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicTransitGRETunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.GRETunnels, nil +} + +// UpdateMagicTransitGRETunnel updates a GRE tunnel. +// +// API reference: https://api.cloudflare.com/#magic-gre-tunnels-update-gre-tunnel +func (api *API) UpdateMagicTransitGRETunnel(ctx context.Context, accountID string, id string, tunnel MagicTransitGRETunnel) (MagicTransitGRETunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/gre_tunnels/%s", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, tunnel) + + if err != nil { + return MagicTransitGRETunnel{}, err + } + + result := UpdateMagicTransitGRETunnelResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitGRETunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !result.Result.Modified { + return MagicTransitGRETunnel{}, errors.New(errMagicTransitGRETunnelNotModified) + } + + return result.Result.ModifiedGRETunnel, nil +} + +// DeleteMagicTransitGRETunnel deletes a GRE tunnel. +// +// API reference: https://api.cloudflare.com/#magic-gre-tunnels-delete-gre-tunnel +func (api *API) DeleteMagicTransitGRETunnel(ctx context.Context, accountID string, id string) (MagicTransitGRETunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/gre_tunnels/%s", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return MagicTransitGRETunnel{}, err + } + + result := DeleteMagicTransitGRETunnelResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitGRETunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !result.Result.Deleted { + return MagicTransitGRETunnel{}, errors.New(errMagicTransitGRETunnelNotDeleted) + } + + return result.Result.DeletedGRETunnel, nil +} diff --git a/pkg/cloudflare-go/magic_transit_gre_tunnel_test.go b/pkg/cloudflare-go/magic_transit_gre_tunnel_test.go new file mode 100644 index 000000000..412b6738a --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_gre_tunnel_test.go @@ -0,0 +1,331 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListMagicTransitGRETunnels(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "gre_tunnels": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "GRE_1", + "customer_gre_endpoint": "203.0.113.1", + "cloudflare_gre_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "ttl": 64, + "mtu": 1476, + "health_check": { + "enabled": true, + "target": "203.0.113.1", + "type": "request" + } + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/gre_tunnels", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitGRETunnel{ + { + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "GRE_1", + CustomerGREEndpoint: "203.0.113.1", + CloudflareGREEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + TTL: 64, + MTU: 1476, + HealthCheck: &MagicTransitGRETunnelHealthcheck{ + Enabled: true, + Target: "203.0.113.1", + Type: "request", + }, + }, + } + + actual, err := client.ListMagicTransitGRETunnels(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetMagicTransitGRETunnel(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "gre_tunnel": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "GRE_1", + "customer_gre_endpoint": "203.0.113.1", + "cloudflare_gre_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "ttl": 64, + "mtu": 1476, + "health_check": { + "enabled": true, + "target": "203.0.113.1", + "type": "request" + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/gre_tunnels/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitGRETunnel{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "GRE_1", + CustomerGREEndpoint: "203.0.113.1", + CloudflareGREEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + TTL: 64, + MTU: 1476, + HealthCheck: &MagicTransitGRETunnelHealthcheck{ + Enabled: true, + Target: "203.0.113.1", + Type: "request", + }, + } + + actual, err := client.GetMagicTransitGRETunnel(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMagicTransitGRETunnels(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "gre_tunnels": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "GRE_1", + "customer_gre_endpoint": "203.0.113.1", + "cloudflare_gre_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "ttl": 64, + "mtu": 1476, + "health_check": { + "enabled": true, + "target": "203.0.113.1", + "type": "request" + } + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/gre_tunnels", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitGRETunnel{ + { + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "GRE_1", + CustomerGREEndpoint: "203.0.113.1", + CloudflareGREEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + TTL: 64, + MTU: 1476, + HealthCheck: &MagicTransitGRETunnelHealthcheck{ + Enabled: true, + Target: "203.0.113.1", + Type: "request", + }, + }, + } + + actual, err := client.CreateMagicTransitGRETunnels(context.Background(), testAccountID, want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateMagicTransitGRETunnel(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "modified": true, + "modified_gre_tunnel": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "GRE_1", + "customer_gre_endpoint": "203.0.113.1", + "cloudflare_gre_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "ttl": 64, + "mtu": 1476, + "health_check": { + "enabled": true, + "target": "203.0.113.1", + "type": "request" + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/gre_tunnels/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitGRETunnel{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "GRE_1", + CustomerGREEndpoint: "203.0.113.1", + CloudflareGREEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + TTL: 64, + MTU: 1476, + HealthCheck: &MagicTransitGRETunnelHealthcheck{ + Enabled: true, + Target: "203.0.113.1", + Type: "request", + }, + } + + actual, err := client.UpdateMagicTransitGRETunnel(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteMagicTransitGRETunnel(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "deleted": true, + "deleted_gre_tunnel": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "GRE_1", + "customer_gre_endpoint": "203.0.113.1", + "cloudflare_gre_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "ttl": 64, + "mtu": 1476, + "health_check": { + "enabled": true, + "target": "203.0.113.1", + "type": "request" + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/gre_tunnels/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitGRETunnel{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "GRE_1", + CustomerGREEndpoint: "203.0.113.1", + CloudflareGREEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + TTL: 64, + MTU: 1476, + HealthCheck: &MagicTransitGRETunnelHealthcheck{ + Enabled: true, + Target: "203.0.113.1", + Type: "request", + }, + } + + actual, err := client.DeleteMagicTransitGRETunnel(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/magic_transit_ipsec_tunnel.go b/pkg/cloudflare-go/magic_transit_ipsec_tunnel.go new file mode 100644 index 000000000..6b13fe129 --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_ipsec_tunnel.go @@ -0,0 +1,216 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// Magic Transit IPsec Tunnel Error messages. +const ( + errMagicTransitIPsecTunnelNotModified = "When trying to modify IPsec tunnel, API returned modified: false" + errMagicTransitIPsecTunnelNotDeleted = "When trying to delete IPsec tunnel, API returned deleted: false" +) + +type RemoteIdentities struct { + HexID string `json:"hex_id"` + FQDNID string `json:"fqdn_id"` + UserID string `json:"user_id"` +} + +// MagicTransitIPsecTunnelPskMetadata contains metadata associated with PSK. +type MagicTransitIPsecTunnelPskMetadata struct { + LastGeneratedOn *time.Time `json:"last_generated_on,omitempty"` +} + +// MagicTransitIPsecTunnel contains information about an IPsec tunnel. +type MagicTransitIPsecTunnel struct { + ID string `json:"id,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Name string `json:"name"` + CustomerEndpoint string `json:"customer_endpoint,omitempty"` + CloudflareEndpoint string `json:"cloudflare_endpoint"` + InterfaceAddress string `json:"interface_address"` + Description string `json:"description,omitempty"` + HealthCheck *MagicTransitTunnelHealthcheck `json:"health_check,omitempty"` + Psk string `json:"psk,omitempty"` + PskMetadata *MagicTransitIPsecTunnelPskMetadata `json:"psk_metadata,omitempty"` + RemoteIdentities *RemoteIdentities `json:"remote_identities,omitempty"` + AllowNullCipher bool `json:"allow_null_cipher"` + ReplayProtection *bool `json:"replay_protection,omitempty"` +} + +// ListMagicTransitIPsecTunnelsResponse contains a response including IPsec tunnels. +type ListMagicTransitIPsecTunnelsResponse struct { + Response + Result struct { + IPsecTunnels []MagicTransitIPsecTunnel `json:"ipsec_tunnels"` + } `json:"result"` +} + +// GetMagicTransitIPsecTunnelResponse contains a response including zero or one IPsec tunnels. +type GetMagicTransitIPsecTunnelResponse struct { + Response + Result struct { + IPsecTunnel MagicTransitIPsecTunnel `json:"ipsec_tunnel"` + } `json:"result"` +} + +// CreateMagicTransitIPsecTunnelsRequest is an array of IPsec tunnels to create. +type CreateMagicTransitIPsecTunnelsRequest struct { + IPsecTunnels []MagicTransitIPsecTunnel `json:"ipsec_tunnels"` +} + +// UpdateMagicTransitIPsecTunnelResponse contains a response after updating an IPsec Tunnel. +type UpdateMagicTransitIPsecTunnelResponse struct { + Response + Result struct { + Modified bool `json:"modified"` + ModifiedIPsecTunnel MagicTransitIPsecTunnel `json:"modified_ipsec_tunnel"` + } `json:"result"` +} + +// DeleteMagicTransitIPsecTunnelResponse contains a response after deleting an IPsec Tunnel. +type DeleteMagicTransitIPsecTunnelResponse struct { + Response + Result struct { + Deleted bool `json:"deleted"` + DeletedIPsecTunnel MagicTransitIPsecTunnel `json:"deleted_ipsec_tunnel"` + } `json:"result"` +} + +// GenerateMagicTransitIPsecTunnelPSKResponse contains a response after generating IPsec Tunnel. +type GenerateMagicTransitIPsecTunnelPSKResponse struct { + Response + Result struct { + Psk string `json:"psk"` + PskMetadata *MagicTransitIPsecTunnelPskMetadata `json:"psk_metadata"` + } `json:"result"` +} + +// ListMagicTransitIPsecTunnels lists all IPsec tunnels for a given account +// +// API reference: https://api.cloudflare.com/#magic-ipsec-tunnels-list-ipsec-tunnels +func (api *API) ListMagicTransitIPsecTunnels(ctx context.Context, accountID string) ([]MagicTransitIPsecTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/ipsec_tunnels", accountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []MagicTransitIPsecTunnel{}, err + } + + result := ListMagicTransitIPsecTunnelsResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicTransitIPsecTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.IPsecTunnels, nil +} + +// GetMagicTransitIPsecTunnel returns zero or one IPsec tunnel +// +// API reference: https://api.cloudflare.com/#magic-ipsec-tunnels-ipsec-tunnel-details +func (api *API) GetMagicTransitIPsecTunnel(ctx context.Context, accountID string, id string) (MagicTransitIPsecTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/ipsec_tunnels/%s", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return MagicTransitIPsecTunnel{}, err + } + + result := GetMagicTransitIPsecTunnelResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitIPsecTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.IPsecTunnel, nil +} + +// CreateMagicTransitIPsecTunnels creates one or more IPsec tunnels +// +// API reference: https://api.cloudflare.com/#magic-ipsec-tunnels-create-ipsec-tunnels +func (api *API) CreateMagicTransitIPsecTunnels(ctx context.Context, accountID string, tunnels []MagicTransitIPsecTunnel) ([]MagicTransitIPsecTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/ipsec_tunnels", accountID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, CreateMagicTransitIPsecTunnelsRequest{ + IPsecTunnels: tunnels, + }) + + if err != nil { + return []MagicTransitIPsecTunnel{}, err + } + + result := ListMagicTransitIPsecTunnelsResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicTransitIPsecTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.IPsecTunnels, nil +} + +// UpdateMagicTransitIPsecTunnel updates an IPsec tunnel +// +// API reference: https://api.cloudflare.com/#magic-ipsec-tunnels-update-ipsec-tunnel +func (api *API) UpdateMagicTransitIPsecTunnel(ctx context.Context, accountID string, id string, tunnel MagicTransitIPsecTunnel) (MagicTransitIPsecTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/ipsec_tunnels/%s", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, tunnel) + + if err != nil { + return MagicTransitIPsecTunnel{}, err + } + + result := UpdateMagicTransitIPsecTunnelResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitIPsecTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !result.Result.Modified { + return MagicTransitIPsecTunnel{}, errors.New(errMagicTransitIPsecTunnelNotModified) + } + + return result.Result.ModifiedIPsecTunnel, nil +} + +// DeleteMagicTransitIPsecTunnel deletes an IPsec Tunnel +// +// API reference: https://api.cloudflare.com/#magic-ipsec-tunnels-delete-ipsec-tunnel +func (api *API) DeleteMagicTransitIPsecTunnel(ctx context.Context, accountID string, id string) (MagicTransitIPsecTunnel, error) { + uri := fmt.Sprintf("/accounts/%s/magic/ipsec_tunnels/%s", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return MagicTransitIPsecTunnel{}, err + } + + result := DeleteMagicTransitIPsecTunnelResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitIPsecTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !result.Result.Deleted { + return MagicTransitIPsecTunnel{}, errors.New(errMagicTransitIPsecTunnelNotDeleted) + } + + return result.Result.DeletedIPsecTunnel, nil +} + +// GenerateMagicTransitIPsecTunnelPSK generates a pre shared key (psk) for an IPsec tunnel +// +// API reference: https://api.cloudflare.com/#magic-ipsec-tunnels-generate-pre-shared-key-psk-for-ipsec-tunnels +func (api *API) GenerateMagicTransitIPsecTunnelPSK(ctx context.Context, accountID string, id string) (string, *MagicTransitIPsecTunnelPskMetadata, error) { + uri := fmt.Sprintf("/accounts/%s/magic/ipsec_tunnels/%s/psk_generate", accountID, id) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + + if err != nil { + return "", nil, err + } + + result := GenerateMagicTransitIPsecTunnelPSKResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return "", nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.Psk, result.Result.PskMetadata, nil +} diff --git a/pkg/cloudflare-go/magic_transit_ipsec_tunnel_test.go b/pkg/cloudflare-go/magic_transit_ipsec_tunnel_test.go new file mode 100644 index 000000000..ed93007d8 --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_ipsec_tunnel_test.go @@ -0,0 +1,418 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListMagicTransitIPsecTunnels(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ipsec_tunnels": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "replay_protection": true + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitIPsecTunnel{ + { + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + ReplayProtection: BoolPtr(true), + }, + } + + actual, err := client.ListMagicTransitIPsecTunnels(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetMagicTransitIPsecTunnel(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ipsec_tunnel": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "allow_null_cipher": true, + "replay_protection": true + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitIPsecTunnel{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + AllowNullCipher: true, + ReplayProtection: BoolPtr(true), + } + + actual, err := client.GetMagicTransitIPsecTunnel(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMagicTransitIPsecTunnels(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ipsec_tunnels": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X" + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitIPsecTunnel{{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + }} + + actual, err := client.CreateMagicTransitIPsecTunnels(context.Background(), testAccountID, want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMagicTransitIPsecTunnelsWithHealthcheck(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ipsec_tunnels": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "health_check": { + "enabled": true, + "type": "reply", + "rate": "mid", + "direction": "bidirectional" + } + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitIPsecTunnel{{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + HealthCheck: &MagicTransitTunnelHealthcheck{ + Enabled: true, + Type: "reply", + Rate: "mid", + Direction: "bidirectional", + }, + }} + + actual, err := client.CreateMagicTransitIPsecTunnels(context.Background(), testAccountID, want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMagicTransitIPsecTunnelsWithReplayProtection(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ipsec_tunnels": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "replay_protection": true + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitIPsecTunnel{{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + ReplayProtection: BoolPtr(true), + }} + + actual, err := client.CreateMagicTransitIPsecTunnels(context.Background(), testAccountID, want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateMagicTransitIPsecTunnel(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "modified": true, + "modified_ipsec_tunnel": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X", + "allow_null_cipher": true + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitIPsecTunnel{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + AllowNullCipher: true, + } + + actual, err := client.UpdateMagicTransitIPsecTunnel(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteMagicTransitIPsecTunnel(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "deleted": true, + "deleted_ipsec_tunnel": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "name": "IPsec_1", + "customer_endpoint": "203.0.113.1", + "cloudflare_endpoint": "203.0.113.2", + "interface_address": "192.0.2.0/31", + "description": "Tunnel for ISP X" + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitIPsecTunnel{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Name: "IPsec_1", + CustomerEndpoint: "203.0.113.1", + CloudflareEndpoint: "203.0.113.2", + InterfaceAddress: "192.0.2.0/31", + Description: "Tunnel for ISP X", + } + + actual, err := client.DeleteMagicTransitIPsecTunnel(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestMagicTransitIPsecTunnelGeneratePSK(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "psk": "itworks", + "psk_metadata": { + "last_generated_on": "2017-06-14T05:20:00Z" + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/ipsec_tunnels/c4a7362d577a6c3019a474fd6f485821/psk_generate", handler) + + lastGeneratedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitIPsecTunnelPskMetadata{ + LastGeneratedOn: &lastGeneratedOn, + } + + want_psk := "itworks" + + psk, actual, err := client.GenerateMagicTransitIPsecTunnelPSK(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, *actual) + assert.Equal(t, want_psk, psk) + } +} diff --git a/pkg/cloudflare-go/magic_transit_static_routes.go b/pkg/cloudflare-go/magic_transit_static_routes.go new file mode 100644 index 000000000..3554a9d16 --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_static_routes.go @@ -0,0 +1,180 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// Magic Transit Static Routes Error messages. +const ( + errMagicTransitStaticRouteNotModified = "When trying to modify static route, API returned modified: false" + errMagicTransitStaticRouteNotDeleted = "When trying to delete static route, API returned deleted: false" +) + +// MagicTransitStaticRouteScope contains information about a static route's scope. +type MagicTransitStaticRouteScope struct { + ColoRegions []string `json:"colo_regions,omitempty"` + ColoNames []string `json:"colo_names,omitempty"` +} + +// MagicTransitStaticRoute contains information about a static route. +type MagicTransitStaticRoute struct { + ID string `json:"id,omitempty"` + Prefix string `json:"prefix"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Nexthop string `json:"nexthop"` + Priority int `json:"priority,omitempty"` + Description string `json:"description,omitempty"` + Weight int `json:"weight,omitempty"` + Scope MagicTransitStaticRouteScope `json:"scope,omitempty"` +} + +// ListMagicTransitStaticRoutesResponse contains a response including Magic Transit static routes. +type ListMagicTransitStaticRoutesResponse struct { + Response + Result struct { + Routes []MagicTransitStaticRoute `json:"routes"` + } `json:"result"` +} + +// GetMagicTransitStaticRouteResponse contains a response including exactly one static route. +type GetMagicTransitStaticRouteResponse struct { + Response + Result struct { + Route MagicTransitStaticRoute `json:"route"` + } `json:"result"` +} + +// UpdateMagicTransitStaticRouteResponse contains a static route update response. +type UpdateMagicTransitStaticRouteResponse struct { + Response + Result struct { + Modified bool `json:"modified"` + ModifiedRoute MagicTransitStaticRoute `json:"modified_route"` + } `json:"result"` +} + +// DeleteMagicTransitStaticRouteResponse contains a static route deletion response. +type DeleteMagicTransitStaticRouteResponse struct { + Response + Result struct { + Deleted bool `json:"deleted"` + DeletedRoute MagicTransitStaticRoute `json:"deleted_route"` + } `json:"result"` +} + +// CreateMagicTransitStaticRoutesRequest is an array of static routes to create. +type CreateMagicTransitStaticRoutesRequest struct { + Routes []MagicTransitStaticRoute `json:"routes"` +} + +// ListMagicTransitStaticRoutes lists all static routes for a given account +// +// API reference: https://api.cloudflare.com/#magic-transit-static-routes-list-routes +func (api *API) ListMagicTransitStaticRoutes(ctx context.Context, accountID string) ([]MagicTransitStaticRoute, error) { + uri := fmt.Sprintf("/accounts/%s/magic/routes", accountID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []MagicTransitStaticRoute{}, err + } + + result := ListMagicTransitStaticRoutesResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicTransitStaticRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.Routes, nil +} + +// GetMagicTransitStaticRoute returns exactly one static route +// +// API reference: https://api.cloudflare.com/#magic-transit-static-routes-route-details +func (api *API) GetMagicTransitStaticRoute(ctx context.Context, accountID, ID string) (MagicTransitStaticRoute, error) { + uri := fmt.Sprintf("/accounts/%s/magic/routes/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return MagicTransitStaticRoute{}, err + } + + result := GetMagicTransitStaticRouteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitStaticRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.Route, nil +} + +// CreateMagicTransitStaticRoute creates a new static route +// +// API reference: https://api.cloudflare.com/#magic-transit-static-routes-create-routes +func (api *API) CreateMagicTransitStaticRoute(ctx context.Context, accountID string, route MagicTransitStaticRoute) ([]MagicTransitStaticRoute, error) { + uri := fmt.Sprintf("/accounts/%s/magic/routes", accountID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, CreateMagicTransitStaticRoutesRequest{ + Routes: []MagicTransitStaticRoute{ + route, + }, + }) + + if err != nil { + return []MagicTransitStaticRoute{}, err + } + + result := ListMagicTransitStaticRoutesResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []MagicTransitStaticRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.Routes, nil +} + +// UpdateMagicTransitStaticRoute updates a static route +// +// API reference: https://api.cloudflare.com/#magic-transit-static-routes-update-route +func (api *API) UpdateMagicTransitStaticRoute(ctx context.Context, accountID, ID string, route MagicTransitStaticRoute) (MagicTransitStaticRoute, error) { + uri := fmt.Sprintf("/accounts/%s/magic/routes/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, route) + + if err != nil { + return MagicTransitStaticRoute{}, err + } + + result := UpdateMagicTransitStaticRouteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitStaticRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !result.Result.Modified { + return MagicTransitStaticRoute{}, errors.New(errMagicTransitStaticRouteNotModified) + } + + return result.Result.ModifiedRoute, nil +} + +// DeleteMagicTransitStaticRoute deletes a static route +// +// API reference: https://api.cloudflare.com/#magic-transit-static-routes-delete-route +func (api *API) DeleteMagicTransitStaticRoute(ctx context.Context, accountID, ID string) (MagicTransitStaticRoute, error) { + uri := fmt.Sprintf("/accounts/%s/magic/routes/%s", accountID, ID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return MagicTransitStaticRoute{}, err + } + + result := DeleteMagicTransitStaticRouteResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return MagicTransitStaticRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !result.Result.Deleted { + return MagicTransitStaticRoute{}, errors.New(errMagicTransitStaticRouteNotDeleted) + } + + return result.Result.DeletedRoute, nil +} diff --git a/pkg/cloudflare-go/magic_transit_static_routes_test.go b/pkg/cloudflare-go/magic_transit_static_routes_test.go new file mode 100644 index 000000000..5c8b0a090 --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_static_routes_test.go @@ -0,0 +1,341 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListMagicTransitStaticRoutes(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "routes": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "prefix": "192.0.2.0/24", + "nexthop": "203.0.113.1", + "priority": 100, + "description": "New route for new prefix 203.0.113.1", + "weight": 100, + "scope": { + "colo_regions": [ + "APAC" + ], + "colo_names": [ + "den01" + ] + } + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/routes", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := []MagicTransitStaticRoute{ + { + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Prefix: "192.0.2.0/24", + Nexthop: "203.0.113.1", + Priority: 100, + Description: "New route for new prefix 203.0.113.1", + Weight: 100, + Scope: MagicTransitStaticRouteScope{ + ColoRegions: []string{ + "APAC", + }, + ColoNames: []string{ + "den01", + }, + }, + }, + } + + actual, err := client.ListMagicTransitStaticRoutes(context.Background(), testAccountID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetMagicTransitStaticRoute(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "route": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "prefix": "192.0.2.0/24", + "nexthop": "203.0.113.1", + "priority": 100, + "description": "New route for new prefix 203.0.113.1", + "weight": 100, + "scope": { + "colo_regions": [ + "APAC" + ], + "colo_names": [ + "den01" + ] + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/routes/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitStaticRoute{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Prefix: "192.0.2.0/24", + Nexthop: "203.0.113.1", + Priority: 100, + Description: "New route for new prefix 203.0.113.1", + Weight: 100, + Scope: MagicTransitStaticRouteScope{ + ColoRegions: []string{ + "APAC", + }, + ColoNames: []string{ + "den01", + }, + }, + } + + actual, err := client.GetMagicTransitStaticRoute(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateMagicTransitStaticRoutes(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "routes": [ + { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "prefix": "192.0.2.0/24", + "nexthop": "203.0.113.1", + "priority": 100, + "description": "New route for new prefix 203.0.113.1", + "weight": 100, + "scope": { + "colo_regions": [ + "APAC" + ], + "colo_names": [ + "den01" + ] + } + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/routes", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitStaticRoute{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Prefix: "192.0.2.0/24", + Nexthop: "203.0.113.1", + Priority: 100, + Description: "New route for new prefix 203.0.113.1", + Weight: 100, + Scope: MagicTransitStaticRouteScope{ + ColoRegions: []string{ + "APAC", + }, + ColoNames: []string{ + "den01", + }, + }, + } + + actual, err := client.CreateMagicTransitStaticRoute(context.Background(), testAccountID, want) + if assert.NoError(t, err) { + assert.Equal(t, []MagicTransitStaticRoute{ + want, + }, actual) + } +} + +func TestUpdateMagicTransitStaticRoute(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "modified": true, + "modified_route": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "prefix": "192.0.2.0/24", + "nexthop": "203.0.113.1", + "priority": 100, + "description": "New route for new prefix 203.0.113.1", + "weight": 100, + "scope": { + "colo_regions": [ + "APAC" + ], + "colo_names": [ + "den01" + ] + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/routes/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitStaticRoute{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Prefix: "192.0.2.0/24", + Nexthop: "203.0.113.1", + Priority: 100, + Description: "New route for new prefix 203.0.113.1", + Weight: 100, + Scope: MagicTransitStaticRouteScope{ + ColoRegions: []string{ + "APAC", + }, + ColoNames: []string{ + "den01", + }, + }, + } + + actual, err := client.UpdateMagicTransitStaticRoute(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteMagicTransitStaticRoute(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "deleted": true, + "deleted_route": { + "id": "c4a7362d577a6c3019a474fd6f485821", + "created_on": "2017-06-14T00:00:00Z", + "modified_on": "2017-06-14T05:20:00Z", + "prefix": "192.0.2.0/24", + "nexthop": "203.0.113.1", + "priority": 100, + "description": "New route for new prefix 203.0.113.1", + "weight": 100, + "scope": { + "colo_regions": [ + "APAC" + ], + "colo_names": [ + "den01" + ] + } + } + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/magic/routes/c4a7362d577a6c3019a474fd6f485821", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2017-06-14T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-06-14T05:20:00Z") + + want := MagicTransitStaticRoute{ + ID: "c4a7362d577a6c3019a474fd6f485821", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Prefix: "192.0.2.0/24", + Nexthop: "203.0.113.1", + Priority: 100, + Description: "New route for new prefix 203.0.113.1", + Weight: 100, + Scope: MagicTransitStaticRouteScope{ + ColoRegions: []string{ + "APAC", + }, + ColoNames: []string{ + "den01", + }, + }, + } + + actual, err := client.DeleteMagicTransitStaticRoute(context.Background(), testAccountID, "c4a7362d577a6c3019a474fd6f485821") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/magic_transit_tunnel_healthcheck.go b/pkg/cloudflare-go/magic_transit_tunnel_healthcheck.go new file mode 100644 index 000000000..ced8a40f4 --- /dev/null +++ b/pkg/cloudflare-go/magic_transit_tunnel_healthcheck.go @@ -0,0 +1,10 @@ +package cloudflare + +// MagicTransitTunnelHealthcheck contains information about a tunnel health check. +type MagicTransitTunnelHealthcheck struct { + Enabled bool `json:"enabled"` + Target string `json:"target,omitempty"` + Type string `json:"type,omitempty"` + Rate string `json:"rate,omitempty"` + Direction string `json:"direction,omitempty"` +} diff --git a/pkg/cloudflare-go/managed_headers.go b/pkg/cloudflare-go/managed_headers.go new file mode 100644 index 000000000..54de0dd8e --- /dev/null +++ b/pkg/cloudflare-go/managed_headers.go @@ -0,0 +1,78 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type ListManagedHeadersResponse struct { + Response + Result ManagedHeaders `json:"result"` +} + +type UpdateManagedHeadersParams struct { + ManagedHeaders +} + +type ManagedHeaders struct { + ManagedRequestHeaders []ManagedHeader `json:"managed_request_headers"` + ManagedResponseHeaders []ManagedHeader `json:"managed_response_headers"` +} + +type ManagedHeader struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + HasCoflict bool `json:"has_conflict,omitempty"` + ConflictsWith []string `json:"conflicts_with,omitempty"` +} + +type ListManagedHeadersParams struct { + Status string `url:"status,omitempty"` +} + +func (api *API) ListZoneManagedHeaders(ctx context.Context, rc *ResourceContainer, params ListManagedHeadersParams) (ManagedHeaders, error) { + if rc.Identifier == "" { + return ManagedHeaders{}, ErrMissingZoneID + } + + uri := buildURI(fmt.Sprintf("/zones/%s/managed_headers", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ManagedHeaders{}, err + } + + result := ListManagedHeadersResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ManagedHeaders{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +func (api *API) UpdateZoneManagedHeaders(ctx context.Context, rc *ResourceContainer, params UpdateManagedHeadersParams) (ManagedHeaders, error) { + if rc.Identifier == "" { + return ManagedHeaders{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/managed_headers", rc.Identifier) + + payload, err := json.Marshal(params.ManagedHeaders) + if err != nil { + return ManagedHeaders{}, err + } + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, payload) + if err != nil { + return ManagedHeaders{}, err + } + + result := ListManagedHeadersResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return ManagedHeaders{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} diff --git a/pkg/cloudflare-go/managed_headers_test.go b/pkg/cloudflare-go/managed_headers_test.go new file mode 100644 index 000000000..a1c202ed7 --- /dev/null +++ b/pkg/cloudflare-go/managed_headers_test.go @@ -0,0 +1,234 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListManagedHeaders(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "managed_request_headers": [ + { + "id": "add_true_client_ip_headers", + "enabled": false, + "has_conflict": false, + "conflicts_with": ["remove_visitor_ip_headers"] + }, + { + "id": "add_visitor_location_headers", + "enabled": true, + "has_conflict": false + } + ], + "managed_response_headers": [ + { + "id": "add_security_headers", + "enabled": false, + "has_conflict": false + }, + { + "id": "remove_x-powered-by_header", + "enabled": true, + "has_conflict": false + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + mux.HandleFunc("/zones/"+testZoneID+"/managed_headers", handler) + + want := ManagedHeaders{ + ManagedRequestHeaders: []ManagedHeader{ + { + ID: "add_true_client_ip_headers", + Enabled: false, + HasCoflict: false, + ConflictsWith: []string{"remove_visitor_ip_headers"}, + }, + { + ID: "add_visitor_location_headers", + Enabled: true, + HasCoflict: false, + }, + }, + ManagedResponseHeaders: []ManagedHeader{ + { + ID: "add_security_headers", + Enabled: false, + HasCoflict: false, + }, + { + ID: "remove_x-powered-by_header", + Enabled: true, + HasCoflict: false, + }, + }, + } + + zoneActual, err := client.ListZoneManagedHeaders(context.Background(), ZoneIdentifier(testZoneID), ListManagedHeadersParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } +} + +func TestFilterManagedHeaders(t *testing.T) { + setup() + defer teardown() + + 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, r.URL.Query().Get("status"), "enabled") + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "managed_request_headers": [ + { + "id": "add_visitor_location_headers", + "enabled": true, + "has_conflict": false + } + ], + "managed_response_headers": [ + { + "id": "remove_x-powered-by_header", + "enabled": true, + "has_conflict": false + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + mux.HandleFunc("/zones/"+testZoneID+"/managed_headers", handler) + + want := ManagedHeaders{ + ManagedRequestHeaders: []ManagedHeader{ + { + ID: "add_visitor_location_headers", + Enabled: true, + HasCoflict: false, + }, + }, + ManagedResponseHeaders: []ManagedHeader{ + { + ID: "remove_x-powered-by_header", + Enabled: true, + HasCoflict: false, + }, + }, + } + + zoneActual, err := client.ListZoneManagedHeaders(context.Background(), ZoneIdentifier(testZoneID), ListManagedHeadersParams{ + Status: "enabled", + }) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } +} + +func TestUpdateManagedHeaders(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "managed_request_headers": [ + { + "id": "add_true_client_ip_headers", + "enabled": true, + "has_conflict": false, + "conflicts_with": ["remove_visitor_ip_headers"] + }, + { + "id": "add_visitor_location_headers", + "enabled": true, + "has_conflict": false + } + ], + "managed_response_headers": [ + { + "id": "add_security_headers", + "enabled": false, + "has_conflict": false + }, + { + "id": "remove_x-powered-by_header", + "enabled": false, + "has_conflict": false + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/managed_headers", handler) + managedHeadersForUpdate := ManagedHeaders{ + ManagedRequestHeaders: []ManagedHeader{ + { + ID: "add_visitor_location_headers", + Enabled: true, + }, + }, + ManagedResponseHeaders: []ManagedHeader{ + { + ID: "remove_x-powered-by_header", + Enabled: false, + }, + }, + } + want := ManagedHeaders{ + ManagedRequestHeaders: []ManagedHeader{ + { + ID: "add_true_client_ip_headers", + Enabled: true, + HasCoflict: false, + ConflictsWith: []string{"remove_visitor_ip_headers"}, + }, + { + ID: "add_visitor_location_headers", + Enabled: true, + HasCoflict: false, + }, + }, + ManagedResponseHeaders: []ManagedHeader{ + { + ID: "add_security_headers", + Enabled: false, + HasCoflict: false, + }, + { + ID: "remove_x-powered-by_header", + Enabled: false, + HasCoflict: false, + }, + }, + } + zoneActual, err := client.UpdateZoneManagedHeaders(context.Background(), ZoneIdentifier(testZoneID), UpdateManagedHeadersParams{ + ManagedHeaders: managedHeadersForUpdate, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } +} diff --git a/pkg/cloudflare-go/miscategorization.go b/pkg/cloudflare-go/miscategorization.go new file mode 100644 index 000000000..5164c530d --- /dev/null +++ b/pkg/cloudflare-go/miscategorization.go @@ -0,0 +1,50 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +var ( + // ErrMissingIP is for when ipv4 or ipv6 indicator was given but ip is missing. + ErrMissingIP = errors.New("ip is required when using 'ipv4' or 'ipv6' indicator type and is missing") + // ErrMissingURL is for when url or domain indicator was given but url is missing. + ErrMissingURL = errors.New("url is required when using 'domain' or 'url' indicator type and is missing") +) + +// MisCategorizationParameters represents the parameters for a miscategorization request. +type MisCategorizationParameters struct { + AccountID string + IndicatorType string `json:"indicator_type,omitempty"` + IP string `json:"ip,omitempty"` + URL string `json:"url,omitempty"` + ContentAdds []int `json:"content_adds,omitempty"` + ContentRemoves []int `json:"content_removes,omitempty"` + SecurityAdds []int `json:"security_adds,omitempty"` + SecurityRemoves []int `json:"security_removes,omitempty"` +} + +// CreateMiscategorization creates a miscatergorization. +// +// API Reference: https://api.cloudflare.com/#miscategorization-create-miscategorization +func (api *API) CreateMiscategorization(ctx context.Context, params MisCategorizationParameters) error { + if params.AccountID == "" { + return ErrMissingAccountID + } + if (params.IndicatorType == "ipv6" || params.IndicatorType == "ipv4") && params.IP == "" { + return ErrMissingIP + } + if (params.IndicatorType == "domain" || params.IndicatorType == "url") && params.URL == "" { + return ErrMissingURL + } + + uri := fmt.Sprintf("/accounts/%s/intel/miscategorization", params.AccountID) + _, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/miscategorization_test.go b/pkg/cloudflare-go/miscategorization_test.go new file mode 100644 index 000000000..576b7dedf --- /dev/null +++ b/pkg/cloudflare-go/miscategorization_test.go @@ -0,0 +1,62 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateMiscategorization(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/intel/miscategorization", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [] +}`) + }) + ctx := context.Background() + err := client.CreateMiscategorization(ctx, MisCategorizationParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "ipv4"}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingIP, err) + } + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "ipv6"}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingIP, err) + } + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "url"}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingURL, err) + } + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "domain"}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingURL, err) + } + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "ipv4", IP: "192.0.2.0"}) + assert.NoError(t, err, "Got error for creating miscategorization for ipv4") + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "ipv6", IP: "2400:cb00::/32"}) + assert.NoError(t, err, "Got error for creating miscategorization for ipv6") + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "domain", URL: "example.com"}) + assert.NoError(t, err, "Got error for creating miscategorization for domain") + + err = client.CreateMiscategorization(ctx, MisCategorizationParameters{AccountID: testAccountID, IndicatorType: "url", URL: "https://example.com/news/"}) + assert.NoError(t, err, "Got error for creating miscategorization for url") +} diff --git a/pkg/cloudflare-go/mtls_certificates.go b/pkg/cloudflare-go/mtls_certificates.go new file mode 100644 index 000000000..4ab8794f9 --- /dev/null +++ b/pkg/cloudflare-go/mtls_certificates.go @@ -0,0 +1,215 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// MTLSAssociation represents the metadata for an existing association +// between a user-uploaded mTLS certificate and a Cloudflare service. +type MTLSAssociation struct { + Service string `json:"service"` + Status string `json:"status"` +} + +// MTLSAssociationResponse represents the response from the retrieval endpoint +// for mTLS certificate associations. +type MTLSAssociationResponse struct { + Response + Result []MTLSAssociation `json:"result"` +} + +// MTLSCertificate represents the metadata for a user-uploaded mTLS +// certificate. +type MTLSCertificate struct { + ID string `json:"id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + SerialNumber string `json:"serial_number"` + Certificates string `json:"certificates"` + CA bool `json:"ca"` + UploadedOn time.Time `json:"uploaded_on"` + UpdatedAt time.Time `json:"updated_at"` + ExpiresOn time.Time `json:"expires_on"` +} + +// MTLSCertificateResponse represents the response from endpoints relating to +// retrieving, creating, and deleting an mTLS certificate. +type MTLSCertificateResponse struct { + Response + Result MTLSCertificate `json:"result"` +} + +// MTLSCertificatesResponse represents the response from the mTLS certificate +// list endpoint. +type MTLSCertificatesResponse struct { + Response + Result []MTLSCertificate `json:"result"` + ResultInfo `json:"result_info"` +} + +// MTLSCertificateParams represents the data related to the mTLS certificate +// being uploaded. Name is an optional field. +type CreateMTLSCertificateParams struct { + Name string `json:"name"` + Certificates string `json:"certificates"` + PrivateKey string `json:"private_key"` + CA bool `json:"ca"` +} + +type ListMTLSCertificatesParams struct { + PaginationOptions + Limit int `url:"limit,omitempty"` + Offset int `url:"offset,omitempty"` + Name string `url:"name,omitempty"` + CA bool `url:"ca,omitempty"` +} + +type ListMTLSCertificateAssociationsParams struct { + CertificateID string +} + +var ( + ErrMissingCertificateID = errors.New("missing required certificate ID") +) + +// ListMTLSCertificates returns a list of all user-uploaded mTLS certificates. +// +// API reference: https://api.cloudflare.com/#mtls-certificate-management-list-mtls-certificates +func (api *API) ListMTLSCertificates(ctx context.Context, rc *ResourceContainer, params ListMTLSCertificatesParams) ([]MTLSCertificate, ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return []MTLSCertificate{}, ResultInfo{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return []MTLSCertificate{}, ResultInfo{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/mtls_certificates", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, params) + if err != nil { + return []MTLSCertificate{}, ResultInfo{}, err + } + var r MTLSCertificatesResponse + if err := json.Unmarshal(res, &r); err != nil { + return []MTLSCertificate{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, r.ResultInfo, err +} + +// GetMTLSCertificate returns the metadata associated with a user-uploaded mTLS +// certificate. +// +// API reference: https://api.cloudflare.com/#mtls-certificate-management-get-mtls-certificate +func (api *API) GetMTLSCertificate(ctx context.Context, rc *ResourceContainer, certificateID string) (MTLSCertificate, error) { + if rc.Level != AccountRouteLevel { + return MTLSCertificate{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return MTLSCertificate{}, ErrMissingAccountID + } + + if certificateID == "" { + return MTLSCertificate{}, ErrMissingCertificateID + } + + uri := fmt.Sprintf("/accounts/%s/mtls_certificates/%s", rc.Identifier, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return MTLSCertificate{}, err + } + var r MTLSCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return MTLSCertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListMTLSCertificateAssociations returns a list of all existing associations +// between the mTLS certificate and Cloudflare services. +// +// API reference: https://api.cloudflare.com/#mtls-certificate-management-list-mtls-certificate-associations +func (api *API) ListMTLSCertificateAssociations(ctx context.Context, rc *ResourceContainer, params ListMTLSCertificateAssociationsParams) ([]MTLSAssociation, error) { + if rc.Level != AccountRouteLevel { + return []MTLSAssociation{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return []MTLSAssociation{}, ErrMissingAccountID + } + + if params.CertificateID == "" { + return []MTLSAssociation{}, ErrMissingCertificateID + } + + uri := fmt.Sprintf("/accounts/%s/mtls_certificates/%s/associations", rc.Identifier, params.CertificateID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []MTLSAssociation{}, err + } + var r MTLSAssociationResponse + if err := json.Unmarshal(res, &r); err != nil { + return []MTLSAssociation{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateMTLSCertificate will create the provided certificate for use with mTLS +// enabled Cloudflare services. +// +// API reference: https://api.cloudflare.com/#mtls-certificate-management-upload-mtls-certificate +func (api *API) CreateMTLSCertificate(ctx context.Context, rc *ResourceContainer, params CreateMTLSCertificateParams) (MTLSCertificate, error) { + if rc.Level != AccountRouteLevel { + return MTLSCertificate{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return MTLSCertificate{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/mtls_certificates", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return MTLSCertificate{}, err + } + var r MTLSCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return MTLSCertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteMTLSCertificate will delete the specified mTLS certificate. +// +// API reference: https://api.cloudflare.com/#mtls-certificate-management-delete-mtls-certificate +func (api *API) DeleteMTLSCertificate(ctx context.Context, rc *ResourceContainer, certificateID string) (MTLSCertificate, error) { + if rc.Level != AccountRouteLevel { + return MTLSCertificate{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return MTLSCertificate{}, ErrMissingAccountID + } + + if certificateID == "" { + return MTLSCertificate{}, ErrMissingCertificateID + } + + uri := fmt.Sprintf("/accounts/%s/mtls_certificates/%s", rc.Identifier, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return MTLSCertificate{}, err + } + var r MTLSCertificateResponse + if err := json.Unmarshal(res, &r); err != nil { + return MTLSCertificate{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/mtls_certificates_test.go b/pkg/cloudflare-go/mtls_certificates_test.go new file mode 100644 index 000000000..2652eae7b --- /dev/null +++ b/pkg/cloudflare-go/mtls_certificates_test.go @@ -0,0 +1,245 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetMTLSCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "name": "example_ca_cert_5", + "issuer": "O=Example Inc.,L=California,ST=San Francisco,C=US", + "signature": "SHA256WithRSA", + "serial_number": "235217144297995885180570755458463043449861756659", + "certificates": "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + "ca": true, + "uploaded_on": "2022-11-22T17:32:30.467938Z", + "expires_on": "2122-10-29T16:59:47Z" + } + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/mtls_certificates/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2122-10-29T16:59:47Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2022-11-22T17:32:30.467938Z") + want := MTLSCertificate{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Name: "example_ca_cert_5", + Issuer: "O=Example Inc.,L=California,ST=San Francisco,C=US", + Signature: "SHA256WithRSA", + SerialNumber: "235217144297995885180570755458463043449861756659", + Certificates: "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + CA: true, + UploadedOn: uploadedOn, + ExpiresOn: expiresOn, + } + + actual, err := client.GetMTLSCertificate(context.Background(), AccountIdentifier(testAccountID), "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListMTLSCertificates(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "name": "example_ca_cert_5", + "issuer": "O=Example Inc.,L=California,ST=San Francisco,C=US", + "signature": "SHA256WithRSA", + "serial_number": "235217144297995885180570755458463043449861756659", + "certificates": "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + "ca": true, + "uploaded_on": "2022-11-22T17:32:30.467938Z", + "expires_on": "2122-10-29T16:59:47Z" + } + ], + "result_info": { + "page": 1, + "per_page": 50, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/mtls_certificates", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2122-10-29T16:59:47Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2022-11-22T17:32:30.467938Z") + want := []MTLSCertificate{ + { + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Name: "example_ca_cert_5", + Issuer: "O=Example Inc.,L=California,ST=San Francisco,C=US", + Signature: "SHA256WithRSA", + SerialNumber: "235217144297995885180570755458463043449861756659", + Certificates: "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + CA: true, + UploadedOn: uploadedOn, + ExpiresOn: expiresOn, + }, + } + + actual, _, err := client.ListMTLSCertificates(context.Background(), AccountIdentifier(testAccountID), ListMTLSCertificatesParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListCertificateAssociations(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "service": "gateway", + "status": "pending_deployment" + } + ] + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/mtls_certificates/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60/associations", handler) + want := []MTLSAssociation{ + { + Service: "gateway", + Status: "pending_deployment", + }, + } + + actual, err := client.ListMTLSCertificateAssociations(context.Background(), AccountIdentifier(testAccountID), ListMTLSCertificateAssociationsParams{ + CertificateID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUploadMTLSCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "name": "example_ca_cert_5", + "issuer": "O=Example Inc.,L=California,ST=San Francisco,C=US", + "signature": "SHA256WithRSA", + "serial_number": "235217144297995885180570755458463043449861756659", + "certificates": "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + "ca": true, + "uploaded_on": "2022-11-22T17:32:30.467938Z", + "updated_at": "2022-11-22T17:32:30.467938Z", + "expires_on": "2122-10-29T16:59:47Z" + } + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/mtls_certificates", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2122-10-29T16:59:47Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2022-11-22T17:32:30.467938Z") + want := MTLSCertificate{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Name: "example_ca_cert_5", + Issuer: "O=Example Inc.,L=California,ST=San Francisco,C=US", + Signature: "SHA256WithRSA", + SerialNumber: "235217144297995885180570755458463043449861756659", + Certificates: "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + CA: true, + UploadedOn: uploadedOn, + UpdatedAt: uploadedOn, + ExpiresOn: expiresOn, + } + + cert := CreateMTLSCertificateParams{ + Name: "example_ca_cert_5", + Certificates: "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDEXDkcICRU3XBv9hiiPnBWIjgTQyowmVFxDr11mONgZB/cMYjE/OvQjvnpwNcOaSK16MOpAjNbELKRx2lZiVJaLRDCccqCxXwP/CrdRChcqGzo7mbNksMlcidrErb0LlEBKLFC2QjRmRKqB+YOs4TD8WsZu2S667A2fZmjRlaqOxFi1h62ee0P+TLU628UC/nl41JifSt5Evt7hMDHakemdwZblNYr2p6T3NQjdhjYXTtP4UmOGJBhJ7i7Kicg3d3CIgdTMbggSeGWqjndr4ldVnD96FN3cVT5uDFsn2CJXTFgdeBWoUnMS4VnUZzPWGf4vSBXC8qV7Ls+w46yT7T1AgMBAAECggEAQZnp/oqCeNPOR6l5S2L+1tfx0gWjZ78hJVteUpZ0iHSK7F6kKeOxyOird7vUXV0kmo+cJq+0hp0Ke4eam640FCpwKfYoSQ4/R3vgujGWJnaihCN5tv5sMet0XeJPuz5qE7ALoKCvwI6aXLHs20aAeZIDTQJ9QbGSGnJVzOWn+JDTidIgZpN57RpXfSAwnJPTQK/PN8i5z108hsaDOdEgGmxYZ7kYqMqzX20KXmth58LDfPixs5JGtS60iiKC/wOcGzkB2/AdTSojR76oEU77cANP/3zO25NG//whUdYlW0t0d7PgXxIeJe+xgYnamDQJx3qonVyt4H77ha0ObRAj9QKBgQDicZr+VTwFMnELP3a+FXGnjehRiuS1i7MXGKxNweCD+dFlML0FplSQS8Ro2n+d8lu8BBXGx0qm6VXu8Rhn7TAUL6q+PCgfarzxfIhacb/TZCqfieIHsMlVBfhV5HCXnk+kis0tuC/PRArcWTwDHJUJXkBhvkUsNswvQzavDPI7KwKBgQDd/WgLkj7A3X5fgIHZH/GbDSBiXwzKb+rF4ZCT2XFgG/OAW7vapfcX/w+v+5lBLyrocmOAS3PGGAhM5T3HLnUCQfnK4qgps1Lqibkc9Tmnsn60LanUjuUMsYv/zSw70tozbzhJ0pioEpWfRxRZBztO2Rr8Ntm7h6Fk701EXGNAXwKBgQCD1xsjy2J3sCerIdcz0u5qXLAPkeuZW+34m4/ucdwTWwc0gEz9lhsULFj9p4G351zLuiEnq+7mAWLcDJlmIO3mQt6JhiLiL9Y0T4pgBmxmWqKKYtAsJB0EmMY+1BNN44mBRqMxZFTJu1cLdhT/xstrOeoIPqytknYNanfTMZlzIwKBgHrLXe5oq0XMP8dcMneEcAUwsaU4pr6kQd3L9EmUkl5zl7J9C+DaxWAEuwzBw/iGutlxzRB+rD/7szu14wJ29EqXbDGKRzMp+se5/yfBjm7xEZ1hVPw7PwBShfqt57X/4Ktq7lwHnmH6RcGhc+P7WBc5iO/S94YAdIp8xOT3pf9JAoGAE0QkqJUY+5Mgr+fBO0VNV72ZoPveGpW+De59uhKAOnu1zljQCUtk59m6+DXfm0tNYKtawa5n8iN71Zh+s62xXSt3pYi1Y5CCCmv8Y4BhwIcPwXKk3zEvLgSHVTpC0bayA9aSO4bbZgVXa5w+Z0w/vvfp9DWo1IS3EnQRrz6WMYA=\n-----END PRIVATE KEY-----", + CA: true, + } + actual, err := client.CreateMTLSCertificate(context.Background(), AccountIdentifier(testAccountID), cert) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteMTLSCertificate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + "name": "example_ca_cert_5", + "issuer": "O=Example Inc.,L=California,ST=San Francisco,C=US", + "signature": "SHA256WithRSA", + "serial_number": "235217144297995885180570755458463043449861756659", + "certificates": "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + "ca": true, + "uploaded_on": "2022-11-22T17:32:30.467938Z", + "expires_on": "2122-10-29T16:59:47Z" + } + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/mtls_certificates/2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", handler) + expiresOn, _ := time.Parse(time.RFC3339, "2122-10-29T16:59:47Z") + uploadedOn, _ := time.Parse(time.RFC3339, "2022-11-22T17:32:30.467938Z") + want := MTLSCertificate{ + ID: "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60", + Name: "example_ca_cert_5", + Issuer: "O=Example Inc.,L=California,ST=San Francisco,C=US", + Signature: "SHA256WithRSA", + SerialNumber: "235217144297995885180570755458463043449861756659", + Certificates: "-----BEGIN CERTIFICATE-----\nMIIDmDCCAoCgAwIBAgIUKTOAZNjcXVZRj4oQt0SHsl1c1vMwDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjAgFw0yMjExMjIxNjU5NDdaGA8yMTIyMTAyOTE2NTk0N1owUTELMAkGA1UEBhMCVVMxFjAUBgNVBAgMDVNhbiBGcmFuY2lzY28xEzARBgNVBAcMCkNhbGlmb3JuaWExFTATBgNVBAoMDEV4YW1wbGUgSW5jLjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMRcORwgJFTdcG/2GKI+cFYiOBNDKjCZUXEOvXWY42BkH9wxiMT869CO+enA1w5pIrXow6kCM1sQspHHaVmJUlotEMJxyoLFfA/8Kt1EKFyobOjuZs2SwyVyJ2sStvQuUQEosULZCNGZEqoH5g6zhMPxaxm7ZLrrsDZ9maNGVqo7EWLWHrZ57Q/5MtTrbxQL+eXjUmJ9K3kS+3uEwMdqR6Z3BluU1ivanpPc1CN2GNhdO0/hSY4YkGEnuLsqJyDd3cIiB1MxuCBJ4ZaqOd2viV1WcP3oU3dxVPm4MWyfYIldMWB14FahScxLhWdRnM9YZ/i9IFcLypXsuz7DjrJPtPUCAwEAAaNmMGQwHQYDVR0OBBYEFP5JzLUawNF+c3AXsYTEWHh7z2czMB8GA1UdIwQYMBaAFP5JzLUawNF+c3AXsYTEWHh7z2czMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMA0GCSqGSIb3DQEBCwUAA4IBAQBc+Be7NDhpE09y7hLPZGRPl1cSKBw4RI0XIv6rlbSTFs5EebpTGjhx/whNxwEZhB9HZ7111Oa1YlT8xkI9DshB78mjAHCKBAJ76moK8tkG0aqdYpJ4ZcJTVBB7l98Rvgc7zfTii7WemTy72deBbSeiEtXavm4EF0mWjHhQ5Nxpnp00Bqn5g1x8CyTDypgmugnep+xG+iFzNmTdsz7WI9T/7kDMXqB7M/FPWBORyS98OJqNDswCLF8bIZYwUBEe+bRHFomoShMzaC3tvim7WCb16noDkSTMlfKO4pnvKhpcVdSgwcruATV7y+W+Lvmz2OT/Gui4JhqeoTewsxndhDDE\n-----END CERTIFICATE-----", + CA: true, + UploadedOn: uploadedOn, + ExpiresOn: expiresOn, + } + + actual, err := client.DeleteMTLSCertificate(context.Background(), AccountIdentifier(testAccountID), "2458ce5a-0c35-4c7f-82c7-8e9487d3ff60") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/notifications.go b/pkg/cloudflare-go/notifications.go new file mode 100644 index 000000000..7afd66f1f --- /dev/null +++ b/pkg/cloudflare-go/notifications.go @@ -0,0 +1,447 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// NotificationMechanismData holds a single public facing mechanism data +// integation. +type NotificationMechanismData struct { + Name string `json:"name"` + ID string `json:"id"` +} + +// NotificationMechanismIntegrations is a list of all the integrations of a +// certain mechanism type e.g. all email integrations. +type NotificationMechanismIntegrations []NotificationMechanismData + +// NotificationPolicy represents the notification policy created along with +// the destinations. +type NotificationPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + AlertType string `json:"alert_type"` + Mechanisms map[string]NotificationMechanismIntegrations `json:"mechanisms"` + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + Conditions map[string]interface{} `json:"conditions"` + Filters map[string][]string `json:"filters"` +} + +// NotificationPoliciesResponse holds the response for listing all +// notification policies for an account. +type NotificationPoliciesResponse struct { + Response + ResultInfo + Result []NotificationPolicy +} + +// NotificationPolicyResponse holds the response type when a single policy +// is retrieved. +type NotificationPolicyResponse struct { + Response + Result NotificationPolicy +} + +// NotificationWebhookIntegration describes the webhook information along +// with its status. +type NotificationWebhookIntegration struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at"` + LastSuccess *time.Time `json:"last_success"` + LastFailure *time.Time `json:"last_failure"` +} + +// NotificationWebhookResponse describes a single webhook retrieved. +type NotificationWebhookResponse struct { + Response + ResultInfo + Result NotificationWebhookIntegration +} + +// NotificationWebhooksResponse describes a list of webhooks retrieved. +type NotificationWebhooksResponse struct { + Response + ResultInfo + Result []NotificationWebhookIntegration +} + +// NotificationUpsertWebhooks describes a valid webhook request. +type NotificationUpsertWebhooks struct { + Name string `json:"name"` + URL string `json:"url"` + Secret string `json:"secret"` +} + +// NotificationPagerDutyResource describes a PagerDuty integration. +type NotificationPagerDutyResource struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// NotificationPagerDutyResponse describes the PagerDuty integration +// retrieved. +type NotificationPagerDutyResponse struct { + Response + ResultInfo + Result NotificationPagerDutyResource +} + +// NotificationResource describes the id of an inserted/updated/deleted +// resource. +type NotificationResource struct { + ID string +} + +// SaveResponse is returned when a resource is inserted/updated/deleted. +type SaveResponse struct { + Response + Result NotificationResource +} + +// NotificationMechanismMetaData represents the state of the delivery +// mechanism. +type NotificationMechanismMetaData struct { + Eligible bool `json:"eligible"` + Ready bool `json:"ready"` + Type string `json:"type"` +} + +// NotificationMechanisms are the different possible delivery mechanisms. +type NotificationMechanisms struct { + Email NotificationMechanismMetaData `json:"email"` + PagerDuty NotificationMechanismMetaData `json:"pagerduty"` + Webhooks NotificationMechanismMetaData `json:"webhooks,omitempty"` +} + +// NotificationEligibilityResponse describes the eligible mechanisms that +// can be configured for a notification. +type NotificationEligibilityResponse struct { + Response + Result NotificationMechanisms +} + +// NotificationsGroupedByProduct are grouped by products. +type NotificationsGroupedByProduct map[string][]NotificationAlertWithDescription + +// NotificationAlertWithDescription represents the alert/notification +// available. +type NotificationAlertWithDescription struct { + DisplayName string `json:"display_name"` + Type string `json:"type"` + Description string `json:"description"` +} + +// NotificationAvailableAlertsResponse describes the available +// alerts/notifications grouped by products. +type NotificationAvailableAlertsResponse struct { + Response + Result NotificationsGroupedByProduct +} + +// NotificationHistory describes the history +// of notifications sent for an account. +type NotificationHistory struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AlertBody string `json:"alert_body"` + AlertType string `json:"alert_type"` + Mechanism string `json:"mechanism"` + MechanismType string `json:"mechanism_type"` + Sent time.Time `json:"sent"` +} + +// NotificationHistoryResponse describes the notification history +// response for an account for a specific time period. +type NotificationHistoryResponse struct { + Response + ResultInfo `json:"result_info"` + Result []NotificationHistory +} + +// ListNotificationPolicies will return the notification policies +// created by a user for a specific account. +// +// API Reference: https://api.cloudflare.com/#notification-policies-properties +func (api *API) ListNotificationPolicies(ctx context.Context, accountID string) (NotificationPoliciesResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/policies", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationPoliciesResponse{}, err + } + var r NotificationPoliciesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// GetNotificationPolicy returns a specific created by a user, given the account +// id and the policy id. +// +// API Reference: https://api.cloudflare.com/#notification-policies-properties +func (api *API) GetNotificationPolicy(ctx context.Context, accountID, policyID string) (NotificationPolicyResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/policies/%s", accountID, policyID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationPolicyResponse{}, err + } + var r NotificationPolicyResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// CreateNotificationPolicy creates a notification policy for an account. +// +// API Reference: https://api.cloudflare.com/#notification-policies-create-notification-policy +func (api *API) CreateNotificationPolicy(ctx context.Context, accountID string, policy NotificationPolicy) (SaveResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/policies", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, baseURL, policy) + if err != nil { + return SaveResponse{}, err + } + return unmarshalNotificationSaveResponse(res) +} + +// UpdateNotificationPolicy updates a notification policy, given the +// account id and the policy id and returns the policy id. +// +// API Reference: https://api.cloudflare.com/#notification-policies-update-notification-policy +func (api *API) UpdateNotificationPolicy(ctx context.Context, accountID string, policy *NotificationPolicy) (SaveResponse, error) { + if policy == nil { + return SaveResponse{}, fmt.Errorf("policy cannot be nil") + } + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/policies/%s", accountID, policy.ID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, baseURL, policy) + if err != nil { + return SaveResponse{}, err + } + return unmarshalNotificationSaveResponse(res) +} + +// DeleteNotificationPolicy deletes a notification policy for an account. +// +// API Reference: https://api.cloudflare.com/#notification-policies-delete-notification-policy +func (api *API) DeleteNotificationPolicy(ctx context.Context, accountID, policyID string) (SaveResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/policies/%s", accountID, policyID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, baseURL, nil) + if err != nil { + return SaveResponse{}, err + } + return unmarshalNotificationSaveResponse(res) +} + +// ListNotificationWebhooks will return the webhook destinations configured +// for an account. +// +// API Reference: https://api.cloudflare.com/#notification-webhooks-list-webhooks +func (api *API) ListNotificationWebhooks(ctx context.Context, accountID string) (NotificationWebhooksResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/webhooks", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationWebhooksResponse{}, err + } + var r NotificationWebhooksResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// CreateNotificationWebhooks will help connect a webhooks destination. +// A test message will be sent to the webhooks endpoint during creation. +// If added successfully, the webhooks can be setup as a destination mechanism +// while creating policies. +// +// Notifications will be posted to this URL. +// +// API Reference: https://api.cloudflare.com/#notification-webhooks-create-webhook +func (api *API) CreateNotificationWebhooks(ctx context.Context, accountID string, webhooks *NotificationUpsertWebhooks) (SaveResponse, error) { + if webhooks == nil { + return SaveResponse{}, fmt.Errorf("webhooks cannot be nil") + } + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/webhooks", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, baseURL, webhooks) + if err != nil { + return SaveResponse{}, err + } + + return unmarshalNotificationSaveResponse(res) +} + +// GetNotificationWebhooks will return a specific webhook destination, +// given the account and webhooks ids. +// +// API Reference: https://api.cloudflare.com/#notification-webhooks-get-webhook +func (api *API) GetNotificationWebhooks(ctx context.Context, accountID, webhookID string) (NotificationWebhookResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/webhooks/%s", accountID, webhookID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationWebhookResponse{}, err + } + var r NotificationWebhookResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// UpdateNotificationWebhooks will update a particular webhook's name, +// given the account and webhooks ids. +// +// The webhook url and secret cannot be updated. +// +// API Reference: https://api.cloudflare.com/#notification-webhooks-update-webhook +func (api *API) UpdateNotificationWebhooks(ctx context.Context, accountID, webhookID string, webhooks *NotificationUpsertWebhooks) (SaveResponse, error) { + if webhooks == nil { + return SaveResponse{}, fmt.Errorf("webhooks cannot be nil") + } + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/webhooks/%s", accountID, webhookID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, baseURL, webhooks) + if err != nil { + return SaveResponse{}, err + } + + return unmarshalNotificationSaveResponse(res) +} + +// DeleteNotificationWebhooks will delete a webhook, given the account and +// webhooks ids. Deleting the webhooks will remove it from any connected +// notification policies. +// +// API Reference: https://api.cloudflare.com/#notification-webhooks-delete-webhook +func (api *API) DeleteNotificationWebhooks(ctx context.Context, accountID, webhookID string) (SaveResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/webhooks/%s", accountID, webhookID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, baseURL, nil) + if err != nil { + return SaveResponse{}, err + } + + return unmarshalNotificationSaveResponse(res) +} + +// ListPagerDutyNotificationDestinations will return the pagerduty +// destinations configured for an account. +// +// API Reference: https://api.cloudflare.com/#notification-destinations-with-pagerduty-list-pagerduty-services +func (api *API) ListPagerDutyNotificationDestinations(ctx context.Context, accountID string) (NotificationPagerDutyResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/pagerduty", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationPagerDutyResponse{}, err + } + var r NotificationPagerDutyResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// GetEligibleNotificationDestinations will return the types of +// destinations an account is eligible to configure. +// +// API Reference: https://api.cloudflare.com/#notification-mechanism-eligibility-properties +func (api *API) GetEligibleNotificationDestinations(ctx context.Context, accountID string) (NotificationEligibilityResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/destinations/eligible", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationEligibilityResponse{}, err + } + var r NotificationEligibilityResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// GetAvailableNotificationTypes will return the alert types available for +// a given account. +// +// API Reference: https://api.cloudflare.com/#notification-mechanism-eligibility-properties +func (api *API) GetAvailableNotificationTypes(ctx context.Context, accountID string) (NotificationAvailableAlertsResponse, error) { + baseURL := fmt.Sprintf("/accounts/%s/alerting/v3/available_alerts", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return NotificationAvailableAlertsResponse{}, err + } + var r NotificationAvailableAlertsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} + +// TimeRange is an object for filtering the alert history based on timestamp. +type TimeRange struct { + Since string `json:"since,omitempty" url:"since,omitempty"` + Before string `json:"before,omitempty" url:"before,omitempty"` +} + +// AlertHistoryFilter is an object for filtering the alert history response from the api. +type AlertHistoryFilter struct { + TimeRange + PaginationOptions +} + +// ListNotificationHistory will return the history of alerts sent for +// a given account. The time period varies based on zone plan. +// Free, Biz, Pro = 30 days +// Ent = 90 days +// +// API Reference: https://api.cloudflare.com/#notification-history-list-history +func (api *API) ListNotificationHistory(ctx context.Context, accountID string, alertHistoryFilter AlertHistoryFilter) ([]NotificationHistory, ResultInfo, error) { + uri := buildURI(fmt.Sprintf("/accounts/%s/alerting/v3/history", accountID), alertHistoryFilter) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []NotificationHistory{}, ResultInfo{}, err + } + var r NotificationHistoryResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []NotificationHistory{}, ResultInfo{}, err + } + return r.Result, r.ResultInfo, nil +} + +// unmarshal will unmarshal bytes and return a SaveResponse. +func unmarshalNotificationSaveResponse(res []byte) (SaveResponse, error) { + var r SaveResponse + err := json.Unmarshal(res, &r) + if err != nil { + return r, err + } + return r, nil +} diff --git a/pkg/cloudflare-go/notifications_test.go b/pkg/cloudflare-go/notifications_test.go new file mode 100644 index 000000000..1b857d016 --- /dev/null +++ b/pkg/cloudflare-go/notifications_test.go @@ -0,0 +1,574 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testWebhookID = "fe49ee055d23404e9d58f9110b210c8d" + testPolicyID = "6ec8a5145d0d2263a36fad55c03cb43d" +) + +var ( + notificationTimestamp = time.Date(2021, 05, 01, 10, 47, 01, 01, time.UTC) +) + +func TestGetEligibleNotificationDestinations(t *testing.T) { + setup() + defer teardown() + + expected := NotificationMechanisms{ + Email: NotificationMechanismMetaData{true, true, "email"}, + PagerDuty: NotificationMechanismMetaData{true, true, "pagerduty"}, + Webhooks: NotificationMechanismMetaData{true, true, "webhooks"}, + } + b, err := json.Marshal(expected) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s +}`, string(b)) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/eligible", handler) + + actual, err := client.GetEligibleNotificationDestinations(context.Background(), testAccountID) + require.Nil(t, err) + require.NotNil(t, actual) + assert.Equal(t, expected, actual.Result) +} +func TestGetAvailableNotificationTypes(t *testing.T) { + setup() + defer teardown() + + expected := make(NotificationsGroupedByProduct, 1) + alert1 := NotificationAlertWithDescription{Type: "secondary_dns_zone_successfully_updated", DisplayName: "Secondary DNS Successfully Updated", Description: "Secondary zone transfers are succeeding, the zone has been updated."} + alert2 := NotificationAlertWithDescription{Type: "secondary_dns_zone_validation_warning", DisplayName: "Secondary DNSSEC Validation Warning", Description: "The transferred DNSSEC zone is incorrectly configured."} + expected["DNS"] = []NotificationAlertWithDescription{alert1, alert2} + + b, err := json.Marshal(expected) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/available_alerts", handler) + + actual, err := client.GetAvailableNotificationTypes(context.Background(), testAccountID) + require.Nil(t, err) + require.NotNil(t, actual) + assert.Equal(t, expected, actual.Result) +} +func TestListPagerDutyDestinations(t *testing.T) { + setup() + defer teardown() + + expected := NotificationPagerDutyResource{ID: "valid-uuid", Name: "my pagerduty connection"} + b, err := json.Marshal(expected) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/pagerduty", handler) + + actual, err := client.ListPagerDutyNotificationDestinations(context.Background(), testAccountID) + require.Nil(t, err) + require.NotNil(t, actual) + assert.Equal(t, expected, actual.Result) +} + +func TestCreateNotificationPolicy(t *testing.T) { + setup() + defer teardown() + + mechanisms := make(map[string]NotificationMechanismIntegrations) + mechanisms["email"] = []NotificationMechanismData{{Name: "email to send notification", ID: "test@gmail.com"}} + policy := NotificationPolicy{ + Description: "Notifies when my zones are under attack", + Name: "CF DOS attack alert - L4", + Enabled: true, + AlertType: "dos_attack_l4", + Mechanisms: mechanisms, + Conditions: nil, + Filters: nil, + } + b, err := json.Marshal(policy) + require.NoError(t, err) + require.NotEmpty(t, b) + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/policies", handler) + res, err := client.CreateNotificationPolicy(context.Background(), testAccountID, policy) + require.NoError(t, err) + require.NotNil(t, res) +} + +func TestGetNotificationPolicy(t *testing.T) { + setup() + defer teardown() + + mechanisms := make(map[string]NotificationMechanismIntegrations) + mechanisms["email"] = []NotificationMechanismData{{Name: "email to send notification", ID: "test@gmail.com"}} + policy := NotificationPolicy{ + ID: testPolicyID, + Description: "Notifies when my zones are under attack", + Name: "CF DOS attack alert - L4", + Enabled: true, + AlertType: "dos_attack_l4", + Mechanisms: mechanisms, + Conditions: nil, + Filters: nil, + Created: notificationTimestamp, + Modified: notificationTimestamp, + } + b, err := json.Marshal(policy) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/policies/"+testPolicyID, handler) + + res, err := client.GetNotificationPolicy(context.Background(), testAccountID, testPolicyID) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, policy, res.Result) +} + +func TestListNotificationPolicies(t *testing.T) { + setup() + defer teardown() + + mechanisms := make(map[string]NotificationMechanismIntegrations) + mechanisms["email"] = []NotificationMechanismData{{Name: "email to send notification", ID: "test@gmail.com"}} + policy := NotificationPolicy{ + ID: testPolicyID, + Description: "Notifies when my zones are under attack", + Name: "CF DOS attack alert - L4", + Enabled: true, + AlertType: "dos_attack_l4", + Mechanisms: mechanisms, + Conditions: nil, + Filters: nil, + Created: time.Date(2021, 05, 01, 10, 47, 01, 01, time.UTC), + Modified: time.Date(2021, 05, 01, 10, 47, 01, 01, time.UTC), + } + policies := []NotificationPolicy{ + policy, + } + b, err := json.Marshal(policies) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/policies", handler) + + res, err := client.ListNotificationPolicies(context.Background(), testAccountID) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, policies, res.Result) +} + +func TestUpdateNotificationPolicy(t *testing.T) { + setup() + defer teardown() + + mechanisms := make(map[string]NotificationMechanismIntegrations) + mechanisms["email"] = []NotificationMechanismData{{Name: "email to send notification", ID: "test@gmail.com"}} + policy := NotificationPolicy{ + ID: testPolicyID, + Description: "Notifies when my zones are under attack", + Name: "CF DOS attack alert - L4", + Enabled: true, + AlertType: "dos_attack_l4", + Mechanisms: mechanisms, + Conditions: nil, + Filters: nil, + Created: time.Date(2021, 05, 01, 10, 47, 01, 01, time.UTC), + Modified: time.Date(2021, 05, 01, 10, 47, 01, 01, time.UTC), + } + b, err := json.Marshal(policy) + require.NoError(t, err) + require.NotEmpty(t, b) + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/policies/"+testPolicyID, handler) + + res, err := client.UpdateNotificationPolicy(context.Background(), testAccountID, &policy) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, testPolicyID, res.Result.ID) +} + +func TestDeleteNotificationPolicy(t *testing.T) { + setup() + defer teardown() + + result := NotificationResource{ID: testPolicyID} + b, err := json.Marshal(result) + require.NoError(t, err) + require.NotNil(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/policies/"+testPolicyID, handler) + + res, err := client.DeleteNotificationPolicy(context.Background(), testAccountID, testPolicyID) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, testPolicyID, res.Result.ID) +} + +func TestCreateNotificationWebhooks(t *testing.T) { + setup() + defer teardown() + + webhook := NotificationUpsertWebhooks{ + Name: "my test webhook", + URL: "https://example.com", + Secret: "mischief-managed", // optional + } + + result := NotificationResource{ID: testWebhookID} + + b, err := json.Marshal(result) + require.NoError(t, err) + require.NotEmpty(t, b) + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/webhooks", handler) + + res, err := client.CreateNotificationWebhooks(context.Background(), testAccountID, &webhook) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, testWebhookID, res.Result.ID) +} + +func TestListNotificationWebhooks(t *testing.T) { + setup() + defer teardown() + + webhook := NotificationWebhookIntegration{ + ID: testWebhookID, + Name: "my test webhook", + URL: "https://example.com", + Type: "generic", + CreatedAt: notificationTimestamp, + LastSuccess: ¬ificationTimestamp, + LastFailure: ¬ificationTimestamp, + } + webhooks := []NotificationWebhookIntegration{webhook} + b, err := json.Marshal(webhooks) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/webhooks", handler) + + res, err := client.ListNotificationWebhooks(context.Background(), testAccountID) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, webhooks, res.Result) +} + +func TestGetNotificationWebhooks(t *testing.T) { + setup() + defer teardown() + + webhook := NotificationWebhookIntegration{ + ID: testWebhookID, + Name: "my test webhook", + URL: "https://example.com", + Type: "generic", + CreatedAt: notificationTimestamp, + LastSuccess: ¬ificationTimestamp, + LastFailure: ¬ificationTimestamp, + } + b, err := json.Marshal(webhook) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/webhooks/"+testWebhookID, handler) + + res, err := client.GetNotificationWebhooks(context.Background(), testAccountID, testWebhookID) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, webhook, res.Result) +} + +func TestUpdateNotificationWebhooks(t *testing.T) { + setup() + defer teardown() + + result := NotificationResource{ID: testWebhookID} + b, err := json.Marshal(result) + require.NoError(t, err) + require.NotEmpty(t, b) + + webhook := NotificationUpsertWebhooks{ + Name: "my test webhook with a new name", + URL: "https://example.com", + Secret: "mischief-managed", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/webhooks/"+testWebhookID, handler) + + res, err := client.UpdateNotificationWebhooks(context.Background(), testAccountID, testWebhookID, &webhook) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, testWebhookID, res.Result.ID) +} + +func TestDeleteNotificationWebhooks(t *testing.T) { + setup() + defer teardown() + + result := NotificationResource{ID: testWebhookID} + b, err := json.Marshal(result) + require.NoError(t, err) + require.NotEmpty(t, b) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result":%s + }`, + string(b)) + require.NoError(t, err) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/destinations/webhooks/"+testWebhookID, handler) + + res, err := client.DeleteNotificationWebhooks(context.Background(), testAccountID, testWebhookID) + require.NoError(t, err) + require.NotNil(t, res) + + assert.Equal(t, testWebhookID, res.Result.ID) +} + +func TestListNotificationHistory(t *testing.T) { + setup() + defer teardown() + + expected := []NotificationHistory{ + { + ID: "some-id", + Name: "some-name", + Description: "some-description", + AlertBody: "some-alert-body", + AlertType: "some-alert-type", + Mechanism: "some-mechanism", + MechanismType: "some-mechanism-type", + Sent: notificationTimestamp, + }, + } + + expectedResultInfo := ResultInfo{ + Page: 0, + PerPage: 25, + Count: 1, + } + + pageOptions := PaginationOptions{ + PerPage: 25, + Page: 1, + } + + timeRange := TimeRange{ + Since: time.Now().Add(-15 * time.Minute).Format(time.RFC3339), + Before: time.Now().Format(time.RFC3339), + } + + historyFilters := AlertHistoryFilter{TimeRange: timeRange, PaginationOptions: pageOptions} + + alertHistory, err := json.Marshal(expected) + require.NoError(t, err) + require.NotNil(t, alertHistory) + + resultInfo, err := json.Marshal(expectedResultInfo) + require.NoError(t, err) + require.NotNil(t, resultInfo) + + 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") + _, err = fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result_info": %s, + "result": %s + }`, + string(resultInfo), + string(alertHistory)) + if err != nil { + return + } + } + + mux.HandleFunc("/accounts/"+testAccountID+"/alerting/v3/history", handler) + + actualResult, actualResultInfo, err := client.ListNotificationHistory(context.Background(), testAccountID, historyFilters) + require.Nil(t, err) + require.NotNil(t, actualResult) + require.Equal(t, expected, actualResult) + require.Equal(t, expectedResultInfo, actualResultInfo) +} diff --git a/pkg/cloudflare-go/observatory.go b/pkg/cloudflare-go/observatory.go new file mode 100644 index 000000000..d1f7d08ed --- /dev/null +++ b/pkg/cloudflare-go/observatory.go @@ -0,0 +1,401 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/goccy/go-json" + "github.com/google/go-querystring/query" +) + +var ( + ErrMissingObservatoryUrl = errors.New("missing required page url") + ErrMissingObservatoryTestID = errors.New("missing required test id") +) + +// ObservatoryPage describes all the tests for a web page. +type ObservatoryPage struct { + URL string `json:"url"` + Region labeledRegion `json:"region"` + ScheduleFrequency string `json:"scheduleFrequency"` + Tests []ObservatoryPageTest `json:"tests"` +} + +// ObservatoryPageTest describes a single test for a web page. +type ObservatoryPageTest struct { + ID string `json:"id"` + Date *time.Time `json:"date"` + URL string `json:"url"` + Region labeledRegion `json:"region"` + ScheduleFrequency *string `json:"scheduleFrequency"` + MobileReport ObservatoryLighthouseReport `json:"mobileReport"` + DesktopReport ObservatoryLighthouseReport `json:"desktopReport"` +} + +// labeledRegion describes a region the test was run in. +type labeledRegion struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// ObservatorySchedule describe a test schedule. +type ObservatorySchedule struct { + URL string `json:"url"` + Region string `json:"region"` + Frequency string `json:"frequency"` +} + +// ObservatoryLighthouseReport describes the web vital metrics result. +type ObservatoryLighthouseReport struct { + PerformanceScore int `json:"performanceScore"` + State string `json:"state"` + DeviceType string `json:"deviceType"` + // TTFB is time to first byte + TTFB int `json:"ttfb"` + // FCP is first contentful paint + FCP int `json:"fcp"` + // LCP is largest contentful pain + LCP int `json:"lcp"` + // TTI is time to interactive + TTI int `json:"tti"` + // TBT is total blocking time + TBT int `json:"tbt"` + // SI is speed index + SI int `json:"si"` + // CLS is cumulative layout shift + CLS float64 `json:"cls"` + Error *lighthouseError `json:"error,omitempty"` +} + +// lighthouseError describes the test error. +type lighthouseError struct { + Code string `json:"code"` + Detail string `json:"detail"` + FinalDisplayedURL string `json:"finalDisplayedUrl"` +} + +// ObservatoryPageTrend describes the web vital metrics trend. +type ObservatoryPageTrend struct { + PerformanceScore []*int `json:"performanceScore"` + TTFB []*int `json:"ttfb"` + FCP []*int `json:"fcp"` + LCP []*int `json:"lcp"` + TTI []*int `json:"tti"` + TBT []*int `json:"tbt"` + SI []*int `json:"si"` + CLS []*float64 `json:"cls"` +} + +type ListObservatoryPagesParams struct { +} + +// ObservatoryPagesResponse is the API response, containing a list of ObservatoryPage. +type ObservatoryPagesResponse struct { + Response + Result []ObservatoryPage `json:"result"` +} + +// ListObservatoryPages returns a list of pages which have been tested. +// +// API reference: https://api.cloudflare.com/#speed-list-pages +func (api *API) ListObservatoryPages(ctx context.Context, rc *ResourceContainer, params ListObservatoryPagesParams) ([]ObservatoryPage, error) { + uri := fmt.Sprintf("/zones/%s/speed_api/pages", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r ObservatoryPagesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +type GetObservatoryPageTrendParams struct { + URL string `url:"-"` + Region string `url:"region"` + DeviceType string `url:"deviceType"` + Start *time.Time `url:"start"` + End *time.Time `url:"end,omitempty"` + Timezone string `url:"tz"` + Metrics []string `url:"metrics"` +} + +type ObservatoryPageTrendResponse struct { + Response + Result ObservatoryPageTrend `json:"result"` +} + +// GetObservatoryPageTrend returns a the trend of web vital metrics for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-list-page-trend +func (api *API) GetObservatoryPageTrend(ctx context.Context, rc *ResourceContainer, params GetObservatoryPageTrendParams) (*ObservatoryPageTrend, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + // cannot use buildURI because params.URL contains "/" that should be encoded and buildURI will double encode %2F into %252F + v, _ := query.Values(params) + uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/trend?%s", rc.Identifier, url.PathEscape(params.URL), v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r ObservatoryPageTrendResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +var listObservatoryPageTestDefaultPageSize = 20 + +type ListObservatoryPageTestParams struct { + URL string `url:"-"` + Region string `url:"region"` + ResultInfo +} + +type ObservatoryPageTestsResponse struct { + Response + Result []ObservatoryPageTest `json:"result"` + ResultInfo `json:"result_info"` +} + +// ListObservatoryPageTests returns a list of tests for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-list-test-history +func (api *API) ListObservatoryPageTests(ctx context.Context, rc *ResourceContainer, params ListObservatoryPageTestParams) ([]ObservatoryPageTest, *ResultInfo, error) { + if params.URL == "" { + return nil, nil, ErrMissingObservatoryUrl + } + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = listObservatoryPageTestDefaultPageSize + } + if params.Page < 1 { + params.Page = 1 + } + var tests []ObservatoryPageTest + var lastResultInfo ResultInfo + for { + // cannot use buildURI because params.URL contains "/" that should be encoded and buildURI will double encode %2F into %252F + v, _ := query.Values(params) + uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests?%s", rc.Identifier, url.PathEscape(params.URL), v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, nil, err + } + var r ObservatoryPageTestsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + tests = append(tests, r.Result...) + lastResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + return tests, &lastResultInfo, nil +} + +type CreateObservatoryPageTestParams struct { + URL string + Settings CreateObservatoryPageTestSettings +} +type CreateObservatoryPageTestSettings struct { + Region string `json:"region"` +} + +type ObservatoryPageTestResponse struct { + Response + Result ObservatoryPageTest `json:"result"` +} + +// CreateObservatoryPageTest starts a test for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-create-test +func (api *API) CreateObservatoryPageTest(ctx context.Context, rc *ResourceContainer, params CreateObservatoryPageTestParams) (*ObservatoryPageTest, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests", rc.Identifier, url.PathEscape(params.URL)) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Settings) + if err != nil { + return nil, err + } + var r ObservatoryPageTestResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type DeleteObservatoryPageTestsParams struct { + URL string `url:"-"` + Region string `url:"region"` +} + +type ObservatoryCountResponse struct { + Response + Result struct { + Count int `json:"count"` + } `json:"result"` +} + +// DeleteObservatoryPageTests deletes all tests for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-delete-tests +func (api *API) DeleteObservatoryPageTests(ctx context.Context, rc *ResourceContainer, params DeleteObservatoryPageTestsParams) (*int, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + // cannot use buildURI because params.URL contains "/" that should be encoded and buildURI will double encode %2F into %252F + v, _ := query.Values(params) + uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests?%s", rc.Identifier, url.PathEscape(params.URL), v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + var r ObservatoryCountResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result.Count, nil +} + +type GetObservatoryPageTestParams struct { + URL string + TestID string +} + +// GetObservatoryPageTest returns a specific test for a page. +// +// API reference: https://api.cloudflare.com/#speed-get-test +func (api *API) GetObservatoryPageTest(ctx context.Context, rc *ResourceContainer, params GetObservatoryPageTestParams) (*ObservatoryPageTest, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + if params.TestID == "" { + return nil, ErrMissingObservatoryTestID + } + uri := fmt.Sprintf("/zones/%s/speed_api/pages/%s/tests/%s", rc.Identifier, url.PathEscape(params.URL), params.TestID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r ObservatoryPageTestResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type CreateObservatoryScheduledPageTestParams struct { + URL string `url:"-" json:"-"` + Region string `url:"region" json:"-"` + Frequency string `url:"frequency" json:"-"` +} + +type ObservatoryScheduledPageTest struct { + Schedule ObservatorySchedule `json:"schedule"` + Test ObservatoryPageTest `json:"test"` +} + +type CreateObservatoryScheduledPageTestResponse struct { + Response + Result ObservatoryScheduledPageTest `json:"result"` +} + +// CreateObservatoryScheduledPageTest creates a scheduled test for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-create-scheduled-test +func (api *API) CreateObservatoryScheduledPageTest(ctx context.Context, rc *ResourceContainer, params CreateObservatoryScheduledPageTestParams) (*ObservatoryScheduledPageTest, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + // cannot use buildURI because params.URL contains "/" that should be encoded and buildURI will double encode %2F into %252F + v, _ := query.Values(params) + uri := fmt.Sprintf("/zones/%s/speed_api/schedule/%s?%s", rc.Identifier, url.PathEscape(params.URL), v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return nil, err + } + var r CreateObservatoryScheduledPageTestResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type GetObservatoryScheduledPageTestParams struct { + URL string `url:"-"` + Region string `url:"region"` +} + +type ObservatoryScheduleResponse struct { + Response + Result ObservatorySchedule `json:"result"` +} + +// GetObservatoryScheduledPageTest returns the test schedule for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-get-scheduled-test +func (api *API) GetObservatoryScheduledPageTest(ctx context.Context, rc *ResourceContainer, params GetObservatoryScheduledPageTestParams) (*ObservatorySchedule, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + // cannot use buildURI because params.URL contains "/" that should be encoded and buildURI will double encode %2F into %252F + v, _ := query.Values(params) + uri := fmt.Sprintf("/zones/%s/speed_api/schedule/%s?%s", rc.Identifier, url.PathEscape(params.URL), v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r ObservatoryScheduleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type DeleteObservatoryScheduledPageTestParams struct { + URL string `url:"-"` + Region string `url:"region"` +} + +// DeleteObservatoryScheduledPageTest deletes the test schedule for a page in a specific region. +// +// API reference: https://api.cloudflare.com/#speed-delete-scheduled-test +func (api *API) DeleteObservatoryScheduledPageTest(ctx context.Context, rc *ResourceContainer, params DeleteObservatoryScheduledPageTestParams) (*int, error) { + if params.URL == "" { + return nil, ErrMissingObservatoryUrl + } + // cannot use buildURI because params.URL contains "/" that should be encoded and buildURI will double encode %2F into %252F + v, _ := query.Values(params) + uri := fmt.Sprintf("/zones/%s/speed_api/schedule/%s?%s", rc.Identifier, url.PathEscape(params.URL), v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + var r ObservatoryCountResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result.Count, nil +} diff --git a/pkg/cloudflare-go/observatory_test.go b/pkg/cloudflare-go/observatory_test.go new file mode 100644 index 000000000..fa897141d --- /dev/null +++ b/pkg/cloudflare-go/observatory_test.go @@ -0,0 +1,464 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var testURL = "example.com/a/b" +var escapedTestURL = url.PathEscape(testURL) +var region = "us-central1" +var regionLabel = "Iowa, USA" +var frequency = "DAILY" +var observatoryTestID = "52cc96c9-b709-4ffe-9048-338853d3db46" +var date = time.Now().UTC() + +var pageJSON = fmt.Sprintf(` +{ + "url": "%[1]s", + "region": { + "value": "%[2]s", + "label": "%[3]s" + }, + "scheduleFrequency": "%[4]s", + "tests": [ + { + "id": "%[5]s", + "date": "%[6]s", + "url": "%[1]s", + "scheduleFrequency": "%[4]s", + "region": { + "value": "%[2]s", + "label": "%[3]s" + }, + "mobileReport": { + "performanceScore": 100, + "ttfb": 10, + "fcp": 10, + "lcp": 10, + "tti": 10, + "tbt": 10, + "si": 10, + "cls": 0.10, + "state": "COMPLETED", + "deviceType": "DESKTOP" + }, + "desktopReport": { + "performanceScore": 100, + "ttfb": 10, + "fcp": 10, + "lcp": 10, + "tti": 10, + "tbt": 10, + "si": 10, + "cls": 0.10, + "state": "COMPLETED", + "deviceType": "DESKTOP" + } + } + ] +}`, testURL, region, regionLabel, frequency, observatoryTestID, date.Format(time.RFC3339Nano)) + +var scheduledPageTestJSON = fmt.Sprintf(` +{ + "schedule": { + "url": "%[1]s", + "region": "%[2]s", + "frequency": "%[3]s" + }, + "test": %[4]s +} +`, testURL, region, frequency, pageTestJSON) + +var scheduleJSON = fmt.Sprintf(` +{ + "url": "%[1]s", + "region": "%[2]s", + "frequency": "%[3]s" +} +`, testURL, region, frequency) + +var pageTestJSON = fmt.Sprintf(` +{ + "id": "%[1]s", + "date": "%[2]s", + "url": "%[3]s", + "region": { + "value": "%[4]s", + "label": "%[5]s" + }, + "scheduleFrequency": "%[6]s", + "mobileReport": %[7]s, + "desktopReport": %[7]s +}`, observatoryTestID, date.Format(time.RFC3339Nano), testURL, region, regionLabel, frequency, reportJSON, reportJSON) + +var reportJSON = ` +{ + "state": "COMPLETED", + "deviceType": "DESKTOP", + "performanceScore": 100, + "ttfb": 10, + "fcp": 10, + "lcp": 10, + "tti": 10, + "tbt": 10, + "si": 10, + "cls": 0.10 +} +` + +var report = ObservatoryLighthouseReport{ + PerformanceScore: 100, + State: "COMPLETED", + DeviceType: "DESKTOP", + TTFB: 10, + FCP: 10, + LCP: 10, + TTI: 10, + TBT: 10, + SI: 10, + CLS: 0.10, + Error: nil, +} + +var page = ObservatoryPage{ + URL: testURL, + Region: labeledRegion{ + Value: region, + Label: regionLabel, + }, + ScheduleFrequency: frequency, + Tests: []ObservatoryPageTest{ + pageTest, + }, +} +var pageTest = ObservatoryPageTest{ + ID: observatoryTestID, + Date: &date, + URL: testURL, + Region: labeledRegion{ + Value: region, + Label: regionLabel, + }, + ScheduleFrequency: &frequency, + MobileReport: report, + DesktopReport: report, +} + +var scheduledPageTest = ObservatoryScheduledPageTest{ + Schedule: ObservatorySchedule{ + URL: testURL, + Region: region, + Frequency: frequency, + }, + Test: pageTest, +} + +var schedule = ObservatorySchedule{ + URL: testURL, + Region: region, + Frequency: frequency, +} + +func TestListObservatoryPages(t *testing.T) { + setup() + defer teardown() + + 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") + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/pages", r.URL.EscapedPath()) + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, pageJSON) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages", handler) + want := []ObservatoryPage{ + page, + } + pages, err := client.ListObservatoryPages(context.Background(), ZoneIdentifier(testZoneID), ListObservatoryPagesParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, pages) + } +} + +func TestObservatoryPageTrend(t *testing.T) { + setup() + defer teardown() + + 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, "DESKTOP", r.URL.Query().Get("deviceType")) + assert.Equal(t, "America/Chicago", r.URL.Query().Get("tz")) + assert.Equal(t, "fcp,lcp", r.URL.Query().Get("metrics")) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/pages/"+escapedTestURL+"/trend", r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "performanceScore": [null, 100], + "ttfb": [null, 10], + "fcp": [null, 10], + "lcp": [null, 10], + "tti": [null, 10], + "tbt": [null, 10], + "si": [null, 10], + "cls": [null, 0.10] + } + } + `) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/trend", handler) + want := ObservatoryPageTrend{ + PerformanceScore: []*int{nil, IntPtr(100)}, + TTFB: []*int{nil, IntPtr(10)}, + FCP: []*int{nil, IntPtr(10)}, + LCP: []*int{nil, IntPtr(10)}, + TTI: []*int{nil, IntPtr(10)}, + TBT: []*int{nil, IntPtr(10)}, + SI: []*int{nil, IntPtr(10)}, + CLS: []*float64{nil, Float64Ptr(0.10)}, + } + trend, err := client.GetObservatoryPageTrend(context.Background(), ZoneIdentifier(testZoneID), GetObservatoryPageTrendParams{ + URL: testURL, + Region: region, + DeviceType: "DESKTOP", + Start: &date, + End: &date, + Timezone: "America/Chicago", + Metrics: []string{"fcp,lcp"}, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, trend) + } +} + +func TestListObservatoryPageTests(t *testing.T) { + setup() + defer teardown() + + 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, region, r.URL.Query().Get("region")) + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "10", r.URL.Query().Get("per_page")) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/pages/"+escapedTestURL+"/tests", r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [%s] + } + `, pageTestJSON) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests", handler) + want := []ObservatoryPageTest{ + pageTest, + } + tests, _, err := client.ListObservatoryPageTests(context.Background(), ZoneIdentifier(testZoneID), ListObservatoryPageTestParams{ + URL: testURL, + ResultInfo: ResultInfo{ + Page: 1, + PerPage: 10, + }, + Region: region, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, tests) + } +} + +func TestCreateObservatoryPageTest(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + b, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.True(t, strings.Contains(string(b), region)) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/pages/"+escapedTestURL+"/tests", r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, pageTestJSON) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests", handler) + want := pageTest + test, err := client.CreateObservatoryPageTest(context.Background(), ZoneIdentifier(testZoneID), CreateObservatoryPageTestParams{ + URL: testURL, + Settings: CreateObservatoryPageTestSettings{ + Region: region, + }, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, test) + } +} + +func TestDeleteObservatoryPageTests(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + assert.Equal(t, region, r.URL.Query().Get("region")) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/pages/"+escapedTestURL+"/tests", r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "count": 2 + } + } + `) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests", handler) + want := 2 + count, err := client.DeleteObservatoryPageTests(context.Background(), ZoneIdentifier(testZoneID), DeleteObservatoryPageTestsParams{ + URL: testURL, + Region: region, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, count) + } +} + +func TestGetObservatoryPageTest(t *testing.T) { + setup() + defer teardown() + + 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, "/zones/"+testZoneID+"/speed_api/pages/"+escapedTestURL+"/tests/"+observatoryTestID, r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, pageTestJSON) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/pages/"+testURL+"/tests/"+observatoryTestID, handler) + want := pageTest + test, err := client.GetObservatoryPageTest(context.Background(), ZoneIdentifier(testZoneID), GetObservatoryPageTestParams{ + TestID: observatoryTestID, + URL: testURL, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, test) + } +} + +func TestCreateObservatoryScheduledPageTest(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + assert.Equal(t, frequency, r.URL.Query().Get("frequency")) + assert.Equal(t, region, r.URL.Query().Get("region")) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/schedule/"+escapedTestURL, r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, scheduledPageTestJSON) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/schedule/"+testURL, handler) + want := scheduledPageTest + pages, err := client.CreateObservatoryScheduledPageTest(context.Background(), ZoneIdentifier(testZoneID), CreateObservatoryScheduledPageTestParams{ + Frequency: frequency, + URL: testURL, + Region: region, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, pages) + } +} + +func TestObservatoryScheduledPageTest(t *testing.T) { + setup() + defer teardown() + + 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, region, r.URL.Query().Get("region")) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/schedule/"+escapedTestURL, r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, scheduleJSON) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/schedule/"+testURL, handler) + want := schedule + schedule, err := client.GetObservatoryScheduledPageTest(context.Background(), ZoneIdentifier(testZoneID), GetObservatoryScheduledPageTestParams{ + URL: testURL, + Region: region, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, schedule) + } +} + +func TestDeleteObservatoryScheduledPageTest(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + assert.Equal(t, region, r.URL.Query().Get("region")) + assert.Equal(t, "/zones/"+testZoneID+"/speed_api/schedule/"+escapedTestURL, r.URL.EscapedPath()) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "count": 2 + } + } + `) + } + mux.HandleFunc("/zones/"+testZoneID+"/speed_api/schedule/"+testURL, handler) + want := 2 + count, err := client.DeleteObservatoryScheduledPageTest(context.Background(), ZoneIdentifier(testZoneID), DeleteObservatoryScheduledPageTestParams{ + URL: testURL, + Region: region, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, count) + } +} diff --git a/pkg/cloudflare-go/options.go b/pkg/cloudflare-go/options.go new file mode 100644 index 000000000..0638a2d5c --- /dev/null +++ b/pkg/cloudflare-go/options.go @@ -0,0 +1,106 @@ +package cloudflare + +import ( + "net/http" + "time" + + "golang.org/x/time/rate" +) + +// Option is a functional option for configuring the API client. +type Option func(*API) error + +// HTTPClient accepts a custom *http.Client for making API calls. +func HTTPClient(client *http.Client) Option { + return func(api *API) error { + api.httpClient = client + return nil + } +} + +// Headers allows you to set custom HTTP headers when making API calls (e.g. for +// satisfying HTTP proxies, or for debugging). +func Headers(headers http.Header) Option { + return func(api *API) error { + api.headers = headers + return nil + } +} + +// UsingRateLimit applies a non-default rate limit to client API requests +// If not specified the default of 4rps will be applied. +func UsingRateLimit(rps float64) Option { + return func(api *API) error { + // because ratelimiter doesnt do any windowing + // setting burst makes it difficult to enforce a fixed rate + // so setting it equal to 1 this effectively disables bursting + // this doesn't check for sensible values, ultimately the api will enforce that the value is ok + api.rateLimiter = rate.NewLimiter(rate.Limit(rps), 1) + return nil + } +} + +// UsingRetryPolicy applies a non-default number of retries and min/max retry delays +// This will be used when the client exponentially backs off after errored requests. +func UsingRetryPolicy(maxRetries int, minRetryDelaySecs int, maxRetryDelaySecs int) Option { + // seconds is very granular for a minimum delay - but this is only in case of failure + return func(api *API) error { + api.retryPolicy = RetryPolicy{ + MaxRetries: maxRetries, + MinRetryDelay: time.Duration(minRetryDelaySecs) * time.Second, + MaxRetryDelay: time.Duration(maxRetryDelaySecs) * time.Second, + } + return nil + } +} + +// UsingLogger can be set if you want to get log output from this API instance +// By default no log output is emitted. +func UsingLogger(logger Logger) Option { + return func(api *API) error { + api.logger = logger + return nil + } +} + +// UserAgent can be set if you want to send a software name and version for HTTP access logs. +// It is recommended to set it in order to help future Customer Support diagnostics +// and prevent collateral damage by sharing generic User-Agent string with abusive users. +// E.g. "my-software/1.2.3". By default generic Go User-Agent is used. +func UserAgent(userAgent string) Option { + return func(api *API) error { + api.UserAgent = userAgent + return nil + } +} + +// BaseURL allows you to override the default HTTP base URL used for API calls. +func BaseURL(baseURL string) Option { + return func(api *API) error { + api.BaseURL = baseURL + return nil + } +} + +func Debug(debug bool) Option { + return func(api *API) error { + api.Debug = debug + return nil + } +} + +// parseOptions parses the supplied options functions and returns a configured +// *API instance. +func (api *API) parseOptions(opts ...Option) error { + // Range over each options function and apply it to our API type to + // configure it. Options functions are applied in order, with any + // conflicting options overriding earlier calls. + for _, option := range opts { + err := option(api) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/cloudflare-go/origin_ca.go b/pkg/cloudflare-go/origin_ca.go new file mode 100644 index 000000000..ff8589a3c --- /dev/null +++ b/pkg/cloudflare-go/origin_ca.go @@ -0,0 +1,233 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// OriginCACertificate represents a Cloudflare-issued certificate. +// +// API reference: https://api.cloudflare.com/#cloudflare-ca +type OriginCACertificate struct { + ID string `json:"id"` + Certificate string `json:"certificate"` + Hostnames []string `json:"hostnames"` + ExpiresOn time.Time `json:"expires_on"` + RequestType string `json:"request_type"` + RequestValidity int `json:"requested_validity"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + CSR string `json:"csr"` +} + +type CreateOriginCertificateParams struct { + ID string `json:"id"` + Certificate string `json:"certificate"` + Hostnames []string `json:"hostnames"` + ExpiresOn time.Time `json:"expires_on"` + RequestType string `json:"request_type"` + RequestValidity int `json:"requested_validity"` + RevokedAt time.Time `json:"revoked_at,omitempty"` + CSR string `json:"csr"` +} + +// UnmarshalJSON handles custom parsing from an API response to an OriginCACertificate +// http://choly.ca/post/go-json-marshalling/ +func (c *OriginCACertificate) UnmarshalJSON(data []byte) error { + type Alias OriginCACertificate + + aux := &struct { + ExpiresOn string `json:"expires_on"` + *Alias + }{ + Alias: (*Alias)(c), + } + + var err error + + if err = json.Unmarshal(data, &aux); err != nil { + return err + } + + // This format comes from time.Time.String() source + c.ExpiresOn, err = time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", aux.ExpiresOn) + + if err != nil { + c.ExpiresOn, err = time.Parse(time.RFC3339, aux.ExpiresOn) + } + + if err != nil { + return err + } + + return nil +} + +// ListOriginCertificatesParams represents the parameters used to list +// Cloudflare-issued certificates. +type ListOriginCertificatesParams struct { + ZoneID string `url:"zone_id,omitempty"` +} + +// OriginCACertificateID represents the ID of the revoked certificate from the Revoke Certificate endpoint. +type OriginCACertificateID struct { + ID string `json:"id"` +} + +// originCACertificateResponse represents the response from the Create Certificate and the Certificate Details endpoints. +type originCACertificateResponse struct { + Response + Result OriginCACertificate `json:"result"` +} + +// originCACertificateResponseList represents the response from the List Certificates endpoint. +type originCACertificateResponseList struct { + Response + Result []OriginCACertificate `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// originCACertificateResponseRevoke represents the response from the Revoke Certificate endpoint. +type originCACertificateResponseRevoke struct { + Response + Result OriginCACertificateID `json:"result"` +} + +// CreateOriginCACertificate creates a Cloudflare-signed certificate. +// +// API reference: https://api.cloudflare.com/#cloudflare-ca-create-certificate +func (api *API) CreateOriginCACertificate(ctx context.Context, params CreateOriginCertificateParams) (*OriginCACertificate, error) { + res, err := api.makeRequestContext(ctx, http.MethodPost, "/certificates", params) + if err != nil { + return &OriginCACertificate{}, err + } + + var originResponse *originCACertificateResponse + + err = json.Unmarshal(res, &originResponse) + + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !originResponse.Success { + return nil, errors.New(errRequestNotSuccessful) + } + + return &originResponse.Result, nil +} + +// ListOriginCACertificates lists all Cloudflare-issued certificates. +// +// API reference: https://api.cloudflare.com/#cloudflare-ca-list-certificates +func (api *API) ListOriginCACertificates(ctx context.Context, params ListOriginCertificatesParams) ([]OriginCACertificate, error) { + uri := buildURI("/certificates", params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + if err != nil { + return nil, err + } + + var originResponse *originCACertificateResponseList + + err = json.Unmarshal(res, &originResponse) + + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !originResponse.Success { + return nil, errors.New(errRequestNotSuccessful) + } + + return originResponse.Result, nil +} + +// GetOriginCACertificate returns the details for a Cloudflare-issued +// certificate. +// +// API reference: https://api.cloudflare.com/#cloudflare-ca-certificate-details +func (api *API) GetOriginCACertificate(ctx context.Context, certificateID string) (*OriginCACertificate, error) { + uri := fmt.Sprintf("/certificates/%s", certificateID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + if err != nil { + return nil, err + } + + var originResponse *originCACertificateResponse + + err = json.Unmarshal(res, &originResponse) + + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !originResponse.Success { + return nil, errors.New(errRequestNotSuccessful) + } + + return &originResponse.Result, nil +} + +// RevokeOriginCACertificate revokes a created certificate for a zone. +// +// API reference: https://api.cloudflare.com/#cloudflare-ca-revoke-certificate +func (api *API) RevokeOriginCACertificate(ctx context.Context, certificateID string) (*OriginCACertificateID, error) { + uri := fmt.Sprintf("/certificates/%s", certificateID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return nil, err + } + + var originResponse *originCACertificateResponseRevoke + + err = json.Unmarshal(res, &originResponse) + + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !originResponse.Success { + return nil, errors.New(errRequestNotSuccessful) + } + + return &originResponse.Result, nil +} + +// Gets the Cloudflare Origin CA Root Certificate for a given algorithm in PEM format. +// Algorithm must be one of ['ecc', 'rsa']. +func GetOriginCARootCertificate(algorithm string) ([]byte, error) { + var url string + switch algorithm { + case "ecc": + url = originCARootCertEccURL + case "rsa": + url = originCARootCertRsaURL + default: + return nil, fmt.Errorf("invalid algorithm: must be one of ['ecc', 'rsa']") + } + + resp, err := http.Get(url) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(errRequestNotSuccessful) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("Response body could not be read: %w", err) + } + + return body, nil +} diff --git a/pkg/cloudflare-go/origin_ca_test.go b/pkg/cloudflare-go/origin_ca_test.go new file mode 100644 index 000000000..df79ace0a --- /dev/null +++ b/pkg/cloudflare-go/origin_ca_test.go @@ -0,0 +1,259 @@ +package cloudflare + +import ( + "context" + "encoding/pem" + "fmt" + "net/http" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +var ( + payloadTemplate = `{"expires_on":"%s"}` + unmarshalTime = time.Now().UTC().Round(time.Second) +) + +func TestOriginCA_UnmarshalRFC3339(t *testing.T) { + payload := fmt.Sprintf(payloadTemplate, unmarshalTime.Format(time.RFC3339)) + + var cert OriginCACertificate + err := json.Unmarshal([]byte(payload), &cert) + if assert.NoError(t, err) { + assert.Equal(t, unmarshalTime, cert.ExpiresOn) + } +} + +func TestOriginCA_UnmarshalString(t *testing.T) { + payload := fmt.Sprintf(payloadTemplate, unmarshalTime.String()) + + var cert OriginCACertificate + err := json.Unmarshal([]byte(payload), &cert) + if assert.NoError(t, err) { + assert.Equal(t, unmarshalTime, cert.ExpiresOn) + } +} + +func TestOriginCA_UnmarshalOther(t *testing.T) { + payload := fmt.Sprintf(payloadTemplate, unmarshalTime.Format(time.RFC1123)) + + var cert OriginCACertificate + err := json.Unmarshal([]byte(payload), &cert) + assert.Error(t, err) + assert.Equal(t, OriginCACertificate{}, cert) +} + +func TestOriginCA_CreateOriginCertificate(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/certificates", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %ss", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0x47530d8f561faa08", + "certificate": "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + "hostnames": [ + "example.com", + "*.another.com" + ], + "expires_on": "2014-01-01T05:20:00.12345Z", + "request_type": "origin-rsa", + "requested_validity": 5475, + "csr": "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----" + } +}`) + }) + + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + testCertificate := CreateOriginCertificateParams{ + ID: "0x47530d8f561faa08", + Certificate: "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + Hostnames: []string{"example.com", "*.another.com"}, + ExpiresOn: expiresOn, + RequestType: "origin-rsa", + RequestValidity: 5475, + CSR: "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----", + } + + createdCertificate, err := client.CreateOriginCACertificate(context.Background(), testCertificate) + + if assert.NoError(t, err) { + assert.Equal(t, &OriginCACertificate{ + ID: "0x47530d8f561faa08", + Certificate: "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + Hostnames: []string{"example.com", "*.another.com"}, + ExpiresOn: expiresOn, + RequestType: "origin-rsa", + RequestValidity: 5475, + CSR: "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----", + }, createdCertificate) + } +} + +func TestOriginCA_OriginCertificates(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/certificates", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %ss", r.Method) + assert.Equal(t, testZoneID, r.URL.Query().Get("zone_id"), "Expected zone_id '', got %%s", testZoneID) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "0x47530d8f561faa08", + "certificate": "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + "hostnames": [ + "example.com", + "*.another.com" + ], + "expires_on": "2014-01-01T05:20:00.12345Z", + "request_type": "origin-rsa", + "requested_validity": 5475, + "csr": "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } +}`) + }) + + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + testCertificate := OriginCACertificate{ + ID: "0x47530d8f561faa08", + Certificate: "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + Hostnames: []string{"example.com", "*.another.com"}, + ExpiresOn: expiresOn, + RequestType: "origin-rsa", + RequestValidity: 5475, + CSR: "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----", + } + + certs, err := client.ListOriginCACertificates(context.Background(), ListOriginCertificatesParams{ZoneID: testZoneID}) + + if assert.NoError(t, err) { + assert.IsType(t, []OriginCACertificate{}, certs, "Expected type []OriginCACertificate and got %v", certs) + assert.Equal(t, certs[0], testCertificate) + } +} + +func TestOriginCA_OriginCertificate(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/certificates/0x47530d8f561faa08", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %ss", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0x47530d8f561faa08", + "certificate": "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + "hostnames": [ + "example.com", + "*.another.com" + ], + "expires_on": "2014-01-01T05:20:00.12345Z", + "request_type": "origin-rsa", + "requested_validity": 5475, + "revoked_at": "2014-01-02T05:20:00.12345Z", + "csr": "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----" + } +}`) + }) + + expiresOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + revokedAt, _ := time.Parse(time.RFC3339, "2014-01-02T05:20:00.12345Z") + + testCertificate := OriginCACertificate{ + ID: "0x47530d8f561faa08", + Certificate: "-----BEGIN CERTIFICATE-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE-----", + Hostnames: []string{"example.com", "*.another.com"}, + ExpiresOn: expiresOn, + RequestType: "origin-rsa", + RequestValidity: 5475, + RevokedAt: revokedAt, + CSR: "-----BEGIN CERTIFICATE REQUEST-----MIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFV0YWgxDzANBgNVBAcMBkxpbmRvbjEWMBQGA1UECgwNRGlnaUNlcnQgSW5jLjERMA8GA1UECwwIRGlnaUNlcnQxHTAbBgNVBAMMFGV4YW1wbGUuZGlnaWNlcnQuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8+To7d+2kPWeBv/orU3LVbJwDrSQbeKamCmowp5bqDxIwV20zqRb7APUOKYoVEFFOEQs6T6gImnIolhbiH6m4zgZ/CPvWBOkZc+c1Po2EmvBz+AD5sBdT5kzGQA6NbWyZGldxRthNLOs1efOhdnWFuhI162qmcflgpiIWDuwq4C9f+YkeJhNn9dF5+owm8cOQmDrV8NNdiTqin8q3qYAHHJRW28glJUCZkTZwIaSR6crBQ8TbYNE0dc+Caa3DOIkz1EOsHWzTx+n0zKfqcbgXi4DJx+C1bjptYPRBPZL8DAeWuA8ebudVT44yEp82G96/Ggcf7F33xMxe0yc+Xa6owIDAQABoAAwDQYJKoZIhvcNAQEFBQADggEBAB0kcrFccSmFDmxox0Ne01UIqSsDqHgL+XmHTXJwre6DhJSZwbvEtOK0G3+dr4Fs11WuUNt5qcLsx5a8uk4G6AKHMzuhLsJ7XZjgmQXGECpYQ4mC3yT3ZoCGpIXbw+iP3lmEEXgaQL0Tx5LFl/okKbKYwIqNiyKWOMj7ZR/wxWg/ZDGRs55xuoeLDJ/ZRFf9bI+IaCUd1YrfYcHIl3G87Av+r49YVwqRDT0VDV7uLgqn29XI1PpVUNCPQGn9p/eX6Qo7vpDaPybRtA2R7XLKjQaF9oXWeCUqy1hvJac9QFO297Ob1alpHPoZ7mWiEuJwjBPii6a9M9G30nUo39lBi1w=-----END CERTIFICATE REQUEST-----", + } + + cert, err := client.GetOriginCACertificate(context.Background(), testCertificate.ID) + + if assert.NoError(t, err) { + assert.IsType(t, &OriginCACertificate{}, cert, "Expected type &OriginCACertificate and got %v", cert) + assert.Equal(t, cert, &testCertificate) + } +} + +func TestOriginCA_RevokeCertificate(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/certificates/0x47530d8f561faa08", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %ss", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0x47530d8f561faa08" + } +}`) + }) + + testCertificate := OriginCACertificateID{ + ID: "0x47530d8f561faa08", + } + + cert, err := client.RevokeOriginCACertificate(context.Background(), testCertificate.ID) + + if assert.NoError(t, err) { + assert.IsType(t, &OriginCACertificateID{}, cert, "Expected type &OriginCACertificateID and got %v", cert) + assert.Equal(t, cert, &testCertificate) + } +} + +func TestOriginCA_OriginCARootCertificate(t *testing.T) { + setup() + defer teardown() + + // This test intentionally hits the live endpoints to ensure these are + // - **active** (as they may change over time) + // - not subject to bot management + // - return the expected content (type) + + algorithms := []string{"ecc", "rsa"} + + for _, algorithm := range algorithms { + t.Logf("get origin CA root certificate for algorithm %s", algorithm) + rootCACert, err := GetOriginCARootCertificate(algorithm) + + if assert.NoError(t, err) { + assert.NotNil(t, rootCACert) + + // Asserts that the content returned is a PEM formatted certificate + p, _ := pem.Decode(rootCACert) + assert.NotNil(t, p) + assert.Equal(t, "CERTIFICATE", p.Type) + } + } +} diff --git a/pkg/cloudflare-go/page_rules.go b/pkg/cloudflare-go/page_rules.go new file mode 100644 index 000000000..c4fa391f5 --- /dev/null +++ b/pkg/cloudflare-go/page_rules.go @@ -0,0 +1,240 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// PageRuleTarget is the target to evaluate on a request. +// +// Currently Target must always be "url" and Operator must be "matches". Value +// is the URL pattern to match against. +type PageRuleTarget struct { + Target string `json:"target"` + Constraint struct { + Operator string `json:"operator"` + Value string `json:"value"` + } `json:"constraint"` +} + +/* +PageRuleAction is the action to take when the target is matched. + +Valid IDs are: + + always_online + always_use_https + automatic_https_rewrites + browser_cache_ttl + browser_check + bypass_cache_on_cookie + cache_by_device_type + cache_deception_armor + cache_level + cache_key_fields + cache_on_cookie + disable_apps + disable_performance + disable_railgun + disable_security + edge_cache_ttl + email_obfuscation + explicit_cache_control + forwarding_url + host_header_override + ip_geolocation + minify + mirage + opportunistic_encryption + origin_error_page_pass_thru + polish + resolve_override + respect_strong_etag + response_buffering + rocket_loader + security_level + server_side_exclude + sort_query_string_for_cache + ssl + true_client_ip_header + waf +*/ +type PageRuleAction struct { + ID string `json:"id"` + Value interface{} `json:"value"` +} + +// PageRuleActions maps API action IDs to human-readable strings. +var PageRuleActions = map[string]string{ + "always_online": "Always Online", // Value of type string + "always_use_https": "Always Use HTTPS", // Value of type interface{} + "automatic_https_rewrites": "Automatic HTTPS Rewrites", // Value of type string + "browser_cache_ttl": "Browser Cache TTL", // Value of type int + "browser_check": "Browser Integrity Check", // Value of type string + "bypass_cache_on_cookie": "Bypass Cache on Cookie", // Value of type string + "cache_by_device_type": "Cache By Device Type", // Value of type string + "cache_deception_armor": "Cache Deception Armor", // Value of type string + "cache_level": "Cache Level", // Value of type string + "cache_key_fields": "Custom Cache Key", // Value of type map[string]interface + "cache_on_cookie": "Cache On Cookie", // Value of type string + "disable_apps": "Disable Apps", // Value of type interface{} + "disable_performance": "Disable Performance", // Value of type interface{} + "disable_railgun": "Disable Railgun", // Value of type string + "disable_security": "Disable Security", // Value of type interface{} + "edge_cache_ttl": "Edge Cache TTL", // Value of type int + "email_obfuscation": "Email Obfuscation", // Value of type string + "explicit_cache_control": "Origin Cache Control", // Value of type string + "forwarding_url": "Forwarding URL", // Value of type map[string]interface + "host_header_override": "Host Header Override", // Value of type string + "ip_geolocation": "IP Geolocation Header", // Value of type string + "minify": "Minify", // Value of type map[string]interface + "mirage": "Mirage", // Value of type string + "opportunistic_encryption": "Opportunistic Encryption", // Value of type string + "origin_error_page_pass_thru": "Origin Error Page Pass-thru", // Value of type string + "polish": "Polish", // Value of type string + "resolve_override": "Resolve Override", // Value of type string + "respect_strong_etag": "Respect Strong ETags", // Value of type string + "response_buffering": "Response Buffering", // Value of type string + "rocket_loader": "Rocker Loader", // Value of type string + "security_level": "Security Level", // Value of type string + "server_side_exclude": "Server Side Excludes", // Value of type string + "sort_query_string_for_cache": "Query String Sort", // Value of type string + "ssl": "SSL", // Value of type string + "true_client_ip_header": "True Client IP Header", // Value of type string + "waf": "Web Application Firewall", // Value of type string +} + +// PageRule describes a Page Rule. +type PageRule struct { + ID string `json:"id,omitempty"` + Targets []PageRuleTarget `json:"targets"` + Actions []PageRuleAction `json:"actions"` + Priority int `json:"priority"` + Status string `json:"status"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` +} + +// PageRuleDetailResponse is the API response, containing a single PageRule. +type PageRuleDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result PageRule `json:"result"` +} + +// PageRulesResponse is the API response, containing an array of PageRules. +type PageRulesResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result []PageRule `json:"result"` +} + +// CreatePageRule creates a new Page Rule for a zone. +// +// API reference: https://api.cloudflare.com/#page-rules-for-a-zone-create-a-page-rule +func (api *API) CreatePageRule(ctx context.Context, zoneID string, rule PageRule) (*PageRule, error) { + uri := fmt.Sprintf("/zones/%s/pagerules", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, rule) + if err != nil { + return nil, err + } + var r PageRuleDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +// ListPageRules returns all Page Rules for a zone. +// +// API reference: https://api.cloudflare.com/#page-rules-for-a-zone-list-page-rules +func (api *API) ListPageRules(ctx context.Context, zoneID string) ([]PageRule, error) { + uri := fmt.Sprintf("/zones/%s/pagerules", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PageRule{}, err + } + var r PageRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []PageRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// PageRule fetches detail about one Page Rule for a zone. +// +// API reference: https://api.cloudflare.com/#page-rules-for-a-zone-page-rule-details +func (api *API) PageRule(ctx context.Context, zoneID, ruleID string) (PageRule, error) { + uri := fmt.Sprintf("/zones/%s/pagerules/%s", zoneID, ruleID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PageRule{}, err + } + var r PageRuleDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PageRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ChangePageRule lets you change individual settings for a Page Rule. This is +// in contrast to UpdatePageRule which replaces the entire Page Rule. +// +// API reference: https://api.cloudflare.com/#page-rules-for-a-zone-change-a-page-rule +func (api *API) ChangePageRule(ctx context.Context, zoneID, ruleID string, rule PageRule) error { + uri := fmt.Sprintf("/zones/%s/pagerules/%s", zoneID, ruleID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, rule) + if err != nil { + return err + } + var r PageRuleDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// UpdatePageRule lets you replace a Page Rule. This is in contrast to +// ChangePageRule which lets you change individual settings. +// +// API reference: https://api.cloudflare.com/#page-rules-for-a-zone-update-a-page-rule +func (api *API) UpdatePageRule(ctx context.Context, zoneID, ruleID string, rule PageRule) error { + uri := fmt.Sprintf("/zones/%s/pagerules/%s", zoneID, ruleID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, rule) + if err != nil { + return err + } + var r PageRuleDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// DeletePageRule deletes a Page Rule for a zone. +// +// API reference: https://api.cloudflare.com/#page-rules-for-a-zone-delete-a-page-rule +func (api *API) DeletePageRule(ctx context.Context, zoneID, ruleID string) error { + uri := fmt.Sprintf("/zones/%s/pagerules/%s", zoneID, ruleID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r PageRuleDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} diff --git a/pkg/cloudflare-go/page_rules_example_test.go b/pkg/cloudflare-go/page_rules_example_test.go new file mode 100644 index 000000000..182f8c93b --- /dev/null +++ b/pkg/cloudflare-go/page_rules_example_test.go @@ -0,0 +1,110 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +var exampleNewPageRule = cloudflare.PageRule{ + Actions: []cloudflare.PageRuleAction{ + { + ID: "always_online", + Value: "on", + }, + { + ID: "ssl", + Value: "flexible", + }, + }, + Targets: []cloudflare.PageRuleTarget{ + { + Target: "url", + Constraint: struct { + Operator string "json:\"operator\"" + Value string "json:\"value\"" + }{Operator: "matches", Value: fmt.Sprintf("example.%s", domain)}, + }, + }, + Priority: 1, + Status: "active", +} + +func ExampleAPI_CreatePageRule() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + pageRule, err := api.CreatePageRule(context.Background(), zoneID, exampleNewPageRule) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", pageRule) +} + +func ExampleAPI_ListPageRules() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + pageRules, err := api.ListPageRules(context.Background(), zoneID) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", pageRules) + for _, r := range pageRules { + fmt.Printf("%+v\n", r) + } +} + +func ExampleAPI_PageRule() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + pageRules, err := api.PageRule(context.Background(), zoneID, "my_page_rule_id") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", pageRules) +} + +func ExampleAPI_DeletePageRule() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + err = api.DeletePageRule(context.Background(), zoneID, "my_page_rule_id") + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/cloudflare-go/page_rules_test.go b/pkg/cloudflare-go/page_rules_test.go new file mode 100644 index 000000000..fd7a1fd45 --- /dev/null +++ b/pkg/cloudflare-go/page_rules_test.go @@ -0,0 +1,198 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + pageRuleID = "15dae2fc158942f2adb1dd2a3d4273bc" + serverPageRuleDescription = `{ + "id": "%s", + "targets": [ + { + "target": "url", + "constraint": { + "operator": "matches", + "value": "example.%s" + } + } + ], + "actions": [ + { + "id": "always_online", + "value": "on" + }, + { + "id": "ssl", + "value": "flexible" + } + ], + "priority": 1, + "status": "active", + "created_on": "%[3]s", + "modified_on": "%[3]s" + } +` +) + +var testTimestamp = time.Now().UTC() +var expectedPageRuleStruct = PageRule{ + ID: pageRuleID, + Actions: []PageRuleAction{ + { + ID: "always_online", + Value: "on", + }, + { + ID: "ssl", + Value: "flexible", + }, + }, + Targets: []PageRuleTarget{ + { + Target: "url", + Constraint: struct { + Operator string "json:\"operator\"" + Value string "json:\"value\"" + }{Operator: "matches", Value: fmt.Sprintf("example.%s", testZoneID)}, + }, + }, + Priority: 1, + Status: "active", + CreatedOn: testTimestamp, + ModifiedOn: testTimestamp, +} + +func TestListPageRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 1, + "per_page": 25, + "count": 1, + "total_count": 1 + } + } + `, fmt.Sprintf(serverPageRuleDescription, pageRuleID, testZoneID, testTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/pagerules", handler) + want := []PageRule{expectedPageRuleStruct} + + actual, err := client.ListPageRules(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetPageRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(serverPageRuleDescription, pageRuleID, testZoneID, testTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/pagerules/"+pageRuleID, handler) + want := expectedPageRuleStruct + + actual, err := client.PageRule(context.Background(), testZoneID, pageRuleID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreatePageRule(t *testing.T) { + setup() + defer teardown() + newPageRule := PageRule{ + Actions: []PageRuleAction{ + { + ID: "always_online", + Value: "on", + }, + { + ID: "ssl", + Value: "flexible", + }, + }, + Targets: []PageRuleTarget{ + { + Target: "url", + Constraint: struct { + Operator string "json:\"operator\"" + Value string "json:\"value\"" + }{Operator: "matches", Value: fmt.Sprintf("example.%s", testZoneID)}, + }, + }, + Priority: 1, + Status: "active", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, fmt.Sprintf(serverPageRuleDescription, pageRuleID, testZoneID, testTimestamp.Format(time.RFC3339Nano))) + } + + mux.HandleFunc("/zones/"+testZoneID+"/pagerules", handler) + want := &expectedPageRuleStruct + + actual, err := client.CreatePageRule(context.Background(), testZoneID, newPageRule) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeletePageRule(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": null, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/pagerules/"+pageRuleID, handler) + + err := client.DeletePageRule(context.Background(), testZoneID, pageRuleID) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/page_shield.go b/pkg/cloudflare-go/page_shield.go new file mode 100644 index 000000000..d05c94972 --- /dev/null +++ b/pkg/cloudflare-go/page_shield.go @@ -0,0 +1,76 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// PageShield represents the page shield object minus any timestamps. +type PageShield struct { + Enabled *bool `json:"enabled,omitempty"` + UseCloudflareReportingEndpoint *bool `json:"use_cloudflare_reporting_endpoint,omitempty"` + UseConnectionURLPath *bool `json:"use_connection_url_path,omitempty"` +} + +type UpdatePageShieldSettingsParams struct { + Enabled *bool `json:"enabled,omitempty"` + UseCloudflareReportingEndpoint *bool `json:"use_cloudflare_reporting_endpoint,omitempty"` + UseConnectionURLPath *bool `json:"use_connection_url_path,omitempty"` +} + +// PageShieldSettings represents the page shield settings for a zone. +type PageShieldSettings struct { + PageShield + UpdatedAt string `json:"updated_at"` +} + +// PageShieldSettingsResponse represents the response from the page shield settings endpoint. +type PageShieldSettingsResponse struct { + PageShield PageShieldSettings `json:"result"` + Response +} + +type GetPageShieldSettingsParams struct{} + +// GetPageShieldSettings returns the page shield settings for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-get-page-shield-settings +func (api *API) GetPageShieldSettings(ctx context.Context, rc *ResourceContainer, params GetPageShieldSettingsParams) (*PageShieldSettingsResponse, error) { + uri := fmt.Sprintf("/zones/%s/page_shield", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var psResponse PageShieldSettingsResponse + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse, nil +} + +// UpdatePageShieldSettings updates the page shield settings for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-update-page-shield-settings +func (api *API) UpdatePageShieldSettings(ctx context.Context, rc *ResourceContainer, params UpdatePageShieldSettingsParams) (*PageShieldSettingsResponse, error) { + uri := fmt.Sprintf("/zones/%s/page_shield", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return nil, err + } + + var psResponse PageShieldSettingsResponse + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse, nil +} diff --git a/pkg/cloudflare-go/page_shield_connections.go b/pkg/cloudflare-go/page_shield_connections.go new file mode 100644 index 000000000..904651276 --- /dev/null +++ b/pkg/cloudflare-go/page_shield_connections.go @@ -0,0 +1,88 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// ListPageShieldConnectionsParams represents parameters for a page shield connection request. +type ListPageShieldConnectionsParams struct { + Direction string `url:"direction"` + ExcludeCdnCgi *bool `url:"exclude_cdn_cgi,omitempty"` + ExcludeUrls string `url:"exclude_urls"` + Export string `url:"export"` + Hosts string `url:"hosts"` + OrderBy string `url:"order_by"` + Page string `url:"page"` + PageURL string `url:"page_url"` + PerPage int `url:"per_page"` + PrioritizeMalicious *bool `url:"prioritize_malicious,omitempty"` + Status string `url:"status"` + URLs string `url:"urls"` +} + +// PageShieldConnection represents a page shield connection. +type PageShieldConnection struct { + AddedAt string `json:"added_at"` + DomainReportedMalicious *bool `json:"domain_reported_malicious,omitempty"` + FirstPageURL string `json:"first_page_url"` + FirstSeenAt string `json:"first_seen_at"` + Host string `json:"host"` + ID string `json:"id"` + LastSeenAt string `json:"last_seen_at"` + PageURLs []string `json:"page_urls"` + URL string `json:"url"` + URLContainsCdnCgiPath *bool `json:"url_contains_cdn_cgi_path,omitempty"` +} + +// ListPageShieldConnectionsResponse represents the response from the list page shield connections endpoint. +type ListPageShieldConnectionsResponse struct { + Result []PageShieldConnection `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// ListPageShieldConnections lists all page shield connections for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-list-page-shield-connections +func (api *API) ListPageShieldConnections(ctx context.Context, rc *ResourceContainer, params ListPageShieldConnectionsParams) ([]PageShieldConnection, ResultInfo, error) { + path := fmt.Sprintf("/zones/%s/page_shield/connections", rc.Identifier) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var psResponse ListPageShieldConnectionsResponse + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return psResponse.Result, psResponse.ResultInfo, nil +} + +// GetPageShieldConnection gets a page shield connection for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-get-a-page-shield-connection +func (api *API) GetPageShieldConnection(ctx context.Context, rc *ResourceContainer, connectionID string) (*PageShieldConnection, error) { + path := fmt.Sprintf("/zones/%s/page_shield/connections/%s", rc.Identifier, connectionID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var psResponse PageShieldConnection + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse, nil +} diff --git a/pkg/cloudflare-go/page_shield_connections_test.go b/pkg/cloudflare-go/page_shield_connections_test.go new file mode 100644 index 000000000..42e78cc63 --- /dev/null +++ b/pkg/cloudflare-go/page_shield_connections_test.go @@ -0,0 +1,90 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +// Mock data for PageShieldConnections. +var mockPageShieldConnections = []PageShieldConnection{ + { + AddedAt: "2021-08-18T10:51:10.09615Z", + DomainReportedMalicious: BoolPtr(false), + FirstPageURL: "blog.cloudflare.com/page", + FirstSeenAt: "2021-08-18T10:51:08Z", + Host: "blog.cloudflare.com", + ID: "c9ef84a6bf5e47138c75d95e2f933e8f", + LastSeenAt: "2021-09-02T09:57:54Z", + PageURLs: []string{"blog.cloudflare.com/page1", "blog.cloudflare.com/page2"}, + URL: "https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.min.js", + URLContainsCdnCgiPath: BoolPtr(false), + }, + { + AddedAt: "2021-09-18T10:51:10.09615Z", + DomainReportedMalicious: BoolPtr(false), + FirstPageURL: "blog.cloudflare.com/page02", + FirstSeenAt: "2021-08-18T10:51:08Z", + Host: "blog.cloudflare.com", + ID: "c9ef84a6bf5e47138c75d95e2f933e8f", + LastSeenAt: "2021-09-02T09:57:54Z", + PageURLs: []string{"blog.cloudflare.com/page1", "blog.cloudflare.com/page2"}, + URL: "https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.min.js", + URLContainsCdnCgiPath: BoolPtr(false), + }, + { + AddedAt: "2021-10-18T10:51:10.09615Z", + DomainReportedMalicious: BoolPtr(false), + FirstPageURL: "blog.cloudflare.com/page03", + FirstSeenAt: "2021-08-18T10:51:08Z", + Host: "blog.cloudflare.com", + ID: "c9ef84a6bf5e47138c75d95e2f933e8f", + LastSeenAt: "2021-09-02T09:57:54Z", + PageURLs: []string{"blog.cloudflare.com/page1", "blog.cloudflare.com/page2"}, + URL: "https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.min.js", + URLContainsCdnCgiPath: BoolPtr(false), + }, +} + +func TestListPageShieldConnections(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/page_shield/connections", 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") + response := ListPageShieldConnectionsResponse{ + Result: mockPageShieldConnections, + } + err := json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + result, _, err := client.ListPageShieldConnections(context.Background(), ZoneIdentifier(testZoneID), ListPageShieldConnectionsParams{}) + assert.NoError(t, err) + assert.Equal(t, mockPageShieldConnections, result) +} + +func TestGetPageShieldConnection(t *testing.T) { + setup() + defer teardown() + + connectionID := "c9ef84a6bf5e47138c75d95e2f933e8f" //nolint + mux.HandleFunc(fmt.Sprintf("/zones/"+testZoneID+"/page_shield/connections/%s", connectionID), 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") + response := mockPageShieldConnections[0] // Assuming it's the first mock connection + err := json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + result, err := client.GetPageShieldConnection(context.Background(), ZoneIdentifier(testZoneID), connectionID) + assert.NoError(t, err) + assert.Equal(t, &mockPageShieldConnections[0], result) +} diff --git a/pkg/cloudflare-go/page_shield_policies.go b/pkg/cloudflare-go/page_shield_policies.go new file mode 100644 index 000000000..29ce64ad0 --- /dev/null +++ b/pkg/cloudflare-go/page_shield_policies.go @@ -0,0 +1,140 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// PageShieldPolicy represents a page shield policy. +type PageShieldPolicy struct { + Action string `json:"action"` + Description string `json:"description"` + Enabled *bool `json:"enabled,omitempty"` + Expression string `json:"expression"` + ID string `json:"id"` + Value string `json:"value"` +} + +type CreatePageShieldPolicyParams struct { + Action string `json:"action"` + Description string `json:"description"` + Enabled *bool `json:"enabled,omitempty"` + Expression string `json:"expression"` + ID string `json:"id"` + Value string `json:"value"` +} + +type UpdatePageShieldPolicyParams struct { + Action string `json:"action"` + Description string `json:"description"` + Enabled *bool `json:"enabled,omitempty"` + Expression string `json:"expression"` + ID string `json:"id"` + Value string `json:"value"` +} + +// ListPageShieldPoliciesResponse represents the response from the list page shield policies endpoint. +type ListPageShieldPoliciesResponse struct { + Result []PageShieldPolicy `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type ListPageShieldPoliciesParams struct{} + +// ListPageShieldPolicies lists all page shield policies for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-list-page-shield-policies +func (api *API) ListPageShieldPolicies(ctx context.Context, rc *ResourceContainer, params ListPageShieldPoliciesParams) ([]PageShieldPolicy, ResultInfo, error) { + path := fmt.Sprintf("/zones/%s/page_shield/policies", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var psResponse ListPageShieldPoliciesResponse + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return psResponse.Result, psResponse.ResultInfo, nil +} + +// CreatePageShieldPolicy creates a page shield policy for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-create-page-shield-policy +func (api *API) CreatePageShieldPolicy(ctx context.Context, rc *ResourceContainer, params CreatePageShieldPolicyParams) (*PageShieldPolicy, error) { + path := fmt.Sprintf("/zones/%s/page_shield/policies", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, path, params) + if err != nil { + return nil, err + } + + var psResponse PageShieldPolicy + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse, nil +} + +// DeletePageShieldPolicy deletes a page shield policy for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-delete-page-shield-policy +func (api *API) DeletePageShieldPolicy(ctx context.Context, rc *ResourceContainer, policyID string) error { + path := fmt.Sprintf("/zones/%s/page_shield/policies/%s", rc.Identifier, policyID) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + + return nil +} + +// GetPageShieldPolicy gets a page shield policy for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-get-page-shield-policy +func (api *API) GetPageShieldPolicy(ctx context.Context, rc *ResourceContainer, policyID string) (*PageShieldPolicy, error) { + path := fmt.Sprintf("/zones/%s/page_shield/policies/%s", rc.Identifier, policyID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var psResponse PageShieldPolicy + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse, nil +} + +// UpdatePageShieldPolicy updates a page shield policy for a zone. +// +// API documentation: https://developers.cloudflare.com/api/operations/page-shield-update-page-shield-policy +func (api *API) UpdatePageShieldPolicy(ctx context.Context, rc *ResourceContainer, params UpdatePageShieldPolicyParams) (*PageShieldPolicy, error) { + path := fmt.Sprintf("/zones/%s/page_shield/policies/%s", rc.Identifier, params.ID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, path, params) + if err != nil { + return nil, err + } + + var psResponse PageShieldPolicy + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse, nil +} diff --git a/pkg/cloudflare-go/page_shield_policies_test.go b/pkg/cloudflare-go/page_shield_policies_test.go new file mode 100644 index 000000000..239c4639d --- /dev/null +++ b/pkg/cloudflare-go/page_shield_policies_test.go @@ -0,0 +1,151 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +var mockPageShieldPolicies = []PageShieldPolicy{ + { + Action: "allow", + Description: "Checkout page CSP policy", + Enabled: BoolPtr(true), + Expression: "ends_with(http.request.uri.path, \"/checkout\")", + ID: "c9ef84a6bf5e47138c75d95e2f933e8f", + Value: "script-src 'none';", + }, + { + Action: "allow", + Description: "Checkout page CSP policy", + Enabled: BoolPtr(true), + Expression: "ends_with(http.request.uri.path, \"/login\")", + ID: "c9ef84a6bf5e47138c75d95e2f933e82", + Value: "script-src 'none';", + }, + { + Action: "allow", + Description: "Checkout page CSP policy", + Enabled: BoolPtr(true), + Expression: "ends_with(http.request.uri.path, \"/logout\")", + ID: "c9ef84a6bf5e47138c75d95e2f933e83", + Value: "script-src 'none';", + }, +} + +func TestListPageShieldPolicies(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/page_shield/policies", 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") + response := ListPageShieldPoliciesResponse{ + Result: mockPageShieldPolicies, + } + err := json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + result, _, err := client.ListPageShieldPolicies(context.Background(), ZoneIdentifier(testZoneID), ListPageShieldPoliciesParams{}) + assert.NoError(t, err) + assert.Equal(t, mockPageShieldPolicies, result) +} + +func TestCreatePageShieldPolicy(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/page_shield/policies", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + var params PageShieldPolicy + err := json.NewDecoder(r.Body).Decode(¶ms) + assert.NoError(t, err) + params.ID = "newPolicyID" + err = json.NewEncoder(w).Encode(params) + if err != nil { + t.Fatal(err) + } + }) + + newPolicy := CreatePageShieldPolicyParams{ + Action: "block", + Description: "New policy", + Enabled: BoolPtr(true), + Expression: "ends_with(http.request.uri.path, \"/new\")", + Value: "script-src 'self';", + } + result, err := client.CreatePageShieldPolicy(context.Background(), ZoneIdentifier(testZoneID), newPolicy) + assert.NoError(t, err) + assert.Equal(t, "newPolicyID", result.ID) +} + +func TestDeletePageShieldPolicy(t *testing.T) { + setup() + defer teardown() + + policyID := "c9ef84a6bf5e47138c75d95e2f933e8f" + mux.HandleFunc(fmt.Sprintf("/zones/"+testZoneID+"/page_shield/policies/%s", policyID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + w.WriteHeader(http.StatusOK) // Assuming successful deletion returns 200 OK + }) + err := client.DeletePageShieldPolicy(context.Background(), ZoneIdentifier(testZoneID), policyID) + assert.NoError(t, err) +} + +func TestGetPageShieldPolicy(t *testing.T) { + setup() + defer teardown() + + policyID := "c9ef84a6bf5e47138c75d95e2f933e8f" + mux.HandleFunc(fmt.Sprintf("/zones/"+testZoneID+"/page_shield/policies/%s", policyID), 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") + err := json.NewEncoder(w).Encode(mockPageShieldPolicies[0]) // Assuming the first mock policy + if err != nil { + t.Fatal(err) + } + }) + result, err := client.GetPageShieldPolicy(context.Background(), ZoneIdentifier(testZoneID), policyID) + assert.NoError(t, err) + assert.Equal(t, &mockPageShieldPolicies[0], result) +} + +func TestUpdatePageShieldPolicy(t *testing.T) { + setup() + defer teardown() + + policyID := "c9ef84a6bf5e47138c75d95e2f933e8f" + mux.HandleFunc(fmt.Sprintf("/zones/"+testZoneID+"/page_shield/policies/%s", policyID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var params PageShieldPolicy + err := json.NewDecoder(r.Body).Decode(¶ms) + assert.NoError(t, err) + params.ID = policyID + err = json.NewEncoder(w).Encode(params) + if err != nil { + t.Fatal(err) + } + }) + + updatedPolicy := UpdatePageShieldPolicyParams{ + ID: policyID, + Action: "block", + Description: "Updated policy", + Enabled: BoolPtr(false), + Expression: "ends_with(http.request.uri.path, \"/updated\")", + Value: "script-src 'self';", + } + result, err := client.UpdatePageShieldPolicy(context.Background(), ZoneIdentifier(testZoneID), updatedPolicy) + assert.NoError(t, err) + assert.Equal(t, policyID, result.ID) + assert.Equal(t, "Updated policy", result.Description) +} diff --git a/pkg/cloudflare-go/page_shield_scripts.go b/pkg/cloudflare-go/page_shield_scripts.go new file mode 100644 index 000000000..09559b59f --- /dev/null +++ b/pkg/cloudflare-go/page_shield_scripts.go @@ -0,0 +1,107 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// PageShieldScript represents a Page Shield script. +type PageShieldScript struct { + AddedAt string `json:"added_at"` + DomainReportedMalicious *bool `json:"domain_reported_malicious,omitempty"` + FetchedAt string `json:"fetched_at"` + FirstPageURL string `json:"first_page_url"` + FirstSeenAt string `json:"first_seen_at"` + Hash string `json:"hash"` + Host string `json:"host"` + ID string `json:"id"` + JSIntegrityScore int `json:"js_integrity_score"` + LastSeenAt string `json:"last_seen_at"` + PageURLs []string `json:"page_urls"` + URL string `json:"url"` + URLContainsCdnCgiPath *bool `json:"url_contains_cdn_cgi_path,omitempty"` +} + +// ListPageShieldScriptsParams represents a PageShield Script request parameters. +// +// API reference: https://developers.cloudflare.com/api/operations/page-shield-list-page-shield-scripts#Query-Parameters +type ListPageShieldScriptsParams struct { + Direction string `url:"direction"` + ExcludeCdnCgi *bool `url:"exclude_cdn_cgi,omitempty"` + ExcludeDuplicates *bool `url:"exclude_duplicates,omitempty"` + ExcludeUrls string `url:"exclude_urls"` + Export string `url:"export"` + Hosts string `url:"hosts"` + OrderBy string `url:"order_by"` + Page string `url:"page"` + PageURL string `url:"page_url"` + PerPage int `url:"per_page"` + PrioritizeMalicious *bool `url:"prioritize_malicious,omitempty"` + Status string `url:"status"` + URLs string `url:"urls"` +} + +// PageShieldScriptsResponse represents the response from the PageShield Script API. +type PageShieldScriptsResponse struct { + Results []PageShieldScript `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// PageShieldScriptResponse represents the response from the PageShield Script API. +type PageShieldScriptResponse struct { + Result PageShieldScript `json:"result"` + Versions []PageShieldScriptVersion `json:"versions"` +} + +// PageShieldScriptVersion represents a Page Shield script version. +type PageShieldScriptVersion struct { + FetchedAt string `json:"fetched_at"` + Hash string `json:"hash"` + JSIntegrityScore int `json:"js_integrity_score"` +} + +// ListPageShieldScripts returns a list of PageShield Scripts. +// +// API reference: https://developers.cloudflare.com/api/operations/page-shield-list-page-shield-scripts +func (api *API) ListPageShieldScripts(ctx context.Context, rc *ResourceContainer, params ListPageShieldScriptsParams) ([]PageShieldScript, ResultInfo, error) { + path := fmt.Sprintf("/zones/%s/page_shield/scripts", rc.Identifier) + + uri := buildURI(path, params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, ResultInfo{}, err + } + + var psResponse PageShieldScriptsResponse + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return psResponse.Results, psResponse.ResultInfo, nil +} + +// GetPageShieldScript returns a PageShield Script. +// +// API reference: https://developers.cloudflare.com/api/operations/page-shield-get-a-page-shield-script +func (api *API) GetPageShieldScript(ctx context.Context, rc *ResourceContainer, scriptID string) (*PageShieldScript, []PageShieldScriptVersion, error) { + path := fmt.Sprintf("/zones/%s/page_shield/scripts/%s", rc.Identifier, scriptID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + var psResponse PageShieldScriptResponse + err = json.Unmarshal(res, &psResponse) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &psResponse.Result, psResponse.Versions, nil +} diff --git a/pkg/cloudflare-go/page_shield_scripts_test.go b/pkg/cloudflare-go/page_shield_scripts_test.go new file mode 100644 index 000000000..d6d430dcd --- /dev/null +++ b/pkg/cloudflare-go/page_shield_scripts_test.go @@ -0,0 +1,78 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +var mockPageShieldScripts = []PageShieldScript{ + { + AddedAt: "2021-08-18T10:51:10.09615Z", + DomainReportedMalicious: BoolPtr(false), + FetchedAt: "2021-09-02T10:17:54Z", + FirstPageURL: "blog.cloudflare.com/page", + FirstSeenAt: "2021-08-18T10:51:08Z", + Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + Host: "blog.cloudflare.com", + ID: "c9ef84a6bf5e47138c75d95e2f933e8f", + JSIntegrityScore: 10, + LastSeenAt: "2021-09-02T09:57:54Z", + PageURLs: []string{"blog.cloudflare.com/page1", "blog.cloudflare.com/page2"}, + URL: "https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.0/js/bootstrap.min.js", + URLContainsCdnCgiPath: BoolPtr(false), + }, +} + +var mockVersions = []PageShieldScriptVersion{ + { + FetchedAt: "2021-09-02T10:17:54Z", + Hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + JSIntegrityScore: 10, + }, +} + +func TestListPageShieldScripts(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/page_shield/scripts", 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") + response := PageShieldScriptsResponse{Results: mockPageShieldScripts} + err := json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + result, _, err := client.ListPageShieldScripts(context.Background(), ZoneIdentifier(testZoneID), ListPageShieldScriptsParams{}) + assert.NoError(t, err) + assert.Equal(t, mockPageShieldScripts, result) +} + +func TestGetPageShieldScript(t *testing.T) { + setup() + defer teardown() + + scriptID := "c9ef84a6bf5e47138c75d95e2f933e8f" + mux.HandleFunc(fmt.Sprintf("/zones/"+testZoneID+"/page_shield/scripts/%s", scriptID), 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") + response := PageShieldScriptResponse{ + Result: mockPageShieldScripts[0], + Versions: mockVersions, + } + err := json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + result, versions, err := client.GetPageShieldScript(context.Background(), ZoneIdentifier(testZoneID), scriptID) + assert.NoError(t, err) + assert.Equal(t, &mockPageShieldScripts[0], result) + assert.Equal(t, mockVersions, versions) +} diff --git a/pkg/cloudflare-go/page_shield_test.go b/pkg/cloudflare-go/page_shield_test.go new file mode 100644 index 000000000..a41e51e22 --- /dev/null +++ b/pkg/cloudflare-go/page_shield_test.go @@ -0,0 +1,77 @@ +package cloudflare + +import ( + "context" + "net/http" + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +// Mock PageShieldSettings data. +var mockPageShieldSettings = PageShieldSettings{ + PageShield: PageShield{ + Enabled: BoolPtr(true), + UseCloudflareReportingEndpoint: BoolPtr(true), + UseConnectionURLPath: BoolPtr(true), + }, + UpdatedAt: "2022-10-12T17:56:52.083582+01:00", +} + +func TestGetPageShieldSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/page_shield", 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") + response := PageShieldSettingsResponse{ + PageShield: mockPageShieldSettings, + } + err := json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + + result, err := client.GetPageShieldSettings(context.Background(), ZoneIdentifier(testZoneID), GetPageShieldSettingsParams{}) + assert.NoError(t, err) + assert.Equal(t, &PageShieldSettingsResponse{PageShield: mockPageShieldSettings}, result) +} + +func TestUpdatePageShieldSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/page_shield", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + + var params PageShield + err := json.NewDecoder(r.Body).Decode(¶ms) + assert.NoError(t, err) + + response := PageShieldSettingsResponse{ + PageShield: PageShieldSettings{ + PageShield: params, + UpdatedAt: "2022-10-13T10:00:00.000Z", + }, + } + err = json.NewEncoder(w).Encode(response) + if err != nil { + t.Fatal(err) + } + }) + + newSettings := UpdatePageShieldSettingsParams{ + Enabled: BoolPtr(false), + UseCloudflareReportingEndpoint: BoolPtr(false), + UseConnectionURLPath: BoolPtr(false), + } + result, err := client.UpdatePageShieldSettings(context.Background(), ZoneIdentifier(testZoneID), newSettings) + assert.NoError(t, err) + assert.Equal(t, false, *result.PageShield.Enabled) + assert.Equal(t, false, *result.PageShield.UseCloudflareReportingEndpoint) + assert.Equal(t, false, *result.PageShield.UseConnectionURLPath) +} diff --git a/pkg/cloudflare-go/pages_deployment.go b/pkg/cloudflare-go/pages_deployment.go new file mode 100644 index 000000000..b81a2c1b7 --- /dev/null +++ b/pkg/cloudflare-go/pages_deployment.go @@ -0,0 +1,343 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// SizeOptions can be passed to a list request to configure size and cursor location. +// These values will be defaulted if omitted. +// +// This should be swapped to ResultInfoCursors once the types are corrected. +type SizeOptions struct { + Size int `json:"size,omitempty" url:"size,omitempty"` + Before *int `json:"before,omitempty" url:"before,omitempty"` + After *int `json:"after,omitempty" url:"after,omitempty"` +} + +// PagesDeploymentStageLogs represents the logs for a Pages deployment stage. +type PagesDeploymentStageLogs struct { + Name string `json:"name"` + StartedOn *time.Time `json:"started_on"` + EndedOn *time.Time `json:"ended_on"` + Status string `json:"status"` + Start int `json:"start"` + End int `json:"end"` + Total int `json:"total"` + Data []PagesDeploymentStageLogEntry `json:"data"` +} + +// PagesDeploymentStageLogEntry represents a single log entry in a Pages deployment stage. +type PagesDeploymentStageLogEntry struct { + ID int `json:"id"` + Timestamp *time.Time `json:"timestamp"` + Message string `json:"message"` +} + +// PagesDeploymentLogs represents the logs for a Pages deployment. +type PagesDeploymentLogs struct { + Total int `json:"total"` + IncludesContainerLogs bool `json:"includes_container_logs"` + Data []PagesDeploymentLogEntry `json:"data"` +} + +// PagesDeploymentLogEntry represents a single log entry in a Pages deployment. +type PagesDeploymentLogEntry struct { + Timestamp *time.Time `json:"ts"` + Line string `json:"line"` +} + +type pagesDeploymentListResponse struct { + Response + Result []PagesProjectDeployment `json:"result"` + ResultInfo `json:"result_info"` +} + +type pagesDeploymentResponse struct { + Response + Result PagesProjectDeployment `json:"result"` +} + +type pagesDeploymentLogsResponse struct { + Response + Result PagesDeploymentLogs `json:"result"` + ResultInfo `json:"result_info"` +} + +type ListPagesDeploymentsParams struct { + ProjectName string `url:"-"` + + ResultInfo +} + +type GetPagesDeploymentInfoParams struct { + ProjectName string + DeploymentID string +} + +type GetPagesDeploymentStageLogsParams struct { + ProjectName string + DeploymentID string + StageName string + + SizeOptions +} + +type GetPagesDeploymentLogsParams struct { + ProjectName string + DeploymentID string + + SizeOptions +} + +type DeletePagesDeploymentParams struct { + ProjectName string `url:"-"` + DeploymentID string `url:"-"` + Force bool `url:"force,omitempty"` +} + +type CreatePagesDeploymentParams struct { + ProjectName string `json:"-"` + Branch string `json:"branch,omitempty"` +} + +type RetryPagesDeploymentParams struct { + ProjectName string + DeploymentID string +} + +type RollbackPagesDeploymentParams struct { + ProjectName string + DeploymentID string +} + +var ( + ErrMissingProjectName = errors.New("required missing project name") + ErrMissingDeploymentID = errors.New("required missing deployment ID") +) + +// ListPagesDeployments returns all deployments for a Pages project. +// +// API reference: https://api.cloudflare.com/#pages-deployment-get-deployments +func (api *API) ListPagesDeployments(ctx context.Context, rc *ResourceContainer, params ListPagesDeploymentsParams) ([]PagesProjectDeployment, *ResultInfo, error) { + if rc.Identifier == "" { + return []PagesProjectDeployment{}, &ResultInfo{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return []PagesProjectDeployment{}, &ResultInfo{}, ErrMissingProjectName + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var deployments []PagesProjectDeployment + var r pagesDeploymentListResponse + + for { + uri := buildURI(fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments", rc.Identifier, params.ProjectName), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PagesProjectDeployment{}, &ResultInfo{}, err + } + err = json.Unmarshal(res, &r) + if err != nil { + return []PagesProjectDeployment{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + deployments = append(deployments, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.Done() || !autoPaginate { + break + } + } + return deployments, &r.ResultInfo, nil +} + +// GetPagesDeploymentInfo returns a deployment for a Pages project. +// +// API reference: https://api.cloudflare.com/#pages-deployment-get-deployment-info +func (api *API) GetPagesDeploymentInfo(ctx context.Context, rc *ResourceContainer, projectName, deploymentID string) (PagesProjectDeployment, error) { + if rc.Identifier == "" { + return PagesProjectDeployment{}, ErrMissingAccountID + } + + if projectName == "" { + return PagesProjectDeployment{}, ErrMissingProjectName + } + + if deploymentID == "" { + return PagesProjectDeployment{}, ErrMissingDeploymentID + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments/%s", rc.Identifier, projectName, deploymentID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PagesProjectDeployment{}, err + } + var r pagesDeploymentResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProjectDeployment{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// GetPagesDeploymentLogs returns the logs for a Pages deployment. +// +// API reference: https://api.cloudflare.com/#pages-deployment-get-deployment-logs +func (api *API) GetPagesDeploymentLogs(ctx context.Context, rc *ResourceContainer, params GetPagesDeploymentLogsParams) (PagesDeploymentLogs, error) { + if rc.Identifier == "" { + return PagesDeploymentLogs{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return PagesDeploymentLogs{}, ErrMissingProjectName + } + + if params.DeploymentID == "" { + return PagesDeploymentLogs{}, ErrMissingDeploymentID + } + + uri := buildURI( + fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments/%s/history/logs", rc.Identifier, params.ProjectName, params.DeploymentID), + params.SizeOptions, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PagesDeploymentLogs{}, err + } + var r pagesDeploymentLogsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesDeploymentLogs{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeletePagesDeployment deletes a Pages deployment. +// +// API reference: https://api.cloudflare.com/#pages-deployment-delete-deployment +func (api *API) DeletePagesDeployment(ctx context.Context, rc *ResourceContainer, params DeletePagesDeploymentParams) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if params.ProjectName == "" { + return ErrMissingProjectName + } + + if params.DeploymentID == "" { + return ErrMissingDeploymentID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments/%s", rc.Identifier, params.ProjectName, params.DeploymentID), params) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + return nil +} + +// CreatePagesDeployment creates a Pages production deployment. +// +// API reference: https://api.cloudflare.com/#pages-deployment-create-deployment +func (api *API) CreatePagesDeployment(ctx context.Context, rc *ResourceContainer, params CreatePagesDeploymentParams) (PagesProjectDeployment, error) { + if rc.Identifier == "" { + return PagesProjectDeployment{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return PagesProjectDeployment{}, ErrMissingProjectName + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments", rc.Identifier, params.ProjectName) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return PagesProjectDeployment{}, err + } + var r pagesDeploymentResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProjectDeployment{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// RetryPagesDeployment retries a specific Pages deployment. +// +// API reference: https://api.cloudflare.com/#pages-deployment-retry-deployment +func (api *API) RetryPagesDeployment(ctx context.Context, rc *ResourceContainer, projectName, deploymentID string) (PagesProjectDeployment, error) { + if rc.Identifier == "" { + return PagesProjectDeployment{}, ErrMissingAccountID + } + + if projectName == "" { + return PagesProjectDeployment{}, ErrMissingProjectName + } + + if deploymentID == "" { + return PagesProjectDeployment{}, ErrMissingDeploymentID + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments/%s/retry", rc.Identifier, projectName, deploymentID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return PagesProjectDeployment{}, err + } + var r pagesDeploymentResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProjectDeployment{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// RollbackPagesDeployment rollbacks the Pages production deployment to a previous production deployment. +// +// API reference: https://api.cloudflare.com/#pages-deployment-rollback-deployment +func (api *API) RollbackPagesDeployment(ctx context.Context, rc *ResourceContainer, projectName, deploymentID string) (PagesProjectDeployment, error) { + if rc.Identifier == "" { + return PagesProjectDeployment{}, ErrMissingAccountID + } + + if projectName == "" { + return PagesProjectDeployment{}, ErrMissingProjectName + } + + if deploymentID == "" { + return PagesProjectDeployment{}, ErrMissingDeploymentID + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/deployments/%s/rollback", rc.Identifier, projectName, deploymentID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return PagesProjectDeployment{}, err + } + var r pagesDeploymentResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProjectDeployment{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/pages_deployment_test.go b/pkg/cloudflare-go/pages_deployment_test.go new file mode 100644 index 000000000..7e7f3ee0e --- /dev/null +++ b/pkg/cloudflare-go/pages_deployment_test.go @@ -0,0 +1,500 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testPagesDeploymentResponse = ` + { + "id": "0012e50b-fa5d-44db-8cb5-1f372785dcbe", + "short_id": "0012e50b", + "project_id": "80776025-b1bd-4181-993f-8238c27d226f", + "project_name": "test", + "environment": "production", + "url": "https://0012e50b.test.pages.dev", + "created_on": "2021-01-01T00:00:00Z", + "modified_on": "2021-01-01T00:00:00Z", + "latest_stage": { + "name": "deploy", + "started_on": "2021-01-01T00:00:00Z", + "ended_on": "2021-01-01T00:00:00Z", + "status": "success" + }, + "deployment_trigger": { + "type": "ad_hoc", + "metadata": { + "branch": "main", + "commit_hash": "20fb65fa9d7fd2a11f7fa3ebdc44137b263ee835", + "commit_message": "Test commit" + } + }, + "stages": [ + { + "name": "queued", + "started_on": "2021-01-01T00:00:00Z", + "ended_on": "2021-01-01T00:00:00Z", + "status": "success" + }, + { + "name": "initialize", + "started_on": "2021-01-01T00:00:00Z", + "ended_on": "2021-01-01T00:00:00Z", + "status": "success" + }, + { + "name": "clone_repo", + "started_on": "2021-01-01T00:00:00Z", + "ended_on": "2021-01-01T00:00:00Z", + "status": "success" + }, + { + "name": "build", + "started_on": "2021-01-01T00:00:00Z", + "ended_on": "2021-01-01T00:00:00Z", + "status": "success" + }, + { + "name": "deploy", + "started_on": "2021-01-01T00:00:00Z", + "ended_on": "2021-01-01T00:00:00Z", + "status": "success" + } + ], + "build_config": { + "build_command": "bash test.sh", + "destination_dir": "", + "root_dir": "", + "web_analytics_tag": null, + "web_analytics_token": null + }, + "source": { + "type": "github", + "config": { + "owner": "coudflare", + "repo_name": "Test", + "production_branch": "main", + "pr_comments_enabled": false + } + }, + "env_vars": { + "NODE_VERSION": { + "value": "16" + } + }, + "aliases": null + }` + + testPagesDeploymentLogsResponse = ` + { + "total": 6, + "includes_container_logs": true, + "data": [ + { + "ts": "2021-01-01T00:00:00Z", + "line": "Installing dependencies" + }, + { + "ts": "2021-01-01T00:00:00Z", + "line": "Verify run directory" + }, + { + "ts": "2021-01-01T00:00:00Z", + "line": "Executing user command: bash test.sh" + }, + { + "ts": "2021-01-01T00:00:00Z", + "line": "Finished" + }, + { + "ts": "2021-01-01T00:00:00Z", + "line": "Building functions..." + }, + { + "ts": "2021-01-01T00:00:00Z", + "line": "Validating asset output directory" + }, + { + "ts": "2021-01-01T00:00:00Z", + "line": "Parsed 2 valid header rules." + } + ] + }` +) + +var ( + pagesDeploymentDummyTime, _ = time.Parse(time.RFC3339, "2021-01-01T00:00:00Z") + + expectedPagesDeployment = &PagesProjectDeployment{ + ID: "0012e50b-fa5d-44db-8cb5-1f372785dcbe", + ShortID: "0012e50b", + ProjectID: "80776025-b1bd-4181-993f-8238c27d226f", + ProjectName: "test", + Environment: "production", + URL: "https://0012e50b.test.pages.dev", + CreatedOn: &pagesDeploymentDummyTime, + ModifiedOn: &pagesDeploymentDummyTime, + Aliases: nil, + LatestStage: *expectedPagesDeploymentLatestStage, + EnvVars: EnvironmentVariableMap{ + "NODE_VERSION": &EnvironmentVariable{ + Value: "16", + }, + }, + DeploymentTrigger: PagesProjectDeploymentTrigger{ + Type: "ad_hoc", + Metadata: &PagesProjectDeploymentTriggerMetadata{ + Branch: "main", + CommitHash: "20fb65fa9d7fd2a11f7fa3ebdc44137b263ee835", + CommitMessage: "Test commit", + }, + }, + Stages: expectedPagesDeploymentStages, + BuildConfig: PagesProjectBuildConfig{ + BuildCommand: "bash test.sh", + DestinationDir: "", + RootDir: "", + }, + Source: PagesProjectSource{ + Type: "github", + Config: &PagesProjectSourceConfig{ + Owner: "coudflare", + RepoName: "Test", + ProductionBranch: "main", + PRCommentsEnabled: false, + }, + }, + } + + expectedPagesDeploymentStages = []PagesProjectDeploymentStage{ + { + Name: "queued", + StartedOn: &pagesDeploymentDummyTime, + EndedOn: &pagesDeploymentDummyTime, + Status: "success", + }, + { + Name: "initialize", + StartedOn: &pagesDeploymentDummyTime, + EndedOn: &pagesDeploymentDummyTime, + Status: "success", + }, + { + Name: "clone_repo", + StartedOn: &pagesDeploymentDummyTime, + EndedOn: &pagesDeploymentDummyTime, + Status: "success", + }, + { + Name: "build", + StartedOn: &pagesDeploymentDummyTime, + EndedOn: &pagesDeploymentDummyTime, + Status: "success", + }, + *expectedPagesDeploymentLatestStage, + } + + expectedPagesDeploymentLatestStage = &PagesProjectDeploymentStage{ + Name: "deploy", + StartedOn: &pagesDeploymentDummyTime, + EndedOn: &pagesDeploymentDummyTime, + Status: "success", + } + + expectedPagesDeploymentLogs = &PagesDeploymentLogs{ + Total: 6, + IncludesContainerLogs: true, + Data: expectedPagesDeploymentLogEntries, + } + + expectedPagesDeploymentLogEntries = []PagesDeploymentLogEntry{ + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Installing dependencies", + }, + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Verify run directory", + }, + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Executing user command: bash test.sh", + }, + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Finished", + }, + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Building functions...", + }, + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Validating asset output directory", + }, + { + Timestamp: &pagesDeploymentDummyTime, + Line: "Parsed 2 valid header rules.", + }, + } +) + +func TestListPagesDeployments(t *testing.T) { + setup() + defer teardown() + + 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, "25", r.URL.Query().Get("per_page")) + assert.Equal(t, "1", r.URL.Query().Get("page")) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ], + "result_info": { + "page": 1, + "per_page": 100, + "count": 1, + "total_count": 1 + } + }`, testPagesDeploymentResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments", handler) + + expectedPagesDeployments := []PagesProjectDeployment{ + *expectedPagesDeployment, + } + expectedResultInfo := ResultInfo{ + Page: 1, + PerPage: 100, + Count: 1, + Total: 1, + } + actual, resultInfo, err := client.ListPagesDeployments(context.Background(), AccountIdentifier(testAccountID), ListPagesDeploymentsParams{ + ProjectName: "test", + ResultInfo: ResultInfo{}, + }) + if assert.NoError(t, err) { + assert.Equal(t, expectedPagesDeployments, actual) + assert.Equal(t, &expectedResultInfo, resultInfo) + } +} + +func TestListPagesDeploymentsPagination(t *testing.T) { + setup() + defer teardown() + var page1Called, page2Called bool + handler := func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + w.Header().Set("content-type", "application/json") + switch page { + case "1": + page1Called = true + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ], + "result_info": { + "page": 1, + "per_page": 25, + "total_count": 26, + "total_pages": 2 + } + }`, testPagesDeploymentResponse) + case "2": + page2Called = true + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ], + "result_info": { + "page": 2, + "per_page": 25, + "total_count": 26, + "total_pages": 2 + } + }`, testPagesDeploymentResponse) + default: + assert.Failf(t, "Unexpected page number", "Expected page 1 or 2, got %s", page) + return + } + } + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments", handler) + actual, resultInfo, err := client.ListPagesDeployments(context.Background(), AccountIdentifier(testAccountID), ListPagesDeploymentsParams{ + ProjectName: "test", + ResultInfo: ResultInfo{}, + }) + if assert.NoError(t, err) { + assert.True(t, page1Called) + assert.True(t, page2Called) + assert.Equal(t, 2, len(actual)) + assert.Equal(t, 26, resultInfo.Total) + } +} + +func TestGetPagesDeploymentInfo(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesDeploymentResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments/0012e50b-fa5d-44db-8cb5-1f372785dcbe", handler) + + actual, err := client.GetPagesDeploymentInfo(context.Background(), AccountIdentifier(testAccountID), "test", "0012e50b-fa5d-44db-8cb5-1f372785dcbe") + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesDeployment, actual) + } +} + +func TestGetPagesDeploymentLogs(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesDeploymentLogsResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments/0012e50b-fa5d-44db-8cb5-1f372785dcbe/history/logs", handler) + + actual, err := client.GetPagesDeploymentLogs(context.Background(), AccountIdentifier(testAccountID), GetPagesDeploymentLogsParams{ + ProjectName: "test", + DeploymentID: "0012e50b-fa5d-44db-8cb5-1f372785dcbe", + SizeOptions: SizeOptions{}, + }) + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesDeploymentLogs, actual) + } +} + +func TestDeletePagesDeployment(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + assert.Equal(t, "true", r.URL.Query().Get("force")) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments/0012e50b-fa5d-44db-8cb5-1f372785dcbe", handler) + + err := client.DeletePagesDeployment(context.Background(), AccountIdentifier(testAccountID), DeletePagesDeploymentParams{ProjectName: "test", DeploymentID: "0012e50b-fa5d-44db-8cb5-1f372785dcbe", Force: true}) + assert.NoError(t, err) +} + +func TestCreatePagesDeployment(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesDeploymentResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments", handler) + + actual, err := client.CreatePagesDeployment(context.Background(), AccountIdentifier(testAccountID), CreatePagesDeploymentParams{ + ProjectName: "test", + }) + + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesDeployment, actual) + } +} + +func TestRetryPagesDeployment(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesDeploymentResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments/0012e50b-fa5d-44db-8cb5-1f372785dcbe/retry", handler) + + actual, err := client.RetryPagesDeployment(context.Background(), AccountIdentifier(testAccountID), "test", "0012e50b-fa5d-44db-8cb5-1f372785dcbe") + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesDeployment, actual) + } +} + +func TestRollbackPagesDeployment(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesDeploymentResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/test/deployments/0012e50b-fa5d-44db-8cb5-1f372785dcbe/rollback", handler) + + actual, err := client.RollbackPagesDeployment(context.Background(), AccountIdentifier(testAccountID), "test", "0012e50b-fa5d-44db-8cb5-1f372785dcbe") + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesDeployment, actual) + } +} diff --git a/pkg/cloudflare-go/pages_domain.go b/pkg/cloudflare-go/pages_domain.go new file mode 100644 index 000000000..a6fa8ac9f --- /dev/null +++ b/pkg/cloudflare-go/pages_domain.go @@ -0,0 +1,194 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// PagesDomain represents a pages domain. +type PagesDomain struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + VerificationData VerificationData `json:"verification_data"` + ValidationData ValidationData `json:"validation_data"` + ZoneTag string `json:"zone_tag"` + CreatedOn *time.Time `json:"created_on"` +} + +// VerificationData represents verification data for a domain. +type VerificationData struct { + Status string `json:"status"` +} + +// ValidationData represents validation data for a domain. +type ValidationData struct { + Status string `json:"status"` + Method string `json:"method"` +} + +// PagesDomainsParameters represents parameters for a pages domains request. +type PagesDomainsParameters struct { + AccountID string + ProjectName string +} + +// PagesDomainsResponse represents an API response for a pages domains request. +type PagesDomainsResponse struct { + Response + Result []PagesDomain `json:"result,omitempty"` +} + +// PagesDomainParameters represents parameters for a pages domain request. +type PagesDomainParameters struct { + AccountID string `json:"-"` + ProjectName string `json:"-"` + DomainName string `json:"name,omitempty"` +} + +// PagesDomainResponse represents an API response for a pages domain request. +type PagesDomainResponse struct { + Response + Result PagesDomain `json:"result,omitempty"` +} + +// GetPagesDomains gets all domains for a pages project. +// +// API Reference: https://api.cloudflare.com/#pages-domains-get-domains +func (api *API) GetPagesDomains(ctx context.Context, params PagesDomainsParameters) ([]PagesDomain, error) { + if params.AccountID == "" { + return []PagesDomain{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return []PagesDomain{}, ErrMissingProjectName + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains", params.AccountID, params.ProjectName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PagesDomain{}, err + } + + var pageDomainResponse PagesDomainsResponse + if err := json.Unmarshal(res, &pageDomainResponse); err != nil { + return []PagesDomain{}, err + } + return pageDomainResponse.Result, nil +} + +// GetPagesDomain gets a single domain. +// +// API Reference: https://api.cloudflare.com/#pages-domains-get-domains +func (api *API) GetPagesDomain(ctx context.Context, params PagesDomainParameters) (PagesDomain, error) { + if params.AccountID == "" { + return PagesDomain{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return PagesDomain{}, ErrMissingProjectName + } + + if params.DomainName == "" { + return PagesDomain{}, ErrMissingDomain + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains/%s", params.AccountID, params.ProjectName, params.DomainName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PagesDomain{}, err + } + + var pagesDomainResponse PagesDomainResponse + if err := json.Unmarshal(res, &pagesDomainResponse); err != nil { + return PagesDomain{}, err + } + return pagesDomainResponse.Result, nil +} + +// PagesPatchDomain retries the validation status of a single domain. +// +// API Reference: https://api.cloudflare.com/#pages-domains-patch-domain +func (api *API) PagesPatchDomain(ctx context.Context, params PagesDomainParameters) (PagesDomain, error) { + if params.AccountID == "" { + return PagesDomain{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return PagesDomain{}, ErrMissingProjectName + } + + if params.DomainName == "" { + return PagesDomain{}, ErrMissingDomain + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains/%s", params.AccountID, params.ProjectName, params.DomainName) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, nil) + if err != nil { + return PagesDomain{}, err + } + + var pagesDomainResponse PagesDomainResponse + if err := json.Unmarshal(res, &pagesDomainResponse); err != nil { + return PagesDomain{}, err + } + return pagesDomainResponse.Result, nil +} + +// PagesAddDomain adds a domain to a pages project. +// +// API Reference: https://api.cloudflare.com/#pages-domains-add-domain +func (api *API) PagesAddDomain(ctx context.Context, params PagesDomainParameters) (PagesDomain, error) { + if params.AccountID == "" { + return PagesDomain{}, ErrMissingAccountID + } + + if params.ProjectName == "" { + return PagesDomain{}, ErrMissingProjectName + } + + if params.DomainName == "" { + return PagesDomain{}, ErrMissingDomain + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains", params.AccountID, params.ProjectName) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return PagesDomain{}, err + } + + var pagesDomainResponse PagesDomainResponse + if err := json.Unmarshal(res, &pagesDomainResponse); err != nil { + return PagesDomain{}, err + } + return pagesDomainResponse.Result, nil +} + +// PagesDeleteDomain removes a domain from a pages project. +// +// API Reference: https://api.cloudflare.com/#pages-domains-delete-domain +func (api *API) PagesDeleteDomain(ctx context.Context, params PagesDomainParameters) error { + if params.AccountID == "" { + return ErrMissingAccountID + } + + if params.ProjectName == "" { + return ErrMissingProjectName + } + + if params.DomainName == "" { + return ErrMissingDomain + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains/%s", params.AccountID, params.ProjectName, params.DomainName) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, params) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cloudflare-go/pages_domain_test.go b/pkg/cloudflare-go/pages_domain_test.go new file mode 100644 index 000000000..20e80e7b4 --- /dev/null +++ b/pkg/cloudflare-go/pages_domain_test.go @@ -0,0 +1,277 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testPagesProjectName = "page-project" + +var testCreatedOn, _ = time.Parse(time.RFC3339, "2017-01-01T00:00:00Z") + +var testPagesDomain = PagesDomain{ + ID: "8232210c-6818-4e34-8d95-cc386874b8d2", + Name: "example.com", + Status: "pending", + VerificationData: VerificationData{ + Status: "active", + }, + ValidationData: ValidationData{ + Status: "active", + Method: "http", + }, + ZoneTag: "023e105f4ecef8ad9ca31a8372d0c353", + CreatedOn: &testCreatedOn, +} + +func TestPages_GetDomains(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/"+testPagesProjectName+"/domains", 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, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "8232210c-6818-4e34-8d95-cc386874b8d2", + "name": "example.com", + "status": "pending", + "verification_data": { + "status": "active" + }, + "validation_data": { + "status": "active", + "method": "http" + }, + "zone_tag": "023e105f4ecef8ad9ca31a8372d0c353", + "created_on": "2017-01-01T00:00:00Z" + } + ] +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.GetPagesDomains(context.Background(), PagesDomainsParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing project name is thrown + _, err = client.GetPagesDomains(context.Background(), PagesDomainsParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingProjectName, err) + } + + out, err := client.GetPagesDomains(context.Background(), PagesDomainsParameters{AccountID: testAccountID, ProjectName: testPagesProjectName}) + if assert.NoError(t, err) { + assert.Equal(t, 1, len(out), "Domains length not correct") + assert.Equal(t, out[0], testPagesDomain, "structs not equal") + } +} + +func TestPages_GetDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/"+testPagesProjectName+"/domains/example.com", 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, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "8232210c-6818-4e34-8d95-cc386874b8d2", + "name": "example.com", + "status": "pending", + "verification_data": { + "status": "active" + }, + "validation_data": { + "status": "active", + "method": "http" + }, + "zone_tag": "023e105f4ecef8ad9ca31a8372d0c353", + "created_on": "2017-01-01T00:00:00Z" + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.GetPagesDomain(context.Background(), PagesDomainParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing project name is thrown + _, err = client.GetPagesDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingProjectName, err) + } + + // Make sure missing domain is thrown + _, err = client.GetPagesDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + + out, err := client.GetPagesDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName, DomainName: "example.com"}) + if assert.NoError(t, err) { + assert.Equal(t, out, testPagesDomain, "structs not equal") + } +} + +func TestPages_PatchDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/"+testPagesProjectName+"/domains/example.com", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "8232210c-6818-4e34-8d95-cc386874b8d2", + "name": "example.com", + "status": "pending", + "verification_data": { + "status": "active" + }, + "validation_data": { + "status": "active", + "method": "http" + }, + "zone_tag": "023e105f4ecef8ad9ca31a8372d0c353", + "created_on": "2017-01-01T00:00:00Z" + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.PagesPatchDomain(context.Background(), PagesDomainParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing project name is thrown + _, err = client.PagesPatchDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingProjectName, err) + } + + // Make sure missing domain is thrown + _, err = client.PagesPatchDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + + out, err := client.PagesPatchDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName, DomainName: "example.com"}) + if assert.NoError(t, err) { + assert.Equal(t, out, testPagesDomain, "structs not equal") + } +} + +func TestPages_AddDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/"+testPagesProjectName+"/domains", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "8232210c-6818-4e34-8d95-cc386874b8d2", + "name": "example.com", + "status": "pending", + "verification_data": { + "status": "active" + }, + "validation_data": { + "status": "active", + "method": "http" + }, + "zone_tag": "023e105f4ecef8ad9ca31a8372d0c353", + "created_on": "2017-01-01T00:00:00Z" + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.PagesAddDomain(context.Background(), PagesDomainParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing project name is thrown + _, err = client.PagesAddDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingProjectName, err) + } + + // Make sure missing domain is thrown + _, err = client.PagesAddDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + + out, err := client.PagesAddDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName, DomainName: "example.com"}) + if assert.NoError(t, err) { + assert.Equal(t, out, testPagesDomain, "structs not equal") + } +} + +func TestPages_DeleteDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/"+testPagesProjectName+"/domains/example.com", 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, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": null +}`) + }) + + // Make sure missing account ID is thrown + err := client.PagesDeleteDomain(context.Background(), PagesDomainParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing project name is thrown + err = client.PagesDeleteDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingProjectName, err) + } + + // Make sure missing domain is thrown + err = client.PagesDeleteDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingDomain, err) + } + + err = client.PagesDeleteDomain(context.Background(), PagesDomainParameters{AccountID: testAccountID, ProjectName: testPagesProjectName, DomainName: "example.com"}) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/pages_project.go b/pkg/cloudflare-go/pages_project.go new file mode 100644 index 000000000..202846158 --- /dev/null +++ b/pkg/cloudflare-go/pages_project.go @@ -0,0 +1,333 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type PagesPreviewDeploymentSetting string + +const ( + PagesPreviewAllBranches PagesPreviewDeploymentSetting = "all" + PagesPreviewNoBranches PagesPreviewDeploymentSetting = "none" + PagesPreviewCustomBranches PagesPreviewDeploymentSetting = "custom" +) + +// PagesProject represents a Pages project. +type PagesProject struct { + Name string `json:"name,omitempty"` + ID string `json:"id"` + CreatedOn *time.Time `json:"created_on"` + SubDomain string `json:"subdomain"` + Domains []string `json:"domains,omitempty"` + Source *PagesProjectSource `json:"source,omitempty"` + BuildConfig PagesProjectBuildConfig `json:"build_config"` + DeploymentConfigs PagesProjectDeploymentConfigs `json:"deployment_configs"` + LatestDeployment PagesProjectDeployment `json:"latest_deployment"` + CanonicalDeployment PagesProjectDeployment `json:"canonical_deployment"` + ProductionBranch string `json:"production_branch,omitempty"` +} + +// PagesProjectSource represents the configuration of a Pages project source. +type PagesProjectSource struct { + Type string `json:"type"` + Config *PagesProjectSourceConfig `json:"config"` +} + +// PagesProjectSourceConfig represents the properties use to configure a Pages project source. +type PagesProjectSourceConfig struct { + Owner string `json:"owner"` + RepoName string `json:"repo_name"` + ProductionBranch string `json:"production_branch"` + PRCommentsEnabled bool `json:"pr_comments_enabled"` + DeploymentsEnabled bool `json:"deployments_enabled"` + ProductionDeploymentsEnabled bool `json:"production_deployments_enabled"` + PreviewDeploymentSetting PagesPreviewDeploymentSetting `json:"preview_deployment_setting"` + PreviewBranchIncludes []string `json:"preview_branch_includes"` + PreviewBranchExcludes []string `json:"preview_branch_excludes"` +} + +// PagesProjectBuildConfig represents the configuration of a Pages project build process. +type PagesProjectBuildConfig struct { + BuildCaching *bool `json:"build_caching,omitempty"` + BuildCommand string `json:"build_command"` + DestinationDir string `json:"destination_dir"` + RootDir string `json:"root_dir"` + WebAnalyticsTag string `json:"web_analytics_tag"` + WebAnalyticsToken string `json:"web_analytics_token"` +} + +// PagesProjectDeploymentConfigs represents the configuration for deployments in a Pages project. +type PagesProjectDeploymentConfigs struct { + Preview PagesProjectDeploymentConfigEnvironment `json:"preview"` + Production PagesProjectDeploymentConfigEnvironment `json:"production"` +} + +// PagesProjectDeploymentConfigEnvironment represents the configuration for preview or production deploys. +type PagesProjectDeploymentConfigEnvironment struct { + EnvVars EnvironmentVariableMap `json:"env_vars,omitempty"` + KvNamespaces NamespaceBindingMap `json:"kv_namespaces,omitempty"` + DoNamespaces NamespaceBindingMap `json:"durable_object_namespaces,omitempty"` + D1Databases D1BindingMap `json:"d1_databases,omitempty"` + R2Bindings R2BindingMap `json:"r2_buckets,omitempty"` + ServiceBindings ServiceBindingMap `json:"services,omitempty"` + CompatibilityDate string `json:"compatibility_date,omitempty"` + CompatibilityFlags []string `json:"compatibility_flags,omitempty"` + FailOpen bool `json:"fail_open"` + AlwaysUseLatestCompatibilityDate bool `json:"always_use_latest_compatibility_date"` + UsageModel UsageModel `json:"usage_model,omitempty"` + Placement *Placement `json:"placement,omitempty"` +} + +// PagesProjectDeployment represents a deployment to a Pages project. +type PagesProjectDeployment struct { + ID string `json:"id"` + ShortID string `json:"short_id"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + Environment string `json:"environment"` + URL string `json:"url"` + CreatedOn *time.Time `json:"created_on"` + ModifiedOn *time.Time `json:"modified_on"` + Aliases []string `json:"aliases,omitempty"` + LatestStage PagesProjectDeploymentStage `json:"latest_stage"` + EnvVars EnvironmentVariableMap `json:"env_vars"` + KvNamespaces NamespaceBindingMap `json:"kv_namespaces,omitempty"` + DoNamespaces NamespaceBindingMap `json:"durable_object_namespaces,omitempty"` + D1Databases D1BindingMap `json:"d1_databases,omitempty"` + R2Bindings R2BindingMap `json:"r2_buckets,omitempty"` + ServiceBindings ServiceBindingMap `json:"services,omitempty"` + Placement *Placement `json:"placement,omitempty"` + DeploymentTrigger PagesProjectDeploymentTrigger `json:"deployment_trigger"` + Stages []PagesProjectDeploymentStage `json:"stages"` + BuildConfig PagesProjectBuildConfig `json:"build_config"` + Source PagesProjectSource `json:"source"` + CompatibilityDate string `json:"compatibility_date,omitempty"` + CompatibilityFlags []string `json:"compatibility_flags,omitempty"` + UsageModel UsageModel `json:"usage_model,omitempty"` + IsSkipped bool `json:"is_skipped"` + ProductionBranch string `json:"production_branch,omitempty"` +} + +// PagesProjectDeploymentStage represents an individual stage in a Pages project deployment. +type PagesProjectDeploymentStage struct { + Name string `json:"name"` + StartedOn *time.Time `json:"started_on,omitempty"` + EndedOn *time.Time `json:"ended_on,omitempty"` + Status string `json:"status"` +} + +// PagesProjectDeploymentTrigger represents information about what caused a deployment. +type PagesProjectDeploymentTrigger struct { + Type string `json:"type"` + Metadata *PagesProjectDeploymentTriggerMetadata `json:"metadata"` +} + +// PagesProjectDeploymentTriggerMetadata represents additional information about the cause of a deployment. +type PagesProjectDeploymentTriggerMetadata struct { + Branch string `json:"branch"` + CommitHash string `json:"commit_hash"` + CommitMessage string `json:"commit_message"` +} + +type pagesProjectResponse struct { + Response + Result PagesProject `json:"result"` +} + +type pagesProjectListResponse struct { + Response + Result []PagesProject `json:"result"` + ResultInfo `json:"result_info"` +} + +type EnvironmentVariableMap map[string]*EnvironmentVariable + +// PagesProjectDeploymentVar represents a deployment environment variable. +type EnvironmentVariable struct { + Value string `json:"value"` + Type EnvVarType `json:"type"` +} + +type EnvVarType string + +const ( + PlainText EnvVarType = "plain_text" + SecretText EnvVarType = "secret_text" +) + +type NamespaceBindingMap map[string]*NamespaceBindingValue + +type NamespaceBindingValue struct { + Value string `json:"namespace_id"` +} + +type R2BindingMap map[string]*R2BindingValue + +type R2BindingValue struct { + Name string `json:"name"` +} + +type D1BindingMap map[string]*D1Binding + +type D1Binding struct { + ID string `json:"id"` +} + +type ServiceBindingMap map[string]*ServiceBinding + +type ServiceBinding struct { + Service string `json:"service"` + Environment string `json:"environment"` +} + +type UsageModel string + +const ( + Bundled UsageModel = "bundled" + Unbound UsageModel = "unbound" + Standard UsageModel = "standard" +) + +type ListPagesProjectsParams struct { + PaginationOptions +} + +type CreatePagesProjectParams struct { + Name string `json:"name,omitempty"` + SubDomain string `json:"subdomain"` + Domains []string `json:"domains,omitempty"` + Source *PagesProjectSource `json:"source,omitempty"` + BuildConfig PagesProjectBuildConfig `json:"build_config"` + DeploymentConfigs PagesProjectDeploymentConfigs `json:"deployment_configs"` + LatestDeployment PagesProjectDeployment `json:"latest_deployment"` + CanonicalDeployment PagesProjectDeployment `json:"canonical_deployment"` + ProductionBranch string `json:"production_branch,omitempty"` +} + +type UpdatePagesProjectParams struct { + // `ID` is used for addressing the resource via the UI or a stable + // anchor whereas `Name` is used for updating the value. + ID string `json:"-"` + Name string `json:"name,omitempty"` + SubDomain string `json:"subdomain"` + Domains []string `json:"domains,omitempty"` + Source *PagesProjectSource `json:"source,omitempty"` + BuildConfig PagesProjectBuildConfig `json:"build_config"` + DeploymentConfigs PagesProjectDeploymentConfigs `json:"deployment_configs"` + LatestDeployment PagesProjectDeployment `json:"latest_deployment"` + CanonicalDeployment PagesProjectDeployment `json:"canonical_deployment"` + ProductionBranch string `json:"production_branch,omitempty"` +} + +// ListPagesProjects returns all Pages projects for an account. +// +// API reference: https://api.cloudflare.com/#pages-project-get-projects +func (api *API) ListPagesProjects(ctx context.Context, rc *ResourceContainer, params ListPagesProjectsParams) ([]PagesProject, ResultInfo, error) { + if rc.Identifier == "" { + return []PagesProject{}, ResultInfo{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/pages/projects", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PagesProject{}, ResultInfo{}, err + } + var r pagesProjectListResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []PagesProject{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, r.ResultInfo, nil +} + +// GetPagesProject returns a single Pages project by name. +// +// API reference: https://api.cloudflare.com/#pages-project-get-project +func (api *API) GetPagesProject(ctx context.Context, rc *ResourceContainer, projectName string) (PagesProject, error) { + if rc.Identifier == "" { + return PagesProject{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s", rc.Identifier, projectName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PagesProject{}, err + } + var r pagesProjectResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProject{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreatePagesProject creates a new Pages project in an account. +// +// API reference: https://api.cloudflare.com/#pages-project-create-project +func (api *API) CreatePagesProject(ctx context.Context, rc *ResourceContainer, params CreatePagesProjectParams) (PagesProject, error) { + if rc.Identifier == "" { + return PagesProject{}, ErrMissingAccountID + } + uri := fmt.Sprintf("/accounts/%s/pages/projects", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return PagesProject{}, err + } + var r pagesProjectResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProject{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdatePagesProject updates an existing Pages project. +// +// API reference: https://api.cloudflare.com/#pages-project-update-project +func (api *API) UpdatePagesProject(ctx context.Context, rc *ResourceContainer, params UpdatePagesProjectParams) (PagesProject, error) { + if rc.Identifier == "" { + return PagesProject{}, ErrMissingAccountID + } + + if params.ID == "" { + return PagesProject{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return PagesProject{}, err + } + var r pagesProjectResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PagesProject{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeletePagesProject deletes a Pages project by name. +// +// API reference: https://api.cloudflare.com/#pages-project-delete-project +func (api *API) DeletePagesProject(ctx context.Context, rc *ResourceContainer, projectName string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + uri := fmt.Sprintf("/accounts/%s/pages/projects/%s", rc.Identifier, projectName) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r pagesProjectResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} diff --git a/pkg/cloudflare-go/pages_project_test.go b/pkg/cloudflare-go/pages_project_test.go new file mode 100644 index 000000000..49861c069 --- /dev/null +++ b/pkg/cloudflare-go/pages_project_test.go @@ -0,0 +1,671 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testPagesProjectResponse = ` + { + "name": "Test Pages Project", + "id": "5a321fc7-3162-7d36-adce-1213996a7", + "created_on": "2021-01-01T00:00:00Z", + "subdomain": "test.pages.dev", + "domains": [ + "testdomain.com", + "testdomain.org" + ], + "production_branch": "main", + "source": { + "type": "github", + "config": { + "owner": "cloudflare", + "repo_name": "pages-test", + "production_branch": "main", + "pr_comments_enabled": true, + "deployments_enabled": true, + "preview_deployment_setting": "custom", + "preview_branch_includes": [ + "release/*", + "production", + "main" + ], + "preview_branch_excludes": [ + "dependabot/*", + "dev", + "*/ignore" + ] + } + }, + "build_config": { + "build_caching": true, + "build_command": "npm run build", + "destination_dir": "build", + "root_dir": "/", + "web_analytics_tag": "0ee1d926cd60d2618a108d4232a75b73", + "web_analytics_token": "c05bb382259183db3a0a822b64c11459" + }, + "deployment_configs": { + "preview": { + "env_vars": { + "BUILD_VERSION": { + "value": "1.2" + }, + "ENV": { + "value": "preview" + }, + "API_KEY": { + "value": "", + "type": "secret_text" + } + }, + "compatibility_date": "2022-08-15", + "compatibility_flags": ["preview_flag"] + }, + "production": { + "env_vars": { + "BUILD_VERSION": { + "value": "1.2" + }, + "ENV": { + "value": "production" + }, + "API_KEY": { + "value": "", + "type": "secret_text" + } + }, + "d1_databases": { + "D1_BINDING": { + "id": "a94509c6-0757-43f3-b053-474b0ab10935" + } + }, + "kv_namespaces": { + "KV_BINDING": { + "namespace_id": "5eb63bbbe01eeed093cb22bb8f5acdc3" + } + }, + "durable_object_namespaces": { + "DO_BINDING": { + "namespace_id": "5eb63bbbe01eeed093cb22bb8f5acdc3" + } + }, + "r2_buckets": { + "R2_BINDING": { + "name": "some-bucket" + } + }, + "services": { + "SERVICE_BINDING": { + "service": "some-worker", + "environment": "production" + } + }, + "compatibility_date": "2022-08-15", + "compatibility_flags": ["production_flag"], + "fail_open": false, + "always_use_latest_compatibility_date": false, + "usage_model": "bundled", + "placement": { + "mode": "smart" + } + } + }, + "latest_deployment": { + "id": "c35216d1-ebac-1a3a-d56c-ad74e54e5", + "short_id": "c35216d1", + "project_id": "5a321fc7-3162-7d36-adce-1213996a7", + "project_name": "pages-test", + "environment": "preview", + "production_branch": "main", + "url": "https://c35216d1.pages-test.pages.dev", + "created_on": "2021-03-09T00:55:03.923456Z", + "modified_on": "2021-03-09T00:58:59.045655Z", + "aliases": [ + "https://branchname.pages-test.pages.dev" + ], + "latest_stage": { + "name": "deploy", + "started_on": "2021-03-09T00:55:03.923456Z", + "ended_on": "2021-03-09T00:58:59.045655Z", + "status": "success" + }, + "env_vars": { + "BUILD_VERSION": { + "value": "1.2" + }, + "ENV": { + "value": "STAGING" + }, + "API_KEY": { + "value": "", + "type": "secret_text" + } + }, + "placement": { + "mode": "smart" + }, + "compatibility_date": "2022-08-15", + "compatibility_flags": ["deployment_flag"], + "fail_open": false, + "always_use_latest_compatibility_date": false, + "usage_model": "bundled", + "deployment_trigger": { + "type": "ad_hoc", + "metadata": { + "branch": "main", + "commit_hash": "fa26be19de6bff93f70bc2308434e4a440bbad02", + "commit_message": "Update index.html" + } + }, + "stages": [ + { + "name": "queued", + "started_on": "2021-06-03T15:38:15.608194Z", + "ended_on": "2021-06-03T15:39:03.134378Z", + "status": "active" + }, + { + "name": "test_stage_1", + "started_on": null, + "ended_on": null, + "status": "idle" + } + ], + "build_config": { + "build_caching": true, + "build_command": "npm run build", + "destination_dir": "build", + "root_dir": "/", + "web_analytics_tag": "0ee1d926cd60d2618a108d4232a75b73", + "web_analytics_token": "c05bb382259183db3a0a822b64c11459" + }, + "source": { + "type": "github", + "config": { + "owner": "cloudflare", + "repo_name": "pages-test", + "production_branch": "main", + "pr_comments_enabled": true, + "deployments_enabled": true, + "preview_deployment_setting": "custom", + "preview_branch_includes": [ + "release/*", + "production", + "main" + ], + "preview_branch_excludes": [ + "dependabot/*", + "dev", + "*/ignore" + ] + } + } + }, + "canonical_deployment": { + "id": "c35216d1-ebac-1a3a-d56c-ad74e54e5", + "short_id": "c35216d1", + "project_id": "5a321fc7-3162-7d36-adce-1213996a7", + "project_name": "pages-test", + "environment": "preview", + "url": "https://c35216d1.pages-test.pages.dev", + "created_on": "2021-03-09T00:55:03.923456Z", + "modified_on": "2021-03-09T00:58:59.045655Z", + "production_branch": "main", + "aliases": [ + "https://branchname.pages-test.pages.dev" + ], + "latest_stage": { + "name": "deploy", + "started_on": "2021-03-09T00:55:03.923456Z", + "ended_on": "2021-03-09T00:58:59.045655Z", + "status": "success" + }, + "env_vars": { + "BUILD_VERSION": { + "value": "1.2" + }, + "ENV": { + "value": "STAGING" + }, + "API_KEY": { + "value": "", + "type": "secret_text" + } + }, + "placement": { + "mode": "smart" + }, + "compatibility_date": "2022-08-15", + "compatibility_flags": ["deployment_flag"], + "fail_open": false, + "always_use_latest_compatibility_date": false, + "build_image_major_version": 1, + "usage_model": "bundled", + "deployment_trigger": { + "type": "ad_hoc", + "metadata": { + "branch": "main", + "commit_hash": "fa26be19de6bff93f70bc2308434e4a440bbad02", + "commit_message": "Update index.html" + } + }, + "stages": [ + { + "name": "queued", + "started_on": "2021-06-03T15:38:15.608194Z", + "ended_on": "2021-06-03T15:39:03.134378Z", + "status": "active" + }, + { + "name": "test_stage_1", + "started_on": null, + "ended_on": null, + "status": "idle" + } + ], + "build_config": { + "build_caching": true, + "build_command": "npm run build", + "destination_dir": "build", + "root_dir": "/", + "web_analytics_tag": "0ee1d926cd60d2618a108d4232a75b73", + "web_analytics_token": "c05bb382259183db3a0a822b64c11459" + }, + "source": { + "type": "github", + "config": { + "owner": "cloudflare", + "repo_name": "pages-test", + "production_branch": "main", + "pr_comments_enabled": true, + "deployments_enabled": true, + "preview_deployment_setting": "custom", + "preview_branch_includes": [ + "release/*", + "production", + "main" + ], + "preview_branch_excludes": [ + "dependabot/*", + "dev", + "*/ignore" + ] + } + } + } + }` +) + +var ( + pagesProjectCreatedOn, _ = time.Parse(time.RFC3339, "2021-01-01T00:00:00Z") + + expectedPagesProject = &PagesProject{ + SubDomain: "test.pages.dev", + Name: "Test Pages Project", + Domains: []string{ + "testdomain.com", + "testdomain.org", + }, + CanonicalDeployment: *expectedPagesProjectDeployment, + BuildConfig: *expectedPagesProjectBuildConfig, + CreatedOn: &pagesProjectCreatedOn, + DeploymentConfigs: *expectedPagesProjectDeploymentConfigs, + Source: expectedPagesProjectSource, + ID: "5a321fc7-3162-7d36-adce-1213996a7", + LatestDeployment: *expectedPagesProjectDeployment, + ProductionBranch: "main", + } + + deploymentCreatedOn, _ = time.Parse(time.RFC3339, "2021-03-09T00:55:03.923456Z") + deploymentModifiedOn, _ = time.Parse(time.RFC3339, "2021-03-09T00:58:59.045655Z") + + expectedPagesProjectDeployment = &PagesProjectDeployment{ + ID: "c35216d1-ebac-1a3a-d56c-ad74e54e5", + ShortID: "c35216d1", + ProjectID: "5a321fc7-3162-7d36-adce-1213996a7", + ProjectName: "pages-test", + Environment: "preview", + URL: "https://c35216d1.pages-test.pages.dev", + CreatedOn: &deploymentCreatedOn, + ModifiedOn: &deploymentModifiedOn, + Aliases: []string{ + "https://branchname.pages-test.pages.dev", + }, + LatestStage: *expectedPagesProjectLatestDeploymentStage, + EnvVars: EnvironmentVariableMap{ + "BUILD_VERSION": &EnvironmentVariable{ + Value: "1.2", + }, + "ENV": &EnvironmentVariable{ + Value: "STAGING", + }, + "API_KEY": &EnvironmentVariable{ + Value: "", + Type: SecretText, + }, + }, + Placement: &Placement{ + Mode: PlacementModeSmart, + }, + CompatibilityFlags: []string{"deployment_flag"}, + CompatibilityDate: "2022-08-15", + UsageModel: Bundled, + DeploymentTrigger: *expectedPagesProjectDeploymentTrigger, + Stages: expectedStages, + BuildConfig: *expectedPagesProjectBuildConfig, + Source: *expectedPagesProjectSource, + ProductionBranch: "main", + } + + latestDeploymentStageStartedOn, _ = time.Parse(time.RFC3339, "2021-03-09T00:55:03.923456Z") + latestDeploymentStageEndedOn, _ = time.Parse(time.RFC3339, "2021-03-09T00:58:59.045655Z") + + expectedPagesProjectLatestDeploymentStage = &PagesProjectDeploymentStage{ + Name: "deploy", + StartedOn: &latestDeploymentStageStartedOn, + EndedOn: &latestDeploymentStageEndedOn, + Status: "success", + } + + expectedPagesProjectDeploymentTrigger = &PagesProjectDeploymentTrigger{ + Type: "ad_hoc", + Metadata: expectedPagesProjectDeploymentTriggerMetadata, + } + + expectedPagesProjectDeploymentTriggerMetadata = &PagesProjectDeploymentTriggerMetadata{ + Branch: "main", + CommitHash: "fa26be19de6bff93f70bc2308434e4a440bbad02", + CommitMessage: "Update index.html", + } + + queuedStageStartedOn, _ = time.Parse(time.RFC3339, "2021-06-03T15:38:15.608194Z") + queuedStageEndedOn, _ = time.Parse(time.RFC3339, "2021-06-03T15:39:03.134378Z") + + expectedStages = []PagesProjectDeploymentStage{ + { + Name: "queued", + StartedOn: &queuedStageStartedOn, + EndedOn: &queuedStageEndedOn, + Status: "active", + }, + { + Name: "test_stage_1", + Status: "idle", + }, + } + + expectedPagesProjectBuildConfig = &PagesProjectBuildConfig{ + BuildCaching: BoolPtr(true), + BuildCommand: "npm run build", + DestinationDir: "build", + RootDir: "/", + WebAnalyticsTag: "0ee1d926cd60d2618a108d4232a75b73", + WebAnalyticsToken: "c05bb382259183db3a0a822b64c11459", + } + + expectedPagesProjectDeploymentConfigs = &PagesProjectDeploymentConfigs{ + Preview: *expectedPagesProjectDeploymentConfigPreview, + Production: *expectedPagesProjectDeploymentConfigProduction, + } + + expectedPagesProjectDeploymentConfigPreview = &PagesProjectDeploymentConfigEnvironment{ + EnvVars: EnvironmentVariableMap{ + "BUILD_VERSION": &EnvironmentVariable{ + Value: "1.2", + }, + "ENV": &EnvironmentVariable{ + Value: "preview", + }, + "API_KEY": &EnvironmentVariable{ + Value: "", + Type: SecretText, + }, + }, + CompatibilityDate: "2022-08-15", + CompatibilityFlags: []string{"preview_flag"}, + } + + expectedPagesProjectDeploymentConfigProduction = &PagesProjectDeploymentConfigEnvironment{ + EnvVars: EnvironmentVariableMap{ + "BUILD_VERSION": &EnvironmentVariable{ + Value: "1.2", + }, + "ENV": &EnvironmentVariable{ + Value: "production", + }, + "API_KEY": &EnvironmentVariable{ + Value: "", + Type: SecretText, + }, + }, + KvNamespaces: NamespaceBindingMap{ + "KV_BINDING": &NamespaceBindingValue{Value: "5eb63bbbe01eeed093cb22bb8f5acdc3"}, + }, + D1Databases: D1BindingMap{ + "D1_BINDING": &D1Binding{ID: "a94509c6-0757-43f3-b053-474b0ab10935"}, + }, + DoNamespaces: NamespaceBindingMap{ + "DO_BINDING": &NamespaceBindingValue{Value: "5eb63bbbe01eeed093cb22bb8f5acdc3"}, + }, + R2Bindings: R2BindingMap{ + "R2_BINDING": &R2BindingValue{Name: "some-bucket"}, + }, + ServiceBindings: ServiceBindingMap{ + "SERVICE_BINDING": &ServiceBinding{ + Service: "some-worker", + Environment: "production", + }, + }, + CompatibilityDate: "2022-08-15", + CompatibilityFlags: []string{"production_flag"}, + FailOpen: false, + AlwaysUseLatestCompatibilityDate: false, + UsageModel: Bundled, + Placement: &Placement{ + Mode: PlacementModeSmart, + }, + } + + expectedPagesProjectSource = &PagesProjectSource{ + Type: "github", + Config: expectedPagesProjectSourceConfig, + } + + expectedPagesProjectSourceConfig = &PagesProjectSourceConfig{ + Owner: "cloudflare", + RepoName: "pages-test", + ProductionBranch: "main", + PRCommentsEnabled: true, + DeploymentsEnabled: true, + PreviewDeploymentSetting: PagesPreviewCustomBranches, + PreviewBranchIncludes: []string{"release/*", "production", "main"}, + PreviewBranchExcludes: []string{"dependabot/*", "dev", "*/ignore"}, + } +) + +func TestListPagesProjects(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ], + "result_info": { + "page": 1, + "per_page": 100, + "count": 1, + "total_count": 1 + } + }`, testPagesProjectResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects", handler) + + expectedPagesProjects := []PagesProject{ + *expectedPagesProject, + } + expectedResultInfo := ResultInfo{ + Page: 1, + PerPage: 100, + Count: 1, + Total: 1, + } + + _, _, err := client.ListPagesProjects(context.Background(), AccountIdentifier(""), ListPagesProjectsParams{}) + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } + + actual, resultInfo, err := client.ListPagesProjects(context.Background(), AccountIdentifier(testAccountID), ListPagesProjectsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, expectedPagesProjects, actual) + assert.Equal(t, expectedResultInfo, resultInfo) + } +} + +func TestPagesProject(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesProjectResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/Test Pages Project", handler) + + _, err := client.GetPagesProject(context.Background(), AccountIdentifier(""), "Test Pages Project") + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } + + actual, err := client.GetPagesProject(context.Background(), AccountIdentifier(testAccountID), "Test Pages Project") + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesProject, actual) + } +} + +func TestCreatePagesProject(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesProjectResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects", handler) + + params := &CreatePagesProjectParams{ + SubDomain: "test.pages.dev", + Name: "Test Pages Project", + Domains: []string{ + "testdomain.com", + "testdomain.org", + }, + CanonicalDeployment: *expectedPagesProjectDeployment, + BuildConfig: *expectedPagesProjectBuildConfig, + DeploymentConfigs: *expectedPagesProjectDeploymentConfigs, + Source: expectedPagesProjectSource, + LatestDeployment: *expectedPagesProjectDeployment, + ProductionBranch: "main", + } + _, err := client.CreatePagesProject(context.Background(), AccountIdentifier(""), *params) + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } + + actual, err := client.CreatePagesProject(context.Background(), AccountIdentifier(testAccountID), *params) + if assert.NoError(t, err) { + assert.Equal(t, *expectedPagesProject, actual) + } +} + +func TestUpdatePagesProject(t *testing.T) { + setup() + defer teardown() + + updateAttributes := &UpdatePagesProjectParams{ + ID: "Test Pages Project", + Name: "updated-project-name", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + }`, testPagesProjectResponse) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/Test Pages Project", handler) + + _, err := client.UpdatePagesProject(context.Background(), AccountIdentifier(""), *updateAttributes) + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } + + _, err = client.UpdatePagesProject(context.Background(), AccountIdentifier(testAccountID), *updateAttributes) + + assert.NoError(t, err) +} + +func TestDeletePagesProject(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/pages/projects/Test Pages Project", handler) + + err := client.DeletePagesProject(context.Background(), AccountIdentifier(""), "Test Pages Project") + if assert.Error(t, err) { + assert.Equal(t, err.Error(), errMissingAccountID) + } + + err = client.DeletePagesProject(context.Background(), AccountIdentifier(testAccountID), "Test Pages Project") + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/pagination.go b/pkg/cloudflare-go/pagination.go new file mode 100644 index 000000000..db06ff8a3 --- /dev/null +++ b/pkg/cloudflare-go/pagination.go @@ -0,0 +1,59 @@ +package cloudflare + +import ( + "math" +) + +// Look first for total_pages, but if total_count and per_page are set then use that to get page count. +func (p ResultInfo) getTotalPages() int { + totalPages := p.TotalPages + if totalPages == 0 && p.Total > 0 && p.PerPage > 0 { + totalPages = int(math.Ceil(float64(p.Total) / float64(p.PerPage))) + } + return totalPages +} + +// Done returns true for the last page and false otherwise. +func (p ResultInfo) Done() bool { + // A little hacky but if the response body is lacking a defined `ResultInfo` + // object the page will be 1 however the counts will be empty so if we have + // that response, we just assume this is the only page. + totalPages := p.getTotalPages() + if p.Page == 1 && totalPages == 0 { + return true + } + + return p.Page > 1 && p.Page > totalPages +} + +// Next advances the page of a paginated API response, but does not fetch the +// next page of results. +func (p ResultInfo) Next() ResultInfo { + // A little hacky but if the response body is lacking a defined `ResultInfo` + // object the page will be 1 however the counts will be empty so if we have + // that response, we just assume this is the only page. + totalPages := p.getTotalPages() + if p.Page == 1 && totalPages == 0 { + return p + } + + // This shouldn't happen normally however, when it does just return the + // current page. + if p.Page > totalPages { + return p + } + + p.Page++ + return p +} + +// HasMorePages returns whether there is another page of results after the +// current one. +func (p ResultInfo) HasMorePages() bool { + totalPages := p.getTotalPages() + if totalPages == 0 { + return false + } + + return p.Page >= 1 && p.Page < totalPages +} diff --git a/pkg/cloudflare-go/pagination_test.go b/pkg/cloudflare-go/pagination_test.go new file mode 100644 index 000000000..2a045c637 --- /dev/null +++ b/pkg/cloudflare-go/pagination_test.go @@ -0,0 +1,130 @@ +package cloudflare + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPagination_Done(t *testing.T) { + testCases := map[string]struct { + r ResultInfo + expected bool + }{ + "missing ResultInfo pagination information": { + r: ResultInfo{Page: 1}, + expected: true, + }, + "total pages greater than page": { + r: ResultInfo{Page: 1, TotalPages: 2}, + expected: false, + }, + "total pages greater than page (alot)": { + r: ResultInfo{Page: 1, TotalPages: 200}, + expected: false, + }, + // this should never happen + "total pages less than page": { + r: ResultInfo{Page: 3, TotalPages: 1}, + expected: true, + }, + "total pages missing but done": { + r: ResultInfo{Page: 4, Total: 70, PerPage: 25}, + expected: true, + }, + "total pages missing and not done": { + r: ResultInfo{Page: 1, Total: 70, PerPage: 25}, + expected: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.r.Done(), tc.expected) + }) + } +} + +func TestPagination_Next(t *testing.T) { + testCases := map[string]struct { + r ResultInfo + expected ResultInfo + }{ + "missing ResultInfo pagination information": { + r: ResultInfo{Page: 1}, + expected: ResultInfo{Page: 1}, + }, + "total pages greater than page": { + r: ResultInfo{Page: 1, TotalPages: 3}, + expected: ResultInfo{Page: 2, TotalPages: 3}, + }, + "total pages greater than page (alot)": { + r: ResultInfo{Page: 1, TotalPages: 3000}, + expected: ResultInfo{Page: 2, TotalPages: 3000}, + }, + // bug, this should never happen + "total pages less than page": { + r: ResultInfo{Page: 3, TotalPages: 1}, + expected: ResultInfo{Page: 3, TotalPages: 1}, + }, + "use per page and greater than page": { + r: ResultInfo{Page: 4, Total: 70, PerPage: 25}, + expected: ResultInfo{Page: 4, Total: 70, PerPage: 25}, + }, + "use per page and less than page": { + r: ResultInfo{Page: 1, Total: 70, PerPage: 25}, + expected: ResultInfo{Page: 2, Total: 70, PerPage: 25}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + assert.Equal(t, tc.r.Next(), tc.expected) + }) + } +} + +func TestPagination_HasMorePages(t *testing.T) { + testCases := map[string]struct { + r ResultInfo + expected bool + }{ + "missing ResultInfo pagination information": { + r: ResultInfo{Page: 1}, + expected: false, + }, + "total pages greater than page": { + r: ResultInfo{Page: 1, TotalPages: 3}, + expected: true, + }, + "total pages greater than page (alot)": { + r: ResultInfo{Page: 1, TotalPages: 3000}, + expected: true, + }, + // bug, this should never happen + "total pages less than page": { + r: ResultInfo{Page: 3, TotalPages: 1}, + expected: false, + }, + "use per page and greater than page": { + r: ResultInfo{Page: 4, Total: 70, PerPage: 25}, + expected: false, + }, + "use per page and less than page": { + r: ResultInfo{Page: 1, Total: 70, PerPage: 25}, + expected: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + assert.Equal(t, tc.r.HasMorePages(), tc.expected) + }) + } +} diff --git a/pkg/cloudflare-go/per_hostname_tls_settings.go b/pkg/cloudflare-go/per_hostname_tls_settings.go new file mode 100644 index 000000000..19c159819 --- /dev/null +++ b/pkg/cloudflare-go/per_hostname_tls_settings.go @@ -0,0 +1,251 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// HostnameTLSSetting represents the metadata for a user-created tls setting. +type HostnameTLSSetting struct { + Hostname string `json:"hostname"` + Value string `json:"value"` + Status string `json:"status"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +// HostnameTLSSettingResponse represents the response from the PUT and DELETE endpoints for per-hostname tls settings. +type HostnameTLSSettingResponse struct { + Response + Result HostnameTLSSetting `json:"result"` +} + +// HostnameTLSSettingsResponse represents the response from the retrieval endpoint for per-hostname tls settings. +type HostnameTLSSettingsResponse struct { + Response + Result []HostnameTLSSetting `json:"result"` + ResultInfo `json:"result_info"` +} + +// ListHostnameTLSSettingsParams represents the data related to per-hostname tls settings being retrieved. +type ListHostnameTLSSettingsParams struct { + Setting string `json:"-" url:"setting,omitempty"` + PaginationOptions `json:"-"` + Limit int `json:"-" url:"limit,omitempty"` + Offset int `json:"-" url:"offset,omitempty"` + Hostname []string `json:"-" url:"hostname,omitempty"` +} + +// UpdateHostnameTLSSettingParams represents the data related to the per-hostname tls setting being updated. +type UpdateHostnameTLSSettingParams struct { + Setting string + Hostname string + Value string `json:"value"` +} + +// DeleteHostnameTLSSettingParams represents the data related to the per-hostname tls setting being deleted. +type DeleteHostnameTLSSettingParams struct { + Setting string + Hostname string +} + +var ( + ErrMissingHostnameTLSSettingName = errors.New("tls setting name required but missing") +) + +// ListHostnameTLSSettings returns a list of all user-created tls setting values for the specified setting and hostnames. +// +// API reference: https://developers.cloudflare.com/api/operations/per-hostname-tls-settings-list +func (api *API) ListHostnameTLSSettings(ctx context.Context, rc *ResourceContainer, params ListHostnameTLSSettingsParams) ([]HostnameTLSSetting, ResultInfo, error) { + if rc.Identifier == "" { + return []HostnameTLSSetting{}, ResultInfo{}, ErrMissingZoneID + } + if params.Setting == "" { + return []HostnameTLSSetting{}, ResultInfo{}, ErrMissingHostnameTLSSettingName + } + + uri := buildURI(fmt.Sprintf("/zones/%s/hostnames/settings/%s", rc.Identifier, params.Setting), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []HostnameTLSSetting{}, ResultInfo{}, err + } + var r HostnameTLSSettingsResponse + if err := json.Unmarshal(res, &r); err != nil { + return []HostnameTLSSetting{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, r.ResultInfo, err +} + +// UpdateHostnameTLSSetting will update the per-hostname tls setting for the specified hostname. +// +// API reference: https://developers.cloudflare.com/api/operations/per-hostname-tls-settings-put +func (api *API) UpdateHostnameTLSSetting(ctx context.Context, rc *ResourceContainer, params UpdateHostnameTLSSettingParams) (HostnameTLSSetting, error) { + if rc.Identifier == "" { + return HostnameTLSSetting{}, ErrMissingZoneID + } + if params.Setting == "" { + return HostnameTLSSetting{}, ErrMissingHostnameTLSSettingName + } + if params.Hostname == "" { + return HostnameTLSSetting{}, ErrMissingHostname + } + + uri := fmt.Sprintf("/zones/%s/hostnames/settings/%s/%s", rc.Identifier, params.Setting, params.Hostname) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return HostnameTLSSetting{}, err + } + var r HostnameTLSSettingResponse + if err := json.Unmarshal(res, &r); err != nil { + return HostnameTLSSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteHostnameTLSSetting will delete the specified per-hostname tls setting. +// +// API reference: https://developers.cloudflare.com/api/operations/per-hostname-tls-settings-delete +func (api *API) DeleteHostnameTLSSetting(ctx context.Context, rc *ResourceContainer, params DeleteHostnameTLSSettingParams) (HostnameTLSSetting, error) { + if rc.Identifier == "" { + return HostnameTLSSetting{}, ErrMissingZoneID + } + if params.Setting == "" { + return HostnameTLSSetting{}, ErrMissingHostnameTLSSettingName + } + if params.Hostname == "" { + return HostnameTLSSetting{}, ErrMissingHostname + } + + uri := fmt.Sprintf("/zones/%s/hostnames/settings/%s/%s", rc.Identifier, params.Setting, params.Hostname) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return HostnameTLSSetting{}, err + } + var r HostnameTLSSettingResponse + if err := json.Unmarshal(res, &r); err != nil { + return HostnameTLSSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// HostnameTLSSettingCiphers represents the metadata for a user-created ciphers tls setting. +type HostnameTLSSettingCiphers struct { + Hostname string `json:"hostname"` + Value []string `json:"value"` + Status string `json:"status"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +// HostnameTLSSettingCiphersResponse represents the response from the PUT and DELETE endpoints for per-hostname ciphers tls settings. +type HostnameTLSSettingCiphersResponse struct { + Response + Result HostnameTLSSettingCiphers `json:"result"` +} + +// HostnameTLSSettingsCiphersResponse represents the response from the retrieval endpoint for per-hostname ciphers tls settings. +type HostnameTLSSettingsCiphersResponse struct { + Response + Result []HostnameTLSSettingCiphers `json:"result"` + ResultInfo `json:"result_info"` +} + +// ListHostnameTLSSettingsCiphersParams represents the data related to per-hostname ciphers tls settings being retrieved. +type ListHostnameTLSSettingsCiphersParams struct { + PaginationOptions + Limit int `json:"-" url:"limit,omitempty"` + Offset int `json:"-" url:"offset,omitempty"` + Hostname []string `json:"-" url:"hostname,omitempty"` +} + +// UpdateHostnameTLSSettingCiphersParams represents the data related to the per-hostname ciphers tls setting being updated. +type UpdateHostnameTLSSettingCiphersParams struct { + Hostname string + Value []string `json:"value"` +} + +// DeleteHostnameTLSSettingCiphersParams represents the data related to the per-hostname ciphers tls setting being deleted. +type DeleteHostnameTLSSettingCiphersParams struct { + Hostname string +} + +// ListHostnameTLSSettingsCiphers returns a list of all user-created tls setting ciphers values for the specified setting and hostnames. +// Ciphers functions are separate due to the API returning a list of strings as the value, rather than a string (as is the case for the other tls settings). +// +// API reference: https://developers.cloudflare.com/api/operations/per-hostname-tls-settings-list +func (api *API) ListHostnameTLSSettingsCiphers(ctx context.Context, rc *ResourceContainer, params ListHostnameTLSSettingsCiphersParams) ([]HostnameTLSSettingCiphers, ResultInfo, error) { + if rc.Identifier == "" { + return []HostnameTLSSettingCiphers{}, ResultInfo{}, ErrMissingZoneID + } + + uri := buildURI(fmt.Sprintf("/zones/%s/hostnames/settings/ciphers", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []HostnameTLSSettingCiphers{}, ResultInfo{}, err + } + var r HostnameTLSSettingsCiphersResponse + if err := json.Unmarshal(res, &r); err != nil { + return []HostnameTLSSettingCiphers{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, r.ResultInfo, err +} + +// UpdateHostnameTLSSettingCiphers will update the per-hostname ciphers tls setting for the specified hostname. +// Ciphers functions are separate due to the API returning a list of strings as the value, rather than a string (as is the case for the other tls settings). +// +// API reference: https://developers.cloudflare.com/api/operations/per-hostname-tls-settings-put +func (api *API) UpdateHostnameTLSSettingCiphers(ctx context.Context, rc *ResourceContainer, params UpdateHostnameTLSSettingCiphersParams) (HostnameTLSSettingCiphers, error) { + if rc.Identifier == "" { + return HostnameTLSSettingCiphers{}, ErrMissingZoneID + } + if params.Hostname == "" { + return HostnameTLSSettingCiphers{}, ErrMissingHostname + } + + uri := fmt.Sprintf("/zones/%s/hostnames/settings/ciphers/%s", rc.Identifier, params.Hostname) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return HostnameTLSSettingCiphers{}, err + } + var r HostnameTLSSettingCiphersResponse + if err := json.Unmarshal(res, &r); err != nil { + return HostnameTLSSettingCiphers{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteHostnameTLSSettingCiphers will delete the specified per-hostname ciphers tls setting value. +// Ciphers functions are separate due to the API returning a list of strings as the value, rather than a string (as is the case for the other tls settings). +// +// API reference: https://developers.cloudflare.com/api/operations/per-hostname-tls-settings-delete +func (api *API) DeleteHostnameTLSSettingCiphers(ctx context.Context, rc *ResourceContainer, params DeleteHostnameTLSSettingCiphersParams) (HostnameTLSSettingCiphers, error) { + if rc.Identifier == "" { + return HostnameTLSSettingCiphers{}, ErrMissingZoneID + } + if params.Hostname == "" { + return HostnameTLSSettingCiphers{}, ErrMissingHostname + } + + uri := fmt.Sprintf("/zones/%s/hostnames/settings/ciphers/%s", rc.Identifier, params.Hostname) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return HostnameTLSSettingCiphers{}, err + } + // Unmarshal into HostnameTLSSettingResponse first because the API returns an empty string + var r HostnameTLSSettingResponse + if err := json.Unmarshal(res, &r); err != nil { + return HostnameTLSSettingCiphers{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return HostnameTLSSettingCiphers{ + Hostname: r.Result.Hostname, + Value: []string{}, + Status: r.Result.Status, + CreatedAt: r.Result.CreatedAt, + UpdatedAt: r.Result.UpdatedAt, + }, nil +} diff --git a/pkg/cloudflare-go/per_hostname_tls_settings_test.go b/pkg/cloudflare-go/per_hostname_tls_settings_test.go new file mode 100644 index 000000000..b857d2eb6 --- /dev/null +++ b/pkg/cloudflare-go/per_hostname_tls_settings_test.go @@ -0,0 +1,273 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListHostnameTLSSettingsMinTLSVersion(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "hostname": "app.example.com", + "value": "1.2", + "status": "active", + "created_at": "2023-07-26T21:12:55.56942Z", + "updated_at": "2023-07-31T22:06:44.739794Z" + } + ], + "result_info": { + "page": 1, + "per_page": 50, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/hostnames/settings/min_tls_version", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-07-26T21:12:55.56942Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-07-31T22:06:44.739794Z") + want := []HostnameTLSSetting{ + { + Hostname: "app.example.com", + Value: "1.2", + Status: "active", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + } + + actual, _, err := client.ListHostnameTLSSettings(context.Background(), ZoneIdentifier(testZoneID), ListHostnameTLSSettingsParams{Setting: "min_tls_version"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateHostnameTLSSettingMinTLSVersion(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "hostname": "app.example.com", + "value": "1.2", + "status": "active", + "created_at": "2023-07-26T21:12:55.56942Z", + "updated_at": "2023-07-31T22:06:44.739794Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/hostnames/settings/min_tls_version/app.example.com", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-07-26T21:12:55.56942Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-07-31T22:06:44.739794Z") + + want := HostnameTLSSetting{ + Hostname: "app.example.com", + Value: "1.2", + Status: "active", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + params := UpdateHostnameTLSSettingParams{ + Setting: "min_tls_version", + Hostname: "app.example.com", + Value: "1.2", + } + actual, err := client.UpdateHostnameTLSSetting(context.Background(), ZoneIdentifier(testZoneID), params) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteHostnameTLSSettingMinTLSVersion(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "hostname": "app.example.com", + "value": "", + "status": "active", + "created_at": "2023-07-26T21:12:55.56942Z", + "updated_at": "2023-07-31T22:06:44.739794Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/hostnames/settings/min_tls_version/app.example.com", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-07-26T21:12:55.56942Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-07-31T22:06:44.739794Z") + want := HostnameTLSSetting{ + Hostname: "app.example.com", + Value: "", + Status: "active", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + actual, err := client.DeleteHostnameTLSSetting(context.Background(), ZoneIdentifier(testZoneID), DeleteHostnameTLSSettingParams{Setting: "min_tls_version", Hostname: "app.example.com"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListHostnameTLSSettingsCiphers(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "hostname": "app.example.com", + "value": [ + "AES128-GCM-SHA256", + "ECDHE-RSA-AES128-GCM-SHA256" + ], + "status": "active", + "created_at": "2023-07-26T21:12:55.56942Z", + "updated_at": "2023-07-31T22:06:44.739794Z" + } + ], + "result_info": { + "page": 1, + "per_page": 50, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/hostnames/settings/ciphers", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-07-26T21:12:55.56942Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-07-31T22:06:44.739794Z") + + want := []HostnameTLSSettingCiphers{ + { + Hostname: "app.example.com", + Value: []string{"AES128-GCM-SHA256", "ECDHE-RSA-AES128-GCM-SHA256"}, + Status: "active", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }, + } + + actual, _, err := client.ListHostnameTLSSettingsCiphers(context.Background(), ZoneIdentifier(testZoneID), ListHostnameTLSSettingsCiphersParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateHostnameTLSSettingCiphers(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "hostname": "app.example.com", + "value": [ + "AES128-GCM-SHA256", + "ECDHE-RSA-AES128-GCM-SHA256" + ], + "status": "active", + "created_at": "2023-07-26T21:12:55.56942Z", + "updated_at": "2023-07-31T22:06:44.739794Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/hostnames/settings/ciphers/app.example.com", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-07-26T21:12:55.56942Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-07-31T22:06:44.739794Z") + + want := HostnameTLSSettingCiphers{ + Hostname: "app.example.com", + Value: []string{"AES128-GCM-SHA256", "ECDHE-RSA-AES128-GCM-SHA256"}, + Status: "active", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + params := UpdateHostnameTLSSettingCiphersParams{ + Hostname: "app.example.com", + Value: []string{"AES128-GCM-SHA256", "ECDHE-RSA-AES128-GCM-SHA256"}, + } + actual, err := client.UpdateHostnameTLSSettingCiphers(context.Background(), ZoneIdentifier(testZoneID), params) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteHostnameTLSSettingCiphers(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "hostname": "app.example.com", + "value": "", + "status": "active", + "created_at": "2023-07-26T21:12:55.56942Z", + "updated_at": "2023-07-31T22:06:44.739794Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/hostnames/settings/ciphers/app.example.com", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-07-26T21:12:55.56942Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-07-31T22:06:44.739794Z") + want := HostnameTLSSettingCiphers{ + Hostname: "app.example.com", + Value: []string{}, + Status: "active", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + actual, err := client.DeleteHostnameTLSSettingCiphers(context.Background(), ZoneIdentifier(testZoneID), DeleteHostnameTLSSettingCiphersParams{Hostname: "app.example.com"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/permission_group.go b/pkg/cloudflare-go/permission_group.go new file mode 100644 index 000000000..9fa7aa315 --- /dev/null +++ b/pkg/cloudflare-go/permission_group.go @@ -0,0 +1,99 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type PermissionGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Meta map[string]string `json:"meta"` + Permissions []Permission `json:"permissions"` +} + +type Permission struct { + ID string `json:"id"` + Key string `json:"key"` + Attributes map[string]string `json:"attributes,omitempty"` // same as Meta in other structs +} + +type PermissionGroupListResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result []PermissionGroup `json:"result"` +} + +type PermissionGroupDetailResponse struct { + Success bool `json:"success"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` + Result PermissionGroup `json:"result"` +} + +type ListPermissionGroupParams struct { + Depth int `url:"depth,omitempty"` + RoleName string `url:"name,omitempty"` +} + +const errMissingPermissionGroupID = "missing required permission group ID" + +var ErrMissingPermissionGroupID = errors.New(errMissingPermissionGroupID) + +// GetPermissionGroup returns a specific permission group from the API given +// the account ID and permission group ID. +func (api *API) GetPermissionGroup(ctx context.Context, rc *ResourceContainer, permissionGroupId string) (PermissionGroup, error) { + if rc.Level != AccountRouteLevel { + return PermissionGroup{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return PermissionGroup{}, ErrMissingAccountID + } + + if permissionGroupId == "" { + return PermissionGroup{}, ErrMissingPermissionGroupID + } + + uri := fmt.Sprintf("/accounts/%s/iam/permission_groups/%s?depth=2", rc.Identifier, permissionGroupId) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return PermissionGroup{}, err + } + + var permissionGroupResponse PermissionGroupDetailResponse + err = json.Unmarshal(res, &permissionGroupResponse) + if err != nil { + return PermissionGroup{}, err + } + + return permissionGroupResponse.Result, nil +} + +// ListPermissionGroups returns all valid permission groups for the provided +// parameters. +func (api *API) ListPermissionGroups(ctx context.Context, rc *ResourceContainer, params ListPermissionGroupParams) ([]PermissionGroup, error) { + if rc.Level != AccountRouteLevel { + return []PermissionGroup{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + params.Depth = 2 + uri := buildURI(fmt.Sprintf("/accounts/%s/iam/permission_groups", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []PermissionGroup{}, err + } + + var permissionGroupResponse PermissionGroupListResponse + err = json.Unmarshal(res, &permissionGroupResponse) + if err != nil { + return []PermissionGroup{}, err + } + + return permissionGroupResponse.Result, nil +} diff --git a/pkg/cloudflare-go/permission_group_test.go b/pkg/cloudflare-go/permission_group_test.go new file mode 100644 index 000000000..d3520db9e --- /dev/null +++ b/pkg/cloudflare-go/permission_group_test.go @@ -0,0 +1,152 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var mockPermissionGroup = PermissionGroup{ + ID: "f08020434ba14a0bb46bd9ff52f23b04", + Name: "Fake Permission Group", + Meta: map[string]string{ + "description": "Can represent a permission group", + }, + Permissions: []Permission{{ + ID: "7d42552322884d19bb63ed7f69b5ac21", + Key: "com.cloudflare.api.account.fake.permission", + Attributes: map[string]string{ + "legacy_name": "#fake:permission", + }, + }}, +} + +func TestListPermissionGroup_ByName(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + { + "id": "f08020434ba14a0bb46bd9ff52f23b04", + "name": "Fake Permission Group", + "status": "V", + "meta": { + "description": "Can represent a permission group" + }, + "created_on": "2022-08-29T16:58:29.745574Z", + "modified_on": "2022-09-12T08:26:37.255907Z", + "permissions": [ + { + "id": "7d42552322884d19bb63ed7f69b5ac21", + "key": "com.cloudflare.api.account.fake.permission", + "attributes": { + "legacy_name": "#fake:permission" + } + } + ] + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/iam/permission_groups", handler) + + result, err := client.ListPermissionGroups(context.Background(), AccountIdentifier(testAccountID), ListPermissionGroupParams{RoleName: "fake-permission-group-name"}) + if assert.NoError(t, err) { + assert.Equal(t, result, []PermissionGroup{mockPermissionGroup}) + } +} + +func TestGetPermissionGroup(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "id": "f08020434ba14a0bb46bd9ff52f23b04", + "name": "Fake Permission Group", + "status": "V", + "meta": { + "description": "Can represent a permission group" + }, + "created_on": "2022-08-29T16:58:29.745574Z", + "modified_on": "2022-09-12T08:26:37.255907Z", + "permissions": [ + { + "id": "7d42552322884d19bb63ed7f69b5ac21", + "key": "com.cloudflare.api.account.fake.permission", + "attributes": { + "legacy_name": "#fake:permission" + } + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/iam/permission_groups/fake-permission-group-id", handler) + + result, err := client.GetPermissionGroup(context.Background(), AccountIdentifier(testAccountID), "fake-permission-group-id") + if assert.NoError(t, err) { + assert.Equal(t, result, mockPermissionGroup) + } +} + +func TestPermissionGroups(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + { + "id": "f08020434ba14a0bb46bd9ff52f23b04", + "name": "Fake Permission Group", + "status": "V", + "meta": { + "description": "Can represent a permission group" + }, + "created_on": "2022-08-29T16:58:29.745574Z", + "modified_on": "2022-09-12T08:26:37.255907Z", + "permissions": [ + { + "id": "7d42552322884d19bb63ed7f69b5ac21", + "key": "com.cloudflare.api.account.fake.permission", + "attributes": { + "legacy_name": "#fake:permission" + } + } + ] + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/iam/permission_groups", handler) + + result, err := client.ListPermissionGroups(context.Background(), AccountIdentifier(testAccountID), ListPermissionGroupParams{}) + if assert.NoError(t, err) { + assert.Equal(t, result, []PermissionGroup{mockPermissionGroup}) + } +} diff --git a/pkg/cloudflare-go/policy.go b/pkg/cloudflare-go/policy.go new file mode 100644 index 000000000..807724120 --- /dev/null +++ b/pkg/cloudflare-go/policy.go @@ -0,0 +1,8 @@ +package cloudflare + +type Policy struct { + ID string `json:"id"` + PermissionGroups []PermissionGroup `json:"permission_groups"` + ResourceGroups []ResourceGroup `json:"resource_groups"` + Access string `json:"access"` +} diff --git a/pkg/cloudflare-go/queue.go b/pkg/cloudflare-go/queue.go new file mode 100644 index 000000000..9f6d6e121 --- /dev/null +++ b/pkg/cloudflare-go/queue.go @@ -0,0 +1,378 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingQueueName = errors.New("required queue name is missing") + ErrMissingQueueConsumerName = errors.New("required queue consumer name is missing") +) + +type Queue struct { + ID string `json:"queue_id,omitempty"` + Name string `json:"queue_name,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + ProducersTotalCount int `json:"producers_total_count,omitempty"` + Producers []QueueProducer `json:"producers,omitempty"` + ConsumersTotalCount int `json:"consumers_total_count,omitempty"` + Consumers []QueueConsumer `json:"consumers,omitempty"` +} + +type QueueProducer struct { + Service string `json:"service,omitempty"` + Environment string `json:"environment,omitempty"` +} + +type QueueConsumer struct { + Name string `json:"-"` + Service string `json:"service,omitempty"` + ScriptName string `json:"script_name,omitempty"` + Environment string `json:"environment,omitempty"` + Settings QueueConsumerSettings `json:"settings,omitempty"` + QueueName string `json:"queue_name,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + DeadLetterQueue string `json:"dead_letter_queue,omitempty"` +} + +type QueueConsumerSettings struct { + BatchSize int `json:"batch_size,omitempty"` + MaxRetires int `json:"max_retries,omitempty"` + MaxWaitTime int `json:"max_wait_time_ms,omitempty"` +} + +type QueueListResponse struct { + Response + ResultInfo `json:"result_info"` + Result []Queue `json:"result"` +} + +type CreateQueueParams struct { + Name string `json:"queue_name"` +} + +type QueueResponse struct { + Response + Result Queue `json:"result"` +} + +type ListQueueConsumersResponse struct { + Response + ResultInfo `json:"result_info"` + Result []QueueConsumer `json:"result"` +} + +type ListQueuesParams struct { + ResultInfo +} + +type QueueConsumerResponse struct { + Response + Result QueueConsumer `json:"result"` +} + +type UpdateQueueParams struct { + Name string `json:"-"` + UpdatedName string `json:"queue_name,omitempty"` +} + +type ListQueueConsumersParams struct { + QueueName string `url:"-"` + ResultInfo +} + +type CreateQueueConsumerParams struct { + QueueName string `json:"-"` + Consumer QueueConsumer +} + +type UpdateQueueConsumerParams struct { + QueueName string `json:"-"` + Consumer QueueConsumer +} + +type DeleteQueueConsumerParams struct { + QueueName, ConsumerName string +} + +// ListQueues returns the queues owned by an account. +// +// API reference: https://api.cloudflare.com/#queue-list-queues +func (api *API) ListQueues(ctx context.Context, rc *ResourceContainer, params ListQueuesParams) ([]Queue, *ResultInfo, error) { + if rc.Identifier == "" { + return []Queue{}, &ResultInfo{}, ErrMissingAccountID + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var queues []Queue + var qResponse QueueListResponse + for { + qResponse = QueueListResponse{} + uri := buildURI(fmt.Sprintf("/accounts/%s/workers/queues", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Queue{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &qResponse) + if err != nil { + return []Queue{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal filters JSON data: %w", err) + } + + queues = append(queues, qResponse.Result...) + params.ResultInfo = qResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return queues, &qResponse.ResultInfo, nil +} + +// CreateQueue creates a new queue. +// +// API reference: https://api.cloudflare.com/#queue-create-queue +func (api *API) CreateQueue(ctx context.Context, rc *ResourceContainer, queue CreateQueueParams) (Queue, error) { + if rc.Identifier == "" { + return Queue{}, ErrMissingAccountID + } + + if queue.Name == "" { + return Queue{}, ErrMissingQueueName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, queue) + if err != nil { + return Queue{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r QueueResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Queue{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteQueue deletes a queue. +// +// API reference: https://api.cloudflare.com/#queue-delete-queue +func (api *API) DeleteQueue(ctx context.Context, rc *ResourceContainer, queueName string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + if queueName == "" { + return ErrMissingQueueName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues/%s", rc.Identifier, queueName) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + return nil +} + +// GetQueue returns a single queue based on the name. +// +// API reference: https://api.cloudflare.com/#queue-get-queue +func (api *API) GetQueue(ctx context.Context, rc *ResourceContainer, queueName string) (Queue, error) { + if rc.Identifier == "" { + return Queue{}, ErrMissingAccountID + } + + if queueName == "" { + return Queue{}, ErrMissingQueueName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues/%s", rc.Identifier, queueName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Queue{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r QueueResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Queue{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateQueue updates a queue. +// +// API reference: https://api.cloudflare.com/#queue-update-queue +func (api *API) UpdateQueue(ctx context.Context, rc *ResourceContainer, params UpdateQueueParams) (Queue, error) { + if rc.Identifier == "" { + return Queue{}, ErrMissingAccountID + } + + if params.Name == "" || params.UpdatedName == "" { + return Queue{}, ErrMissingQueueName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues/%s", rc.Identifier, params.Name) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return Queue{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r QueueResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Queue{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListQueueConsumers returns the consumers of a queue. +// +// API reference: https://api.cloudflare.com/#queue-list-queue-consumers +func (api *API) ListQueueConsumers(ctx context.Context, rc *ResourceContainer, params ListQueueConsumersParams) ([]QueueConsumer, *ResultInfo, error) { + if rc.Identifier == "" { + return []QueueConsumer{}, &ResultInfo{}, ErrMissingAccountID + } + + if params.QueueName == "" { + return []QueueConsumer{}, &ResultInfo{}, ErrMissingQueueName + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var queuesConsumers []QueueConsumer + var qResponse ListQueueConsumersResponse + for { + qResponse = ListQueueConsumersResponse{} + uri := buildURI(fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers", rc.Identifier, params.QueueName), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []QueueConsumer{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &qResponse) + if err != nil { + return []QueueConsumer{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal filters JSON data: %w", err) + } + + queuesConsumers = append(queuesConsumers, qResponse.Result...) + params.ResultInfo = qResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return queuesConsumers, &qResponse.ResultInfo, nil +} + +// CreateQueueConsumer creates a new consumer for a queue. +// +// API reference: https://api.cloudflare.com/#queue-create-queue-consumer +func (api *API) CreateQueueConsumer(ctx context.Context, rc *ResourceContainer, params CreateQueueConsumerParams) (QueueConsumer, error) { + if rc.Identifier == "" { + return QueueConsumer{}, ErrMissingAccountID + } + + if params.QueueName == "" { + return QueueConsumer{}, ErrMissingQueueName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers", rc.Identifier, params.QueueName) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Consumer) + if err != nil { + return QueueConsumer{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r QueueConsumerResponse + err = json.Unmarshal(res, &r) + if err != nil { + return QueueConsumer{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteQueueConsumer deletes the consumer for a queue.. +// +// API reference: https://api.cloudflare.com/#queue-delete-queue-consumer +func (api *API) DeleteQueueConsumer(ctx context.Context, rc *ResourceContainer, params DeleteQueueConsumerParams) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if params.QueueName == "" { + return ErrMissingQueueName + } + + if params.ConsumerName == "" { + return ErrMissingQueueConsumerName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers/%s", rc.Identifier, params.QueueName, params.ConsumerName) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} + +// UpdateQueueConsumer updates the consumer for a queue, or creates one if it does not exist.. +// +// API reference: https://api.cloudflare.com/#queue-update-queue-consumer +func (api *API) UpdateQueueConsumer(ctx context.Context, rc *ResourceContainer, params UpdateQueueConsumerParams) (QueueConsumer, error) { + if rc.Identifier == "" { + return QueueConsumer{}, ErrMissingAccountID + } + + if params.QueueName == "" { + return QueueConsumer{}, ErrMissingQueueName + } + + if params.Consumer.Name == "" { + return QueueConsumer{}, ErrMissingQueueConsumerName + } + + uri := fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers/%s", rc.Identifier, params.QueueName, params.Consumer.Name) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Consumer) + if err != nil { + return QueueConsumer{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r QueueConsumerResponse + err = json.Unmarshal(res, &r) + if err != nil { + return QueueConsumer{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/queue_test.go b/pkg/cloudflare-go/queue_test.go new file mode 100644 index 000000000..b175a7045 --- /dev/null +++ b/pkg/cloudflare-go/queue_test.go @@ -0,0 +1,485 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testQueueID = "6b7efc370ea34ded8327fa20698dfe3a" + testQueueName = "example-queue" + testQueueConsumerName = "example-consumer" +) + +func testQueue() Queue { + CreatedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + ModifiedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + return Queue{ + ID: testQueueID, + Name: testQueueName, + CreatedOn: &CreatedOn, + ModifiedOn: &ModifiedOn, + ProducersTotalCount: 1, + Producers: []QueueProducer{ + { + Service: "example-producer", + Environment: "production", + }, + }, + ConsumersTotalCount: 1, + Consumers: []QueueConsumer{ + { + Service: "example-consumer", + Environment: "production", + Settings: QueueConsumerSettings{ + BatchSize: 10, + MaxRetires: 3, + MaxWaitTime: 5000, + }, + }, + }, + } +} + +func testQueueConsumer() QueueConsumer { + CreatedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + return QueueConsumer{ + Service: "example-consumer", + Environment: "production", + Settings: QueueConsumerSettings{ + BatchSize: 10, + MaxRetires: 3, + MaxWaitTime: 5000, + }, + QueueName: testQueueName, + CreatedOn: &CreatedOn, + } +} + +func TestQueue_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues", testAccountID), 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.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": [ + { + "queue_id": "6b7efc370ea34ded8327fa20698dfe3a", + "queue_name": "example-queue", + "created_on": "2023-01-01T00:00:00Z", + "modified_on": "2023-01-01T00:00:00Z", + "producers_total_count": 1, + "producers": [ + { + "service": "example-producer", + "environment": "production" + } + ], + "consumers_total_count": 1, + "consumers": [ + { + "service": "example-consumer", + "environment": "production", + "settings": { + "batch_size": 10, + "max_retries": 3, + "max_wait_time_ms": 5000 + } + } + ] + } + ], + "result_info": { + "page": 1, + "per_page": 100, + "count": 1, + "total_count": 1, + "total_pages": 1 + } +}`) + }) + + _, _, err := client.ListQueues(context.Background(), AccountIdentifier(""), ListQueuesParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + result, _, err := client.ListQueues(context.Background(), AccountIdentifier(testAccountID), ListQueuesParams{}) + if assert.NoError(t, err) { + assert.Equal(t, 1, len(result)) + assert.Equal(t, testQueue(), result[0]) + } +} + +func TestQueue_Create(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": { + "queue_id": "6b7efc370ea34ded8327fa20698dfe3a", + "queue_name": "example-queue", + "created_on": "2023-01-01T00:00:00Z", + "modified_on": "2023-01-01T00:00:00Z" + } + }`) + }) + _, err := client.CreateQueue(context.Background(), AccountIdentifier(""), CreateQueueParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.CreateQueue(context.Background(), AccountIdentifier(testAccountID), CreateQueueParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + results, err := client.CreateQueue(context.Background(), AccountIdentifier(testAccountID), CreateQueueParams{Name: "example-queue"}) + if assert.NoError(t, err) { + CreatedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + ModifiedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + createdQueue := Queue{ + ID: testQueueID, + Name: testQueueName, + CreatedOn: &CreatedOn, + ModifiedOn: &ModifiedOn, + } + + assert.Equal(t, createdQueue, results) + } +} + +func TestQueue_Delete(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s", testAccountID, testQueueName), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + }) + err := client.DeleteQueue(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + err = client.DeleteQueue(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + err = client.DeleteQueue(context.Background(), AccountIdentifier(testAccountID), testQueueName) + assert.NoError(t, err) +} + +func TestQueue_Get(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s", testAccountID, testQueueID), 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "queue_id": "6b7efc370ea34ded8327fa20698dfe3a", + "queue_name": "example-queue", + "created_on": "2023-01-01T00:00:00Z", + "modified_on": "2023-01-01T00:00:00Z", + "producers_total_count": 1, + "producers": [ + { + "service": "example-producer", + "environment": "production" + } + ], + "consumers_total_count": 1, + "consumers": [ + { + "service": "example-consumer", + "environment": "production", + "settings": { + "batch_size": 10, + "max_retries": 3, + "max_wait_time_ms": 5000 + } + } + ] + } + }`) + }) + + _, err := client.GetQueue(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.GetQueue(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + result, err := client.GetQueue(context.Background(), AccountIdentifier(testAccountID), testQueueID) + if assert.NoError(t, err) { + assert.Equal(t, testQueue(), result) + } +} + +func TestQueue_Update(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s", testAccountID, testQueueName), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": null, + "messages": null, + "result": { + "queue_id": "6b7efc370ea34ded8327fa20698dfe3a", + "queue_name": "renamed-example-queue", + "created_on": "2023-01-01T00:00:00Z", + "modified_on": "2023-01-01T00:00:00Z" + } + }`) + }) + _, err := client.UpdateQueue(context.Background(), AccountIdentifier(""), UpdateQueueParams{Name: testQueueName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.UpdateQueue(context.Background(), AccountIdentifier(testAccountID), UpdateQueueParams{Name: testQueueName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + results, err := client.UpdateQueue(context.Background(), AccountIdentifier(testAccountID), UpdateQueueParams{Name: testQueueName, UpdatedName: "renamed-example-queue"}) + if assert.NoError(t, err) { + CreatedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + ModifiedOn, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z") + createdQueue := Queue{ + ID: testQueueID, + Name: "renamed-example-queue", + CreatedOn: &CreatedOn, + ModifiedOn: &ModifiedOn, + } + + assert.Equal(t, createdQueue, results) + } +} + +func TestQueue_ListConsumers(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers", testAccountID, testQueueName), 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.Fprintf(w, ` + { + "success": true, + "errors": null, + "messages": null, + "result": [ + { + "service": "example-consumer", + "environment": "production", + "settings": { + "batch_size": 10, + "max_retries": 3, + "max_wait_time_ms": 5000 + }, + "queue_name": "example-queue", + "created_on": "2023-01-01T00:00:00Z" + } + ], + "result_info": { + "page": 1, + "per_page": 100, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + }`) + }) + + _, _, err := client.ListQueueConsumers(context.Background(), AccountIdentifier(""), ListQueueConsumersParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, _, err = client.ListQueueConsumers(context.Background(), AccountIdentifier(testAccountID), ListQueueConsumersParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + result, _, err := client.ListQueueConsumers(context.Background(), AccountIdentifier(testAccountID), ListQueueConsumersParams{QueueName: testQueueName}) + if assert.NoError(t, err) { + assert.Equal(t, 1, len(result)) + assert.Equal(t, testQueueConsumer(), result[0]) + } +} + +func TestQueue_CreateConsumer(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers", testAccountID, testQueueName), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "service": "example-consumer", + "environment": "production", + "settings": { + "batch_size": 10, + "max_retries": 3, + "max_wait_time_ms": 5000 + }, + "dead_letter_queue": "example-dlq", + "queue_name": "example-queue", + "created_on": "2023-01-01T00:00:00Z" + } + }`) + }) + + _, err := client.CreateQueueConsumer(context.Background(), AccountIdentifier(""), CreateQueueConsumerParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.CreateQueueConsumer(context.Background(), AccountIdentifier(testAccountID), CreateQueueConsumerParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + result, err := client.CreateQueueConsumer(context.Background(), AccountIdentifier(testAccountID), CreateQueueConsumerParams{QueueName: testQueueName, Consumer: QueueConsumer{ + Service: "example-consumer", + Environment: "production", + }}) + if assert.NoError(t, err) { + expectedQueueConsumer := testQueueConsumer() + expectedQueueConsumer.DeadLetterQueue = "example-dlq" + assert.Equal(t, expectedQueueConsumer, result) + } +} + +func TestQueue_DeleteConsumer(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers/%s", testAccountID, testQueueName, testQueueConsumerName), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": null + }`) + }) + + err := client.DeleteQueueConsumer(context.Background(), AccountIdentifier(""), DeleteQueueConsumerParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + err = client.DeleteQueueConsumer(context.Background(), AccountIdentifier(testAccountID), DeleteQueueConsumerParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + err = client.DeleteQueueConsumer(context.Background(), AccountIdentifier(testAccountID), DeleteQueueConsumerParams{QueueName: testQueueName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueConsumerName, err) + } + + err = client.DeleteQueueConsumer(context.Background(), AccountIdentifier(testAccountID), DeleteQueueConsumerParams{QueueName: testQueueName, ConsumerName: testQueueConsumerName}) + assert.NoError(t, err) +} + +func TestQueue_UpdateConsumer(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/queues/%s/consumers/%s", testAccountID, testQueueName, testQueueConsumerName), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "service": "example-consumer", + "environment": "production", + "settings": { + "batch_size": 10, + "max_retries": 3, + "max_wait_time_ms": 5000 + }, + "queue_name": "example-queue", + "created_on": "2023-01-01T00:00:00Z" + } + }`) + }) + + _, err := client.UpdateQueueConsumer(context.Background(), AccountIdentifier(""), UpdateQueueConsumerParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.UpdateQueueConsumer(context.Background(), AccountIdentifier(testAccountID), UpdateQueueConsumerParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueName, err) + } + + _, err = client.UpdateQueueConsumer(context.Background(), AccountIdentifier(testAccountID), UpdateQueueConsumerParams{QueueName: testQueueName}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingQueueConsumerName, err) + } + + result, err := client.UpdateQueueConsumer(context.Background(), AccountIdentifier(testAccountID), UpdateQueueConsumerParams{QueueName: testQueueName, Consumer: QueueConsumer{ + Name: testQueueConsumerName, + Service: "example-consumer", + Environment: "production", + }}) + if assert.NoError(t, err) { + assert.Equal(t, testQueueConsumer(), result) + } +} diff --git a/pkg/cloudflare-go/r2_bucket.go b/pkg/cloudflare-go/r2_bucket.go new file mode 100644 index 000000000..c9ce8fe0f --- /dev/null +++ b/pkg/cloudflare-go/r2_bucket.go @@ -0,0 +1,147 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingBucketName = errors.New("require bucket name missing") +) + +// R2Bucket defines a container for objects stored in R2 Storage. +type R2Bucket struct { + Name string `json:"name"` + CreationDate *time.Time `json:"creation_date,omitempty"` + Location string `json:"location,omitempty"` +} + +// R2Buckets represents the map of buckets response from +// the R2 buckets endpoint. +type R2Buckets struct { + Buckets []R2Bucket `json:"buckets"` +} + +// R2BucketListResponse represents the response from the list +// R2 buckets endpoint. +type R2BucketListResponse struct { + Result R2Buckets `json:"result"` + Response +} + +type ListR2BucketsParams struct { + Name string `url:"name_contains,omitempty"` + StartAfter string `url:"start_after,omitempty"` + PerPage int64 `url:"per_page,omitempty"` + Order string `url:"order,omitempty"` + Direction string `url:"direction,omitempty"` + Cursor string `url:"cursor,omitempty"` +} + +type CreateR2BucketParameters struct { + Name string `json:"name,omitempty"` + LocationHint string `json:"locationHint,omitempty"` +} + +type R2BucketResponse struct { + Result R2Bucket `json:"result"` + Response +} + +// ListR2Buckets Lists R2 buckets. +func (api *API) ListR2Buckets(ctx context.Context, rc *ResourceContainer, params ListR2BucketsParams) ([]R2Bucket, error) { + if rc.Identifier == "" { + return []R2Bucket{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/r2/buckets", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []R2Bucket{}, err + } + + var r2BucketListResponse R2BucketListResponse + err = json.Unmarshal(res, &r2BucketListResponse) + if err != nil { + return []R2Bucket{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r2BucketListResponse.Result.Buckets, nil +} + +// CreateR2Bucket Creates a new R2 bucket. +// +// API reference: https://api.cloudflare.com/#r2-bucket-create-bucket +func (api *API) CreateR2Bucket(ctx context.Context, rc *ResourceContainer, params CreateR2BucketParameters) (R2Bucket, error) { + if rc.Identifier == "" { + return R2Bucket{}, ErrMissingAccountID + } + + if params.Name == "" { + return R2Bucket{}, ErrMissingBucketName + } + + uri := fmt.Sprintf("/accounts/%s/r2/buckets", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return R2Bucket{}, err + } + + var r2BucketResponse R2BucketResponse + err = json.Unmarshal(res, &r2BucketResponse) + if err != nil { + return R2Bucket{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r2BucketResponse.Result, nil +} + +// GetR2Bucket Gets an existing R2 bucket. +// +// API reference: https://api.cloudflare.com/#r2-bucket-get-bucket +func (api *API) GetR2Bucket(ctx context.Context, rc *ResourceContainer, bucketName string) (R2Bucket, error) { + if rc.Identifier == "" { + return R2Bucket{}, ErrMissingAccountID + } + + if bucketName == "" { + return R2Bucket{}, ErrMissingBucketName + } + + uri := fmt.Sprintf("/accounts/%s/r2/buckets/%s", rc.Identifier, bucketName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return R2Bucket{}, err + } + + var r2BucketResponse R2BucketResponse + err = json.Unmarshal(res, &r2BucketResponse) + if err != nil { + return R2Bucket{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r2BucketResponse.Result, nil +} + +// DeleteR2Bucket Deletes an existing R2 bucket. +// +// API reference: https://api.cloudflare.com/#r2-bucket-delete-bucket +func (api *API) DeleteR2Bucket(ctx context.Context, rc *ResourceContainer, bucketName string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if bucketName == "" { + return ErrMissingBucketName + } + + uri := fmt.Sprintf("/accounts/%s/r2/buckets/%s", rc.Identifier, bucketName) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + return err +} diff --git a/pkg/cloudflare-go/r2_bucket_test.go b/pkg/cloudflare-go/r2_bucket_test.go new file mode 100644 index 000000000..ac9b2f960 --- /dev/null +++ b/pkg/cloudflare-go/r2_bucket_test.go @@ -0,0 +1,160 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testBucketName = "example-bucket" + +func TestR2_ListBuckets(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/r2/buckets", testAccountID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "buckets": [ + { + "name": "example-bucket", + "creation_date": "2022-06-24T19:58:49.477Z" + } + ] + } +}`) + }) + createDate, _ := time.Parse(time.RFC3339, "2022-06-24T19:58:49.477Z") + want := []R2Bucket{ + { + Name: "example-bucket", + CreationDate: &createDate, + }, + } + actual, err := client.ListR2Buckets(context.Background(), AccountIdentifier(testAccountID), ListR2BucketsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestR2_GetBucket(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/r2/buckets/%s", testAccountID, testBucketName), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "example-bucket", + "creation_date": "2022-06-24T19:58:49.477Z", + "location": "ENAM" + } +}`) + }) + + _, err := client.GetR2Bucket(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.GetR2Bucket(context.Background(), AccountIdentifier(testAccountID), "") + + if assert.Error(t, err) { + assert.Equal(t, ErrMissingBucketName, err) + } + + createDate, _ := time.Parse(time.RFC3339, "2022-06-24T19:58:49.477Z") + want := R2Bucket{ + Name: testBucketName, + CreationDate: &createDate, + Location: "ENAM", + } + + actual, err := client.GetR2Bucket(context.Background(), AccountIdentifier(testAccountID), testBucketName) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestR2_CreateBucket(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/r2/buckets", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "example-bucket", + "creation_date": "2022-06-24T19:58:49.477Z", + "location": "ENAM" + } +}`) + }) + + _, err := client.CreateR2Bucket(context.Background(), AccountIdentifier(""), CreateR2BucketParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.CreateR2Bucket(context.Background(), AccountIdentifier(testAccountID), CreateR2BucketParameters{Name: ""}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingBucketName, err) + } + createDate, _ := time.Parse(time.RFC3339, "2022-06-24T19:58:49.477Z") + want := R2Bucket{ + Name: testBucketName, + CreationDate: &createDate, + Location: "ENAM", + } + + actual, err := client.CreateR2Bucket(context.Background(), AccountIdentifier(testAccountID), CreateR2BucketParameters{Name: testBucketName, LocationHint: "ENAM"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestR2_DeleteBucket(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/r2/buckets/%s", testAccountID, testBucketName), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {} +}`) + }) + + err := client.DeleteR2Bucket(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + err = client.DeleteR2Bucket(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingBucketName, err) + } + + err = client.DeleteR2Bucket(context.Background(), AccountIdentifier(testAccountID), "example-bucket") + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/rate_limiting.go b/pkg/cloudflare-go/rate_limiting.go new file mode 100644 index 000000000..1a9f5cb44 --- /dev/null +++ b/pkg/cloudflare-go/rate_limiting.go @@ -0,0 +1,199 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// RateLimit is a policy than can be applied to limit traffic within a customer domain. +type RateLimit struct { + ID string `json:"id,omitempty"` + Disabled bool `json:"disabled,omitempty"` + Description string `json:"description,omitempty"` + Match RateLimitTrafficMatcher `json:"match"` + Bypass []RateLimitKeyValue `json:"bypass,omitempty"` + Threshold int `json:"threshold"` + Period int `json:"period"` + Action RateLimitAction `json:"action"` + Correlate *RateLimitCorrelate `json:"correlate,omitempty"` +} + +// RateLimitTrafficMatcher contains the rules that will be used to apply a rate limit to traffic. +type RateLimitTrafficMatcher struct { + Request RateLimitRequestMatcher `json:"request"` + Response RateLimitResponseMatcher `json:"response"` +} + +// RateLimitRequestMatcher contains the matching rules pertaining to requests. +type RateLimitRequestMatcher struct { + Methods []string `json:"methods,omitempty"` + Schemes []string `json:"schemes,omitempty"` + URLPattern string `json:"url,omitempty"` +} + +// RateLimitResponseMatcher contains the matching rules pertaining to responses. +type RateLimitResponseMatcher struct { + Statuses []int `json:"status,omitempty"` + OriginTraffic *bool `json:"origin_traffic,omitempty"` // api defaults to true so we need an explicit empty value + Headers []RateLimitResponseMatcherHeader `json:"headers,omitempty"` +} + +// RateLimitResponseMatcherHeader contains the structure of the origin +// HTTP headers used in request matcher checks. +type RateLimitResponseMatcherHeader struct { + Name string `json:"name"` + Op string `json:"op"` + Value string `json:"value"` +} + +// RateLimitKeyValue is k-v formatted as expected in the rate limit description. +type RateLimitKeyValue struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// RateLimitAction is the action that will be taken when the rate limit threshold is reached. +type RateLimitAction struct { + Mode string `json:"mode"` + Timeout int `json:"timeout"` + Response *RateLimitActionResponse `json:"response"` +} + +// RateLimitActionResponse is the response that will be returned when rate limit action is triggered. +type RateLimitActionResponse struct { + ContentType string `json:"content_type"` + Body string `json:"body"` +} + +// RateLimitCorrelate pertainings to NAT support. +type RateLimitCorrelate struct { + By string `json:"by"` +} + +type rateLimitResponse struct { + Response + Result RateLimit `json:"result"` +} + +type rateLimitListResponse struct { + Response + Result []RateLimit `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// CreateRateLimit creates a new rate limit for a zone. +// +// API reference: https://api.cloudflare.com/#rate-limits-for-a-zone-create-a-ratelimit +func (api *API) CreateRateLimit(ctx context.Context, zoneID string, limit RateLimit) (RateLimit, error) { + uri := fmt.Sprintf("/zones/%s/rate_limits", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, limit) + if err != nil { + return RateLimit{}, err + } + var r rateLimitResponse + if err := json.Unmarshal(res, &r); err != nil { + return RateLimit{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListRateLimits returns Rate Limits for a zone, paginated according to the provided options +// +// API reference: https://api.cloudflare.com/#rate-limits-for-a-zone-list-rate-limits +func (api *API) ListRateLimits(ctx context.Context, zoneID string, pageOpts PaginationOptions) ([]RateLimit, ResultInfo, error) { + uri := buildURI(fmt.Sprintf("/zones/%s/rate_limits", zoneID), pageOpts) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []RateLimit{}, ResultInfo{}, err + } + + var r rateLimitListResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []RateLimit{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, r.ResultInfo, nil +} + +// ListAllRateLimits returns all Rate Limits for a zone. +// +// API reference: https://api.cloudflare.com/#rate-limits-for-a-zone-list-rate-limits +func (api *API) ListAllRateLimits(ctx context.Context, zoneID string) ([]RateLimit, error) { + pageOpts := PaginationOptions{ + PerPage: 100, // this is the max page size allowed + Page: 1, + } + + allRateLimits := make([]RateLimit, 0) + for { + rateLimits, resultInfo, err := api.ListRateLimits(ctx, zoneID, pageOpts) + if err != nil { + return []RateLimit{}, err + } + allRateLimits = append(allRateLimits, rateLimits...) + // total pages is not returned on this call + // if number of records is less than the max, this must be the last page + // in case TotalCount % PerPage = 0, the last request will return an empty list + if resultInfo.Count < resultInfo.PerPage { + break + } + // continue with the next page + pageOpts.Page = pageOpts.Page + 1 + } + + return allRateLimits, nil +} + +// RateLimit fetches detail about one Rate Limit for a zone. +// +// API reference: https://api.cloudflare.com/#rate-limits-for-a-zone-rate-limit-details +func (api *API) RateLimit(ctx context.Context, zoneID, limitID string) (RateLimit, error) { + uri := fmt.Sprintf("/zones/%s/rate_limits/%s", zoneID, limitID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return RateLimit{}, err + } + var r rateLimitResponse + err = json.Unmarshal(res, &r) + if err != nil { + return RateLimit{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateRateLimit lets you replace a Rate Limit for a zone. +// +// API reference: https://api.cloudflare.com/#rate-limits-for-a-zone-update-rate-limit +func (api *API) UpdateRateLimit(ctx context.Context, zoneID, limitID string, limit RateLimit) (RateLimit, error) { + uri := fmt.Sprintf("/zones/%s/rate_limits/%s", zoneID, limitID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, limit) + if err != nil { + return RateLimit{}, err + } + var r rateLimitResponse + if err := json.Unmarshal(res, &r); err != nil { + return RateLimit{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteRateLimit deletes a Rate Limit for a zone. +// +// API reference: https://api.cloudflare.com/#rate-limits-for-a-zone-delete-rate-limit +func (api *API) DeleteRateLimit(ctx context.Context, zoneID, limitID string) error { + uri := fmt.Sprintf("/zones/%s/rate_limits/%s", zoneID, limitID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r rateLimitResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} diff --git a/pkg/cloudflare-go/rate_limiting_example_test.go b/pkg/cloudflare-go/rate_limiting_example_test.go new file mode 100644 index 000000000..a894d8b4e --- /dev/null +++ b/pkg/cloudflare-go/rate_limiting_example_test.go @@ -0,0 +1,108 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +var exampleNewRateLimit = cloudflare.RateLimit{ + Description: "test", + Match: cloudflare.RateLimitTrafficMatcher{ + Request: cloudflare.RateLimitRequestMatcher{ + URLPattern: "exampledomain.com/test-rate-limit", + }, + }, + Threshold: 0, + Period: 0, + Action: cloudflare.RateLimitAction{ + Mode: "ban", + Timeout: 60, + }, + Correlate: &cloudflare.RateLimitCorrelate{ + By: "nat", + }, +} + +func ExampleAPI_CreateRateLimit() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + rateLimit, err := api.CreateRateLimit(context.Background(), zoneID, exampleNewRateLimit) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", rateLimit) +} + +func ExampleAPI_ListRateLimits() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + pageOpts := cloudflare.PaginationOptions{ + PerPage: 5, + Page: 1, + } + rateLimits, _, err := api.ListRateLimits(context.Background(), zoneID, pageOpts) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", rateLimits) + for _, r := range rateLimits { + fmt.Printf("%+v\n", r) + } +} + +func ExampleAPI_RateLimit() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + rateLimits, err := api.RateLimit(context.Background(), zoneID, "my_rate_limit_id") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", rateLimits) +} + +func ExampleAPI_DeleteRateLimit() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName(domain) + if err != nil { + log.Fatal(err) + } + + err = api.DeleteRateLimit(context.Background(), zoneID, "my_rate_limit_id") + if err != nil { + log.Fatal(err) + } +} diff --git a/pkg/cloudflare-go/rate_limiting_test.go b/pkg/cloudflare-go/rate_limiting_test.go new file mode 100644 index 000000000..9af0650c6 --- /dev/null +++ b/pkg/cloudflare-go/rate_limiting_test.go @@ -0,0 +1,361 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + rateLimitID = "72dae2fc158942f2adb1dd2a3d4143bc" + serverRateLimitDescription = `{ + "id": "72dae2fc158942f2adb1dd2a3d4143bc", + "disabled": false, + "description": "test", + "match": { + "request": { + "methods": [ + "_ALL_" + ], + "schemes": [ + "_ALL_" + ], + "url": "exampledomain.com/test-rate-limit" + }, + "response": { + "origin_traffic": true + } + }, + "login_protect": false, + "threshold": 50, + "period": 1, + "action": { + "mode": "ban", + "timeout": 60 + }, + "correlate": { + "by": "nat" + } +} +` +) + +var expectedOriginTraffic = true +var expectedRateLimitStruct = RateLimit{ + ID: "72dae2fc158942f2adb1dd2a3d4143bc", + Disabled: false, + Description: "test", + Match: RateLimitTrafficMatcher{ + Request: RateLimitRequestMatcher{ + Methods: []string{"_ALL_"}, + Schemes: []string{"_ALL_"}, + URLPattern: "exampledomain.com/test-rate-limit", + }, + Response: RateLimitResponseMatcher{ + OriginTraffic: &expectedOriginTraffic, + }, + }, + Threshold: 50, + Period: 1, + Action: RateLimitAction{ + Mode: "ban", + Timeout: 60, + }, + Correlate: &RateLimitCorrelate{ + By: "nat", + }, +} + +func TestListRateLimits(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 1, + "per_page": 25, + "count": 1, + "total_count": 1 + } + } + `, serverRateLimitDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits", handler) + want := []RateLimit{expectedRateLimitStruct} + + actual, _, err := client.ListRateLimits(context.Background(), testZoneID, PaginationOptions{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListRateLimitsWithPageOpts(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 1, + "per_page": 25, + "count": 1, + "total_count": 1 + } + } + `, serverRateLimitDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits", handler) + want := []RateLimit{expectedRateLimitStruct} + + pageOpts := PaginationOptions{ + PerPage: 50, + } + actual, _, err := client.ListRateLimits(context.Background(), testZoneID, pageOpts) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListAllRateLimitsDoesPagination(t *testing.T) { + setup() + defer teardown() + + oneHundredRateLimitRecords := strings.Repeat(serverRateLimitDescription+",", 99) + serverRateLimitDescription + 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") + if r.URL.Query().Get("page") == "1" { + fmt.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 1, + "per_page": 100, + "count": 100, + "total_count": 101 + } + } + `, oneHundredRateLimitRecords) + } else if r.URL.Query().Get("page") == "2" { + fmt.Fprintf(w, `{ + "result": [ + %s + ], + "success": true, + "errors": null, + "messages": null, + "result_info": { + "page": 2, + "per_page": 100, + "count": 1, + "total_count": 101 + } + } + `, serverRateLimitDescription) + } + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits", handler) + want := make([]RateLimit, 101) + for i := range want { + want[i] = expectedRateLimitStruct + } + + actual, err := client.ListAllRateLimits(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetRateLimit(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, serverRateLimitDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits/"+rateLimitID, handler) + want := expectedRateLimitStruct + + actual, err := client.RateLimit(context.Background(), testZoneID, rateLimitID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateRateLimit(t *testing.T) { + setup() + defer teardown() + newRateLimit := RateLimit{ + Description: "test", + Match: RateLimitTrafficMatcher{ + Request: RateLimitRequestMatcher{ + URLPattern: "exampledomain.com/test-rate-limit", + }, + }, + Period: 1, + Threshold: 50, + Action: RateLimitAction{ + Mode: "ban", + Timeout: 60, + }, + Correlate: &RateLimitCorrelate{ + By: "nat", + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, serverRateLimitDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits", handler) + want := expectedRateLimitStruct + + actual, err := client.CreateRateLimit(context.Background(), testZoneID, newRateLimit) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateRateLimitWithZeroedThreshold(t *testing.T) { + setup() + defer teardown() + newRateLimit := RateLimit{ + Description: "test", + Match: RateLimitTrafficMatcher{ + Request: RateLimitRequestMatcher{ + URLPattern: "exampledomain.com/test-rate-limit", + }, + }, + Period: 0, // 0 is the default values if int fields are not set + Threshold: 0, + Action: RateLimitAction{ + Mode: "ban", + Timeout: 60, + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.WriteHeader(400) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": null, + "success": false, + "errors": [{ "message": "ratelimit.api.validation_error:threshold is too low and must be at least 2,sample_rate is too low and must be at least 1 second" } ], + "messages": null + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits", handler) + + actual, err := client.CreateRateLimit(context.Background(), testZoneID, newRateLimit) + assert.Error(t, err) + assert.Equal(t, RateLimit{}, actual) +} + +func TestUpdateRateLimit(t *testing.T) { + setup() + defer teardown() + newRateLimit := RateLimit{ + Description: "test-2", + Match: RateLimitTrafficMatcher{ + Request: RateLimitRequestMatcher{ + URLPattern: "exampledomain.com/test-rate-limit-2", + }, + }, + Period: 2, + Threshold: 100, + Action: RateLimitAction{ + Mode: "ban", + Timeout: 600, + }, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": %s, + "success": true, + "errors": null, + "messages": null + } + `, serverRateLimitDescription) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits/"+rateLimitID, handler) + want := expectedRateLimitStruct + + actual, err := client.UpdateRateLimit(context.Background(), testZoneID, rateLimitID, newRateLimit) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteRateLimit(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": null, + "success": true, + "errors": null, + "messages": null + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/rate_limits/"+rateLimitID, handler) + + err := client.DeleteRateLimit(context.Background(), testZoneID, rateLimitID) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/regional_hostnames.go b/pkg/cloudflare-go/regional_hostnames.go new file mode 100644 index 000000000..51227709a --- /dev/null +++ b/pkg/cloudflare-go/regional_hostnames.go @@ -0,0 +1,191 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type Region struct { + Key string `json:"key"` + Label string `json:"label"` +} + +type RegionalHostname struct { + Hostname string `json:"hostname"` + RegionKey string `json:"region_key"` + CreatedOn *time.Time `json:"created_on,omitempty"` +} + +// regionalHostnameResponse contains an API Response from a Create, Get, Update, or Delete call. +type regionalHostnameResponse struct { + Response + Result RegionalHostname `json:"result"` +} + +type ListDataLocalizationRegionsParams struct{} +type ListDataLocalizationRegionalHostnamesParams struct{} + +type CreateDataLocalizationRegionalHostnameParams struct { + Hostname string `json:"hostname"` + RegionKey string `json:"region_key"` +} + +type UpdateDataLocalizationRegionalHostnameParams struct { + Hostname string `json:"-"` + RegionKey string `json:"region_key"` +} + +// ListDataLocalizationRegions lists all available regions. +// +// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api +func (api *API) ListDataLocalizationRegions(ctx context.Context, rc *ResourceContainer, params ListDataLocalizationRegionsParams) ([]Region, error) { + if rc.Level != AccountRouteLevel { + return []Region{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return []Region{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/addressing/regional_hostnames/regions", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Region{}, err + } + result := struct { + Result []Region `json:"result"` + }{} + if err := json.Unmarshal(res, &result); err != nil { + return []Region{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result.Result, nil +} + +// ListDataLocalizationRegionalHostnames lists all regional hostnames for a zone. +// +// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api +func (api *API) ListDataLocalizationRegionalHostnames(ctx context.Context, rc *ResourceContainer, params ListDataLocalizationRegionalHostnamesParams) ([]RegionalHostname, error) { + if rc.Level != ZoneRouteLevel { + return []RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return []RegionalHostname{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []RegionalHostname{}, err + } + result := struct { + Result []RegionalHostname `json:"result"` + }{} + if err := json.Unmarshal(res, &result); err != nil { + return []RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result.Result, nil +} + +// CreateDataLocalizationRegionalHostname lists all regional hostnames for a zone. +// +// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api +func (api *API) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, params CreateDataLocalizationRegionalHostnameParams) (RegionalHostname, error) { + if rc.Level != ZoneRouteLevel { + return RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return RegionalHostname{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return RegionalHostname{}, err + } + result := regionalHostnameResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result.Result, nil +} + +// GetDataLocalizationRegionalHostname returns the details of a specific regional hostname. +// +// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api +func (api *API) GetDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, hostname string) (RegionalHostname, error) { + if rc.Level != ZoneRouteLevel { + return RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return RegionalHostname{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames/%s", rc.Identifier, hostname) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return RegionalHostname{}, err + } + + result := regionalHostnameResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result.Result, nil +} + +// UpdateDataLocalizationRegionalHostname returns the details of a specific regional hostname. +// +// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api +func (api *API) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, params UpdateDataLocalizationRegionalHostnameParams) (RegionalHostname, error) { + if rc.Level != ZoneRouteLevel { + return RegionalHostname{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return RegionalHostname{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames/%s", rc.Identifier, params.Hostname) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return RegionalHostname{}, err + } + result := regionalHostnameResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return RegionalHostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result.Result, nil +} + +// DeleteDataLocalizationRegionalHostname deletes a regional hostname. +// +// API reference: https://developers.cloudflare.com/data-localization/regional-services/get-started/#configure-regional-services-via-api +func (api *API) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *ResourceContainer, hostname string) error { + if rc.Level != ZoneRouteLevel { + return fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/addressing/regional_hostnames/%s", rc.Identifier, hostname) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cloudflare-go/regional_hostnames_test.go b/pkg/cloudflare-go/regional_hostnames_test.go new file mode 100644 index 000000000..0391d3ccb --- /dev/null +++ b/pkg/cloudflare-go/regional_hostnames_test.go @@ -0,0 +1,219 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const regionalHostname = "eu.example.com" + +func TestListRegions(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + { + "key": "ca", + "label": "Canada" + }, + { + "key": "eu", + "label": "Europe" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/addressing/regional_hostnames/regions", handler) + + want := []Region{ + { + Key: "ca", + Label: "Canada", + }, + { + Key: "eu", + Label: "Europe", + }, + } + + actual, err := client.ListDataLocalizationRegions(context.Background(), AccountIdentifier(testAccountID), ListDataLocalizationRegionsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListRegionalHostnames(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [ + { + "hostname": "%s", + "region_key": "ca", + "created_on": "2023-01-14T00:47:57.060267Z" + } + ], + "success": true, + "errors": [], + "messages": [] + }`, regionalHostname) + } + + mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z") + want := []RegionalHostname{ + { + Hostname: regionalHostname, + RegionKey: "ca", + CreatedOn: &createdOn, + }, + } + + actual, err := client.ListDataLocalizationRegionalHostnames(context.Background(), ZoneIdentifier(testZoneID), ListDataLocalizationRegionalHostnamesParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateRegionalHostname(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "hostname": "%s", + "region_key": "ca", + "created_on": "2023-01-14T00:47:57.060267Z" + }, + "success": true, + "errors": [], + "messages": [] + }`, regionalHostname) + } + + mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames", handler) + + params := CreateDataLocalizationRegionalHostnameParams{ + RegionKey: "ca", + Hostname: regionalHostname, + } + + want := RegionalHostname{ + RegionKey: "ca", + Hostname: regionalHostname, + } + + actual, err := client.CreateDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), params) + createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z") + want.CreatedOn = &createdOn + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetRegionalHostname(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "hostname": "%s", + "region_key": "ca", + "created_on": "2023-01-14T00:47:57.060267Z" + }, + "success": true, + "errors": [], + "messages": [] + }`, regionalHostname) + } + + mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames/"+regionalHostname, handler) + + actual, err := client.GetDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), regionalHostname) + createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z") + want := RegionalHostname{ + RegionKey: "ca", + Hostname: regionalHostname, + CreatedOn: &createdOn, + } + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateRegionalHostname(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "hostname": "%s", + "region_key": "eu", + "created_on": "2023-01-14T00:47:57.060267Z" + }, + "success": true, + "errors": [], + "messages": [] + }`, regionalHostname) + } + + params := UpdateDataLocalizationRegionalHostnameParams{ + RegionKey: "eu", + Hostname: regionalHostname, + } + + want := RegionalHostname{ + RegionKey: "eu", + Hostname: regionalHostname, + } + + mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames/"+regionalHostname, handler) + + actual, err := client.UpdateDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), params) + createdOn, _ := time.Parse(time.RFC3339, "2023-01-14T00:47:57.060267Z") + want.CreatedOn = &createdOn + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteRegionalHostname(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + } + + mux.HandleFunc("/zones/"+testZoneID+"/addressing/regional_hostnames/"+regionalHostname, handler) + + err := client.DeleteDataLocalizationRegionalHostname(context.Background(), ZoneIdentifier(testZoneID), regionalHostname) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/regional_tiered_cache.go b/pkg/cloudflare-go/regional_tiered_cache.go new file mode 100644 index 000000000..eb3254be2 --- /dev/null +++ b/pkg/cloudflare-go/regional_tiered_cache.go @@ -0,0 +1,85 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// RegionalTieredCache is the structure of the API object for the regional tiered cache +// setting. +type RegionalTieredCache struct { + ID string `json:"id,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + Value string `json:"value"` +} + +// RegionalTieredCacheDetailsResponse is the API response for the regional tiered cache +// setting. +type RegionalTieredCacheDetailsResponse struct { + Result RegionalTieredCache `json:"result"` + Response +} + +type zoneRegionalTieredCacheSingleResponse struct { + Response + Result RegionalTieredCache `json:"result"` +} + +type GetRegionalTieredCacheParams struct{} + +type UpdateRegionalTieredCacheParams struct { + Value string `json:"value"` +} + +// GetRegionalTieredCache returns information about the current regional tiered +// cache settings. +// +// API reference: https://developers.cloudflare.com/api/operations/zone-cache-settings-get-regional-tiered-cache-setting +func (api *API) GetRegionalTieredCache(ctx context.Context, rc *ResourceContainer, params GetRegionalTieredCacheParams) (RegionalTieredCache, error) { + if rc.Level != ZoneRouteLevel { + return RegionalTieredCache{}, ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/cache/regional_tiered_cache", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return RegionalTieredCache{}, err + } + + var RegionalTieredCacheDetailsResponse RegionalTieredCacheDetailsResponse + err = json.Unmarshal(res, &RegionalTieredCacheDetailsResponse) + if err != nil { + return RegionalTieredCache{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return RegionalTieredCacheDetailsResponse.Result, nil +} + +// UpdateRegionalTieredCache updates the regional tiered cache setting for a +// zone. +// +// API reference: https://developers.cloudflare.com/api/operations/zone-cache-settings-change-regional-tiered-cache-setting +func (api *API) UpdateRegionalTieredCache(ctx context.Context, rc *ResourceContainer, params UpdateRegionalTieredCacheParams) (RegionalTieredCache, error) { + if rc.Level != ZoneRouteLevel { + return RegionalTieredCache{}, ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/%s/%s/cache/regional_tiered_cache", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return RegionalTieredCache{}, err + } + + response := &zoneRegionalTieredCacheSingleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return RegionalTieredCache{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} diff --git a/pkg/cloudflare-go/regional_tiered_cache_test.go b/pkg/cloudflare-go/regional_tiered_cache_test.go new file mode 100644 index 000000000..df74f3bfe --- /dev/null +++ b/pkg/cloudflare-go/regional_tiered_cache_test.go @@ -0,0 +1,90 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var regionalTieredCacheTimestampString = "2019-02-20T22:37:07.107449Z" +var regionalTieredCacheTimestamp, _ = time.Parse(time.RFC3339Nano, regionalTieredCacheTimestampString) + +func TestRegionalTieredCache(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "regional_tiered_cache", + "value": "on", + "modified_on": "%s" + } + } + `, regionalTieredCacheTimestampString) + } + + mux.HandleFunc("/zones/"+testZoneID+"/cache/regional_tiered_cache", handler) + want := RegionalTieredCache{ + ID: "regional_tiered_cache", + Value: "on", + ModifiedOn: regionalTieredCacheTimestamp, + } + + actual, err := client.GetRegionalTieredCache( + context.Background(), + ZoneIdentifier(testZoneID), + GetRegionalTieredCacheParams{}, + ) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateRegionalTieredCache(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "regional_tiered_cache", + "value": "off", + "modified_on": "%s" + } + } + `, regionalTieredCacheTimestampString) + } + + mux.HandleFunc("/zones/"+testZoneID+"/cache/regional_tiered_cache", handler) + want := RegionalTieredCache{ + ID: "regional_tiered_cache", + Value: "off", + ModifiedOn: regionalTieredCacheTimestamp, + } + + actual, err := client.UpdateRegionalTieredCache( + context.Background(), + ZoneIdentifier(testZoneID), + UpdateRegionalTieredCacheParams{Value: "off"}, + ) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/registrar.go b/pkg/cloudflare-go/registrar.go new file mode 100644 index 000000000..68144ac4a --- /dev/null +++ b/pkg/cloudflare-go/registrar.go @@ -0,0 +1,176 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// RegistrarDomain is the structure of the API response for a new +// Cloudflare Registrar domain. +type RegistrarDomain struct { + ID string `json:"id"` + Available bool `json:"available"` + SupportedTLD bool `json:"supported_tld"` + CanRegister bool `json:"can_register"` + TransferIn RegistrarTransferIn `json:"transfer_in"` + CurrentRegistrar string `json:"current_registrar"` + ExpiresAt time.Time `json:"expires_at"` + RegistryStatuses string `json:"registry_statuses"` + Locked bool `json:"locked"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RegistrantContact RegistrantContact `json:"registrant_contact"` +} + +// RegistrarTransferIn contains the structure for a domain transfer in +// request. +type RegistrarTransferIn struct { + UnlockDomain string `json:"unlock_domain"` + DisablePrivacy string `json:"disable_privacy"` + EnterAuthCode string `json:"enter_auth_code"` + ApproveTransfer string `json:"approve_transfer"` + AcceptFoa string `json:"accept_foa"` + CanCancelTransfer bool `json:"can_cancel_transfer"` +} + +// RegistrantContact is the contact details for the domain registration. +type RegistrantContact struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Organization string `json:"organization"` + Address string `json:"address"` + Address2 string `json:"address2"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + Country string `json:"country"` + Phone string `json:"phone"` + Email string `json:"email"` + Fax string `json:"fax"` +} + +// RegistrarDomainConfiguration is the structure for making updates to +// and existing domain. +type RegistrarDomainConfiguration struct { + NameServers []string `json:"name_servers"` + Privacy bool `json:"privacy"` + Locked bool `json:"locked"` + AutoRenew bool `json:"auto_renew"` +} + +// RegistrarDomainDetailResponse is the structure of the detailed +// response from the API for a single domain. +type RegistrarDomainDetailResponse struct { + Response + Result RegistrarDomain `json:"result"` +} + +// RegistrarDomainsDetailResponse is the structure of the detailed +// response from the API. +type RegistrarDomainsDetailResponse struct { + Response + Result []RegistrarDomain `json:"result"` +} + +// RegistrarDomain returns a single domain based on the account ID and +// domain name. +// +// API reference: https://api.cloudflare.com/#registrar-domains-get-domain +func (api *API) RegistrarDomain(ctx context.Context, accountID, domainName string) (RegistrarDomain, error) { + uri := fmt.Sprintf("/accounts/%s/registrar/domains/%s", accountID, domainName) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return RegistrarDomain{}, err + } + + var r RegistrarDomainDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return RegistrarDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// RegistrarDomains returns all registrar domains based on the account +// ID. +// +// API reference: https://api.cloudflare.com/#registrar-domains-list-domains +func (api *API) RegistrarDomains(ctx context.Context, accountID string) ([]RegistrarDomain, error) { + uri := fmt.Sprintf("/accounts/%s/registrar/domains", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []RegistrarDomain{}, err + } + + var r RegistrarDomainsDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []RegistrarDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// TransferRegistrarDomain initiates the transfer from another registrar +// to Cloudflare Registrar. +// +// API reference: https://api.cloudflare.com/#registrar-domains-transfer-domain +func (api *API) TransferRegistrarDomain(ctx context.Context, accountID, domainName string) ([]RegistrarDomain, error) { + uri := fmt.Sprintf("/accounts/%s/registrar/domains/%s/transfer", accountID, domainName) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return []RegistrarDomain{}, err + } + + var r RegistrarDomainsDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []RegistrarDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CancelRegistrarDomainTransfer cancels a pending domain transfer. +// +// API reference: https://api.cloudflare.com/#registrar-domains-cancel-transfer +func (api *API) CancelRegistrarDomainTransfer(ctx context.Context, accountID, domainName string) ([]RegistrarDomain, error) { + uri := fmt.Sprintf("/accounts/%s/registrar/domains/%s/cancel_transfer", accountID, domainName) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return []RegistrarDomain{}, err + } + + var r RegistrarDomainsDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []RegistrarDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateRegistrarDomain updates an existing Registrar Domain configuration. +// +// API reference: https://api.cloudflare.com/#registrar-domains-update-domain +func (api *API) UpdateRegistrarDomain(ctx context.Context, accountID, domainName string, domainConfiguration RegistrarDomainConfiguration) (RegistrarDomain, error) { + uri := fmt.Sprintf("/accounts/%s/registrar/domains/%s", accountID, domainName) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, domainConfiguration) + if err != nil { + return RegistrarDomain{}, err + } + + var r RegistrarDomainDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return RegistrarDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/registrar_example_test.go b/pkg/cloudflare-go/registrar_example_test.go new file mode 100644 index 000000000..5e6229bc0 --- /dev/null +++ b/pkg/cloudflare-go/registrar_example_test.go @@ -0,0 +1,85 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_RegistrarDomain() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + domain, err := api.RegistrarDomain(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com") + if err != nil { + log.Fatal(err) + } + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", domain) +} + +func ExampleAPI_RegistrarDomains() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + domains, err := api.RegistrarDomains(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", domains) +} + +func ExampleAPI_TransferRegistrarDomain() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + domain, err := api.TransferRegistrarDomain(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", domain) +} + +func ExampleAPI_CancelRegistrarDomainTransfer() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + domains, err := api.CancelRegistrarDomainTransfer(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", domains) +} + +func ExampleAPI_UpdateRegistrarDomain() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + domain, err := api.UpdateRegistrarDomain(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com", cloudflare.RegistrarDomainConfiguration{ + NameServers: []string{"ns1.cloudflare.com", "ns2.cloudflare.com"}, + Locked: false, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", domain) +} diff --git a/pkg/cloudflare-go/registrar_test.go b/pkg/cloudflare-go/registrar_test.go new file mode 100644 index 000000000..98590ac45 --- /dev/null +++ b/pkg/cloudflare-go/registrar_test.go @@ -0,0 +1,375 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + createdAndModifiedTimestamp, _ = time.Parse(time.RFC3339, "2018-08-28T17:26:26Z") + expiresAtTimestamp, _ = time.Parse(time.RFC3339, "2019-08-28T23:59:59Z") + expectedRegistrarTransferIn = RegistrarTransferIn{ + UnlockDomain: "ok", + DisablePrivacy: "ok", + EnterAuthCode: "needed", + ApproveTransfer: "unknown", + AcceptFoa: "needed", + CanCancelTransfer: true, + } + expectedRegistrarContact = RegistrantContact{ + ID: "ea95132c15732412d22c1476fa83f27a", + FirstName: "John", + LastName: "Appleseed", + Organization: "Cloudflare, Inc.", + Address: "123 Sesame St.", + Address2: "Suite 430", + City: "Austin", + State: "TX", + Zip: "12345", + Country: "US", + Phone: "+1 123-123-1234", + Email: "user@example.com", + Fax: "123-867-5309", + } + expectedRegistrarDomain = RegistrarDomain{ + ID: "ea95132c15732412d22c1476fa83f27a", + Available: false, + SupportedTLD: true, + CanRegister: false, + TransferIn: expectedRegistrarTransferIn, + CurrentRegistrar: "Cloudflare", + ExpiresAt: expiresAtTimestamp, + RegistryStatuses: "ok,serverTransferProhibited", + Locked: false, + CreatedAt: createdAndModifiedTimestamp, + UpdatedAt: createdAndModifiedTimestamp, + RegistrantContact: expectedRegistrarContact, + } +) + +func TestRegistrarDomain(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ea95132c15732412d22c1476fa83f27a", + "available": false, + "supported_tld": true, + "can_register": false, + "transfer_in": { + "unlock_domain": "ok", + "disable_privacy": "ok", + "enter_auth_code": "needed", + "approve_transfer": "unknown", + "accept_foa": "needed", + "can_cancel_transfer": true + }, + "current_registrar": "Cloudflare", + "expires_at": "2019-08-28T23:59:59Z", + "registry_statuses": "ok,serverTransferProhibited", + "locked": false, + "created_at": "2018-08-28T17:26:26Z", + "updated_at": "2018-08-28T17:26:26Z", + "registrant_contact": { + "id": "ea95132c15732412d22c1476fa83f27a", + "first_name": "John", + "last_name": "Appleseed", + "organization": "Cloudflare, Inc.", + "address": "123 Sesame St.", + "address2": "Suite 430", + "city": "Austin", + "state": "TX", + "zip": "12345", + "country": "US", + "phone": "+1 123-123-1234", + "email": "user@example.com", + "fax": "123-867-5309" + } + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/registrar/domains/cloudflare.com", handler) + + actual, err := client.RegistrarDomain(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com") + + if assert.NoError(t, err) { + assert.Equal(t, expectedRegistrarDomain, actual) + } +} + +func TestRegistrarDomains(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "ea95132c15732412d22c1476fa83f27a", + "available": false, + "supported_tld": true, + "can_register": false, + "transfer_in": { + "unlock_domain": "ok", + "disable_privacy": "ok", + "enter_auth_code": "needed", + "approve_transfer": "unknown", + "accept_foa": "needed", + "can_cancel_transfer": true + }, + "current_registrar": "Cloudflare", + "expires_at": "2019-08-28T23:59:59Z", + "registry_statuses": "ok,serverTransferProhibited", + "locked": false, + "created_at": "2018-08-28T17:26:26Z", + "updated_at": "2018-08-28T17:26:26Z", + "registrant_contact": { + "id": "ea95132c15732412d22c1476fa83f27a", + "first_name": "John", + "last_name": "Appleseed", + "organization": "Cloudflare, Inc.", + "address": "123 Sesame St.", + "address2": "Suite 430", + "city": "Austin", + "state": "TX", + "zip": "12345", + "country": "US", + "phone": "+1 123-123-1234", + "email": "user@example.com", + "fax": "123-867-5309" + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/registrar/domains", handler) + + actual, err := client.RegistrarDomains(context.Background(), "01a7362d577a6c3019a474fd6f485823") + + if assert.NoError(t, err) { + assert.Equal(t, []RegistrarDomain{expectedRegistrarDomain}, actual) + } +} + +func TestTransferRegistrarDomain(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "ea95132c15732412d22c1476fa83f27a", + "available": false, + "supported_tld": true, + "can_register": false, + "transfer_in": { + "unlock_domain": "ok", + "disable_privacy": "ok", + "enter_auth_code": "needed", + "approve_transfer": "unknown", + "accept_foa": "needed", + "can_cancel_transfer": true + }, + "current_registrar": "Cloudflare", + "expires_at": "2019-08-28T23:59:59Z", + "registry_statuses": "ok,serverTransferProhibited", + "locked": false, + "created_at": "2018-08-28T17:26:26Z", + "updated_at": "2018-08-28T17:26:26Z", + "registrant_contact": { + "id": "ea95132c15732412d22c1476fa83f27a", + "first_name": "John", + "last_name": "Appleseed", + "organization": "Cloudflare, Inc.", + "address": "123 Sesame St.", + "address2": "Suite 430", + "city": "Austin", + "state": "TX", + "zip": "12345", + "country": "US", + "phone": "+1 123-123-1234", + "email": "user@example.com", + "fax": "123-867-5309" + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/registrar/domains/cloudflare.com/transfer", handler) + + actual, err := client.TransferRegistrarDomain(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com") + + if assert.NoError(t, err) { + assert.Equal(t, []RegistrarDomain{expectedRegistrarDomain}, actual) + } +} + +func TestCancelRegistrarDomainTransfer(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "ea95132c15732412d22c1476fa83f27a", + "available": false, + "supported_tld": true, + "can_register": false, + "transfer_in": { + "unlock_domain": "ok", + "disable_privacy": "ok", + "enter_auth_code": "needed", + "approve_transfer": "unknown", + "accept_foa": "needed", + "can_cancel_transfer": true + }, + "current_registrar": "Cloudflare", + "expires_at": "2019-08-28T23:59:59Z", + "registry_statuses": "ok,serverTransferProhibited", + "locked": false, + "created_at": "2018-08-28T17:26:26Z", + "updated_at": "2018-08-28T17:26:26Z", + "registrant_contact": { + "id": "ea95132c15732412d22c1476fa83f27a", + "first_name": "John", + "last_name": "Appleseed", + "organization": "Cloudflare, Inc.", + "address": "123 Sesame St.", + "address2": "Suite 430", + "city": "Austin", + "state": "TX", + "zip": "12345", + "country": "US", + "phone": "+1 123-123-1234", + "email": "user@example.com", + "fax": "123-867-5309" + } + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/registrar/domains/cloudflare.com/cancel_transfer", handler) + + actual, err := client.CancelRegistrarDomainTransfer(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com") + + if assert.NoError(t, err) { + assert.Equal(t, []RegistrarDomain{expectedRegistrarDomain}, actual) + } +} + +func TestUpdateRegistrarDomain(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "ea95132c15732412d22c1476fa83f27a", + "available": false, + "supported_tld": true, + "can_register": false, + "transfer_in": { + "unlock_domain": "ok", + "disable_privacy": "ok", + "enter_auth_code": "needed", + "approve_transfer": "unknown", + "accept_foa": "needed", + "can_cancel_transfer": true + }, + "current_registrar": "Cloudflare", + "expires_at": "2019-08-28T23:59:59Z", + "registry_statuses": "ok,serverTransferProhibited", + "locked": false, + "created_at": "2018-08-28T17:26:26Z", + "updated_at": "2018-08-28T17:26:26Z", + "registrant_contact": { + "id": "ea95132c15732412d22c1476fa83f27a", + "first_name": "John", + "last_name": "Appleseed", + "organization": "Cloudflare, Inc.", + "address": "123 Sesame St.", + "address2": "Suite 430", + "city": "Austin", + "state": "TX", + "zip": "12345", + "country": "US", + "phone": "+1 123-123-1234", + "email": "user@example.com", + "fax": "123-867-5309" + } + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/registrar/domains/cloudflare.com", handler) + + actual, err := client.UpdateRegistrarDomain(context.Background(), "01a7362d577a6c3019a474fd6f485823", "cloudflare.com", RegistrarDomainConfiguration{ + NameServers: []string{"ns1.cloudflare.com", "ns2.cloudflare.com"}, + Locked: false, + }) + + if assert.NoError(t, err) { + assert.Equal(t, expectedRegistrarDomain, actual) + } +} diff --git a/pkg/cloudflare-go/resource.go b/pkg/cloudflare-go/resource.go new file mode 100644 index 000000000..d78197593 --- /dev/null +++ b/pkg/cloudflare-go/resource.go @@ -0,0 +1,114 @@ +package cloudflare + +import "fmt" + +// RouteLevel holds the "level" where the resource resides. Commonly used in +// routing configurations or builders. +type RouteLevel string + +// ResourceType holds the type of the resource. This is similar to `RouteLevel` +// however this is the singular version of `RouteLevel` and isn't suitable for +// use in routing. +type ResourceType string + +const ( + user = "user" + zone = "zone" + account = "account" + + zones = zone + "s" + accounts = account + "s" + + AccountRouteLevel RouteLevel = accounts + ZoneRouteLevel RouteLevel = zones + UserRouteLevel RouteLevel = user + + AccountType ResourceType = account + ZoneType ResourceType = zone + UserType ResourceType = user +) + +// ResourceContainer defines an API resource you wish to target. Should not be +// used directly, use `UserIdentifier`, `ZoneIdentifier` and `AccountIdentifier` +// instead. +type ResourceContainer struct { + Level RouteLevel + Identifier string + Type ResourceType +} + +func (r RouteLevel) String() string { + switch r { + case AccountRouteLevel: + return accounts + case ZoneRouteLevel: + return zones + case UserRouteLevel: + return user + default: + return "unknown" + } +} + +func (r ResourceType) String() string { + switch r { + case AccountType: + return account + case ZoneType: + return zone + case UserType: + return user + default: + return "unknown" + } +} + +// Returns a URL fragment of the endpoint scoped by the container. +// +// For example, a zone identifier would have a fragment like "zones/foobar" while +// an account identifier would generate "accounts/foobar". +func (rc *ResourceContainer) URLFragment() string { + if rc.Level == "" { + return rc.Identifier + } + + if rc.Level == UserRouteLevel { + return user + } + + return fmt.Sprintf("%s/%s", rc.Level, rc.Identifier) +} + +// ResourceIdentifier returns a generic *ResourceContainer. +func ResourceIdentifier(id string) *ResourceContainer { + return &ResourceContainer{ + Identifier: id, + } +} + +// UserIdentifier returns a user level *ResourceContainer. +func UserIdentifier(id string) *ResourceContainer { + return &ResourceContainer{ + Level: UserRouteLevel, + Identifier: id, + Type: UserType, + } +} + +// ZoneIdentifier returns a zone level *ResourceContainer. +func ZoneIdentifier(id string) *ResourceContainer { + return &ResourceContainer{ + Level: ZoneRouteLevel, + Identifier: id, + Type: ZoneType, + } +} + +// AccountIdentifier returns an account level *ResourceContainer. +func AccountIdentifier(id string) *ResourceContainer { + return &ResourceContainer{ + Level: AccountRouteLevel, + Identifier: id, + Type: AccountType, + } +} diff --git a/pkg/cloudflare-go/resource_group.go b/pkg/cloudflare-go/resource_group.go new file mode 100644 index 000000000..6d25ffb5e --- /dev/null +++ b/pkg/cloudflare-go/resource_group.go @@ -0,0 +1,53 @@ +package cloudflare + +import "fmt" + +type ResourceGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Meta map[string]string `json:"meta"` + Scope Scope `json:"scope"` +} + +type Scope struct { + Key string `json:"key"` + ScopeObjects []ScopeObject `json:"objects"` +} + +type ScopeObject struct { + Key string `json:"key"` +} + +// NewResourceGroupForZone takes an existing zone and provides a resource group +// to be used within a Policy that allows access to that zone. +func NewResourceGroupForZone(zone Zone) ResourceGroup { + return NewResourceGroup(fmt.Sprintf("com.cloudflare.api.account.zone.%s", zone.ID)) +} + +// NewResourceGroupForAccount takes an existing zone and provides a resource group +// to be used within a Policy that allows access to that account. +func NewResourceGroupForAccount(account Account) ResourceGroup { + return NewResourceGroup(fmt.Sprintf("com.cloudflare.api.account.%s", account.ID)) +} + +// NewResourceGroup takes a Cloudflare-formatted key (e.g. 'com.cloudflare.api.%s') and +// returns a resource group to be used within a Policy to allow access to that resource. +func NewResourceGroup(key string) ResourceGroup { + scope := Scope{ + Key: key, + ScopeObjects: []ScopeObject{ + { + Key: "*", + }, + }, + } + resourceGroup := ResourceGroup{ + ID: "", + Name: key, + Meta: map[string]string{ + "editable": "false", + }, + Scope: scope, + } + return resourceGroup +} diff --git a/pkg/cloudflare-go/resource_group_test.go b/pkg/cloudflare-go/resource_group_test.go new file mode 100644 index 000000000..6f0ff3d36 --- /dev/null +++ b/pkg/cloudflare-go/resource_group_test.go @@ -0,0 +1,47 @@ +package cloudflare + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewResourceGroup(t *testing.T) { + setup() + defer teardown() + + key := "com.cloudflare.test.1" + rg := NewResourceGroup(key) + + assert.Equal(t, rg.Name, key) + assert.Equal(t, rg.Scope.Key, key) +} + +func TestNewResourceGroupForAccount(t *testing.T) { + setup() + defer teardown() + + id := "some-fake-account-id" + rg := NewResourceGroupForAccount(Account{ + ID: id, + }) + + key := fmt.Sprintf("com.cloudflare.api.account.%s", id) + assert.Equal(t, rg.Name, key) + assert.Equal(t, rg.Scope.Key, key) +} + +func TestNewResourceGroupForZone(t *testing.T) { + setup() + defer teardown() + + id := "some-fake-zone-id" + rg := NewResourceGroupForZone(Zone{ + ID: id, + }) + + key := fmt.Sprintf("com.cloudflare.api.account.zone.%s", id) + assert.Equal(t, rg.Name, key) + assert.Equal(t, rg.Scope.Key, key) +} diff --git a/pkg/cloudflare-go/resource_test.go b/pkg/cloudflare-go/resource_test.go new file mode 100644 index 000000000..ad2e7295a --- /dev/null +++ b/pkg/cloudflare-go/resource_test.go @@ -0,0 +1,66 @@ +package cloudflare + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResourceProperties(t *testing.T) { + testCases := map[string]struct { + container *ResourceContainer + expectedRoute string + expectedType string + expectedIdentifier string + }{ + account: { + container: AccountIdentifier("abcd1234"), + expectedRoute: accounts, + expectedType: account, + expectedIdentifier: "abcd1234", + }, + zone: { + container: ZoneIdentifier("abcd1234"), + expectedRoute: zones, + expectedType: zone, + expectedIdentifier: "abcd1234", + }, + user: { + container: UserIdentifier("abcd1234"), + expectedRoute: user, + expectedType: user, + expectedIdentifier: "abcd1234", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + setup() + defer teardown() + + assert.Equal(t, tc.container.Level.String(), tc.expectedRoute) + assert.Equal(t, tc.container.Type.String(), tc.expectedType) + assert.Equal(t, tc.container.Identifier, tc.expectedIdentifier) + }) + } +} +func TestResourcURLFragment(t *testing.T) { + tests := map[string]struct { + container *ResourceContainer + want string + }{ + "account resource": {container: AccountIdentifier("foo"), want: "accounts/foo"}, + "zone resource": {container: ZoneIdentifier("foo"), want: "zones/foo"}, + // this is pretty well deprecated in favour of `AccountIdentifier` but + // here for completeness. + "user level resource": {container: UserIdentifier("foo"), want: "user"}, + "missing level resource": {container: &ResourceContainer{Level: "", Identifier: "foo"}, want: "foo"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.container.URLFragment() + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/cloudflare-go/rulesets.go b/pkg/cloudflare-go/rulesets.go new file mode 100644 index 000000000..8b1ad430a --- /dev/null +++ b/pkg/cloudflare-go/rulesets.go @@ -0,0 +1,888 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingRulesetPhase = errors.New("missing required phase") +) + +const ( + RulesetKindCustom RulesetKind = "custom" + RulesetKindManaged RulesetKind = "managed" + RulesetKindRoot RulesetKind = "root" + RulesetKindZone RulesetKind = "zone" + + RulesetPhaseDDoSL4 RulesetPhase = "ddos_l4" + RulesetPhaseDDoSL7 RulesetPhase = "ddos_l7" + RulesetPhaseHTTPConfigSettings RulesetPhase = "http_config_settings" + RulesetPhaseHTTPCustomErrors RulesetPhase = "http_custom_errors" + RulesetPhaseHTTPLogCustomFields RulesetPhase = "http_log_custom_fields" + RulesetPhaseHTTPRatelimit RulesetPhase = "http_ratelimit" + RulesetPhaseHTTPRequestCacheSettings RulesetPhase = "http_request_cache_settings" + RulesetPhaseHTTPRequestDynamicRedirect RulesetPhase = "http_request_dynamic_redirect" //nolint:gosec + RulesetPhaseHTTPRequestFirewallCustom RulesetPhase = "http_request_firewall_custom" + RulesetPhaseHTTPRequestFirewallManaged RulesetPhase = "http_request_firewall_managed" + RulesetPhaseHTTPRequestLateTransform RulesetPhase = "http_request_late_transform" + RulesetPhaseHTTPRequestOrigin RulesetPhase = "http_request_origin" + RulesetPhaseHTTPRequestRedirect RulesetPhase = "http_request_redirect" + RulesetPhaseHTTPRequestSanitize RulesetPhase = "http_request_sanitize" + RulesetPhaseHTTPRequestSBFM RulesetPhase = "http_request_sbfm" + RulesetPhaseHTTPRequestTransform RulesetPhase = "http_request_transform" + RulesetPhaseHTTPResponseCompression RulesetPhase = "http_response_compression" + RulesetPhaseHTTPResponseFirewallManaged RulesetPhase = "http_response_firewall_managed" + RulesetPhaseHTTPResponseHeadersTransform RulesetPhase = "http_response_headers_transform" + RulesetPhaseMagicTransit RulesetPhase = "magic_transit" + + RulesetRuleActionBlock RulesetRuleAction = "block" + RulesetRuleActionChallenge RulesetRuleAction = "challenge" + RulesetRuleActionCompressResponse RulesetRuleAction = "compress_response" + RulesetRuleActionDDoSDynamic RulesetRuleAction = "ddos_dynamic" + RulesetRuleActionDDoSMitigation RulesetRuleAction = "ddos_mitigation" + RulesetRuleActionExecute RulesetRuleAction = "execute" + RulesetRuleActionForceConnectionClose RulesetRuleAction = "force_connection_close" + RulesetRuleActionJSChallenge RulesetRuleAction = "js_challenge" + RulesetRuleActionLog RulesetRuleAction = "log" + RulesetRuleActionLogCustomField RulesetRuleAction = "log_custom_field" + RulesetRuleActionManagedChallenge RulesetRuleAction = "managed_challenge" + RulesetRuleActionRedirect RulesetRuleAction = "redirect" + RulesetRuleActionRewrite RulesetRuleAction = "rewrite" + RulesetRuleActionRoute RulesetRuleAction = "route" + RulesetRuleActionScore RulesetRuleAction = "score" + RulesetRuleActionServeError RulesetRuleAction = "serve_error" + RulesetRuleActionSetCacheSettings RulesetRuleAction = "set_cache_settings" + RulesetRuleActionSetConfig RulesetRuleAction = "set_config" + RulesetRuleActionSkip RulesetRuleAction = "skip" + + RulesetActionParameterProductBIC RulesetActionParameterProduct = "bic" + RulesetActionParameterProductHOT RulesetActionParameterProduct = "hot" + RulesetActionParameterProductRateLimit RulesetActionParameterProduct = "ratelimit" + RulesetActionParameterProductSecurityLevel RulesetActionParameterProduct = "securityLevel" + RulesetActionParameterProductUABlock RulesetActionParameterProduct = "uablock" + RulesetActionParameterProductWAF RulesetActionParameterProduct = "waf" + RulesetActionParameterProductZoneLockdown RulesetActionParameterProduct = "zonelockdown" + + RulesetRuleActionParametersHTTPHeaderOperationRemove RulesetRuleActionParametersHTTPHeaderOperation = "remove" + RulesetRuleActionParametersHTTPHeaderOperationSet RulesetRuleActionParametersHTTPHeaderOperation = "set" + RulesetRuleActionParametersHTTPHeaderOperationAdd RulesetRuleActionParametersHTTPHeaderOperation = "add" +) + +// RulesetKindValues exposes all the available `RulesetKind` values as a slice +// of strings. +func RulesetKindValues() []string { + return []string{ + string(RulesetKindCustom), + string(RulesetKindManaged), + string(RulesetKindRoot), + string(RulesetKindZone), + } +} + +// RulesetPhaseValues exposes all the available `RulesetPhase` values as a slice +// of strings. +func RulesetPhaseValues() []string { + return []string{ + string(RulesetPhaseDDoSL4), + string(RulesetPhaseDDoSL7), + string(RulesetPhaseHTTPConfigSettings), + string(RulesetPhaseHTTPCustomErrors), + string(RulesetPhaseHTTPLogCustomFields), + string(RulesetPhaseHTTPRatelimit), + string(RulesetPhaseHTTPRequestCacheSettings), + string(RulesetPhaseHTTPRequestDynamicRedirect), + string(RulesetPhaseHTTPRequestFirewallCustom), + string(RulesetPhaseHTTPRequestFirewallManaged), + string(RulesetPhaseHTTPRequestLateTransform), + string(RulesetPhaseHTTPRequestOrigin), + string(RulesetPhaseHTTPRequestRedirect), + string(RulesetPhaseHTTPRequestSanitize), + string(RulesetPhaseHTTPRequestSBFM), + string(RulesetPhaseHTTPRequestTransform), + string(RulesetPhaseHTTPResponseCompression), + string(RulesetPhaseHTTPResponseFirewallManaged), + string(RulesetPhaseHTTPResponseHeadersTransform), + string(RulesetPhaseMagicTransit), + } +} + +// RulesetRuleActionValues exposes all the available `RulesetRuleAction` values +// as a slice of strings. +func RulesetRuleActionValues() []string { + return []string{ + string(RulesetRuleActionBlock), + string(RulesetRuleActionChallenge), + string(RulesetRuleActionCompressResponse), + string(RulesetRuleActionDDoSDynamic), + string(RulesetRuleActionDDoSMitigation), + string(RulesetRuleActionExecute), + string(RulesetRuleActionForceConnectionClose), + string(RulesetRuleActionJSChallenge), + string(RulesetRuleActionLog), + string(RulesetRuleActionLogCustomField), + string(RulesetRuleActionManagedChallenge), + string(RulesetRuleActionRedirect), + string(RulesetRuleActionRewrite), + string(RulesetRuleActionRoute), + string(RulesetRuleActionScore), + string(RulesetRuleActionServeError), + string(RulesetRuleActionSetCacheSettings), + string(RulesetRuleActionSetConfig), + string(RulesetRuleActionSkip), + } +} + +// RulesetActionParameterProductValues exposes all the available +// `RulesetActionParameterProduct` values as a slice of strings. +func RulesetActionParameterProductValues() []string { + return []string{ + string(RulesetActionParameterProductBIC), + string(RulesetActionParameterProductHOT), + string(RulesetActionParameterProductRateLimit), + string(RulesetActionParameterProductSecurityLevel), + string(RulesetActionParameterProductUABlock), + string(RulesetActionParameterProductWAF), + string(RulesetActionParameterProductZoneLockdown), + } +} + +func RulesetRuleActionParametersHTTPHeaderOperationValues() []string { + return []string{ + string(RulesetRuleActionParametersHTTPHeaderOperationRemove), + string(RulesetRuleActionParametersHTTPHeaderOperationSet), + string(RulesetRuleActionParametersHTTPHeaderOperationAdd), + } +} + +// RulesetRuleAction defines a custom type that is used to express allowed +// values for the rule action. +type RulesetRuleAction string + +// RulesetKind is the custom type for allowed variances of rulesets. +type RulesetKind string + +// RulesetPhase is the custom type for defining at what point the ruleset will +// be applied in the request pipeline. +type RulesetPhase string + +// RulesetActionParameterProduct is the custom type for defining what products +// can be used within the action parameters of a ruleset. +type RulesetActionParameterProduct string + +// RulesetRuleActionParametersHTTPHeaderOperation defines available options for +// HTTP header operations in actions. +type RulesetRuleActionParametersHTTPHeaderOperation string + +// Ruleset contains the structure of a Ruleset. Using `string` for Kind and +// Phase is a developer nicety to support downstream clients like Terraform who +// don't really have a strong and expansive type system. As always, the +// recommendation is to use the types provided where possible to avoid +// surprises. +type Ruleset struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Kind string `json:"kind,omitempty"` + Version *string `json:"version,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Phase string `json:"phase,omitempty"` + Rules []RulesetRule `json:"rules"` + ShareableEntitlementName string `json:"shareable_entitlement_name,omitempty"` +} + +// RulesetActionParametersLogCustomField wraps an object that is part of +// request_fields, response_fields or cookie_fields. +type RulesetActionParametersLogCustomField struct { + Name string `json:"name,omitempty"` +} + +// RulesetRuleActionParameters specifies the action parameters for a Ruleset +// rule. +type RulesetRuleActionParameters struct { + ID string `json:"id,omitempty"` + Ruleset string `json:"ruleset,omitempty"` + Rulesets []string `json:"rulesets,omitempty"` + Rules map[string][]string `json:"rules,omitempty"` + Increment int `json:"increment,omitempty"` + URI *RulesetRuleActionParametersURI `json:"uri,omitempty"` + Headers map[string]RulesetRuleActionParametersHTTPHeader `json:"headers,omitempty"` + Products []string `json:"products,omitempty"` + Phases []string `json:"phases,omitempty"` + Overrides *RulesetRuleActionParametersOverrides `json:"overrides,omitempty"` + MatchedData *RulesetRuleActionParametersMatchedData `json:"matched_data,omitempty"` + Version *string `json:"version,omitempty"` + Response *RulesetRuleActionParametersBlockResponse `json:"response,omitempty"` + HostHeader string `json:"host_header,omitempty"` + Origin *RulesetRuleActionParametersOrigin `json:"origin,omitempty"` + SNI *RulesetRuleActionParametersSni `json:"sni,omitempty"` + RequestFields []RulesetActionParametersLogCustomField `json:"request_fields,omitempty"` + ResponseFields []RulesetActionParametersLogCustomField `json:"response_fields,omitempty"` + CookieFields []RulesetActionParametersLogCustomField `json:"cookie_fields,omitempty"` + Cache *bool `json:"cache,omitempty"` + AdditionalCacheablePorts []int `json:"additional_cacheable_ports,omitempty"` + EdgeTTL *RulesetRuleActionParametersEdgeTTL `json:"edge_ttl,omitempty"` + BrowserTTL *RulesetRuleActionParametersBrowserTTL `json:"browser_ttl,omitempty"` + ServeStale *RulesetRuleActionParametersServeStale `json:"serve_stale,omitempty"` + Content string `json:"content,omitempty"` + ContentType string `json:"content_type,omitempty"` + StatusCode uint16 `json:"status_code,omitempty"` + RespectStrongETags *bool `json:"respect_strong_etags,omitempty"` + CacheKey *RulesetRuleActionParametersCacheKey `json:"cache_key,omitempty"` + OriginCacheControl *bool `json:"origin_cache_control,omitempty"` + OriginErrorPagePassthru *bool `json:"origin_error_page_passthru,omitempty"` + CacheReserve *RulesetRuleActionParametersCacheReserve `json:"cache_reserve,omitempty"` + FromList *RulesetRuleActionParametersFromList `json:"from_list,omitempty"` + FromValue *RulesetRuleActionParametersFromValue `json:"from_value,omitempty"` + AutomaticHTTPSRewrites *bool `json:"automatic_https_rewrites,omitempty"` + AutoMinify *RulesetRuleActionParametersAutoMinify `json:"autominify,omitempty"` + BrowserIntegrityCheck *bool `json:"bic,omitempty"` + DisableApps *bool `json:"disable_apps,omitempty"` + DisableZaraz *bool `json:"disable_zaraz,omitempty"` + DisableRailgun *bool `json:"disable_railgun,omitempty"` + DisableRUM *bool `json:"disable_rum,omitempty"` + EmailObfuscation *bool `json:"email_obfuscation,omitempty"` + Fonts *bool `json:"fonts,omitempty"` + Mirage *bool `json:"mirage,omitempty"` + OpportunisticEncryption *bool `json:"opportunistic_encryption,omitempty"` + Polish *Polish `json:"polish,omitempty"` + ReadTimeout *uint `json:"read_timeout,omitempty"` + RocketLoader *bool `json:"rocket_loader,omitempty"` + SecurityLevel *SecurityLevel `json:"security_level,omitempty"` + ServerSideExcludes *bool `json:"server_side_excludes,omitempty"` + SSL *SSL `json:"ssl,omitempty"` + SXG *bool `json:"sxg,omitempty"` + HotLinkProtection *bool `json:"hotlink_protection,omitempty"` + Algorithms []RulesetRuleActionParametersCompressionAlgorithm `json:"algorithms,omitempty"` +} + +// RulesetRuleActionParametersFromList holds the FromList struct for +// actions which pull data from a list. +type RulesetRuleActionParametersFromList struct { + Name string `json:"name"` + Key string `json:"key"` +} + +type RulesetRuleActionParametersAutoMinify struct { + HTML bool `json:"html"` + CSS bool `json:"css"` + JS bool `json:"js"` +} + +type RulesetRuleActionParametersFromValue struct { + StatusCode uint16 `json:"status_code,omitempty"` + TargetURL RulesetRuleActionParametersTargetURL `json:"target_url"` + PreserveQueryString *bool `json:"preserve_query_string,omitempty"` +} + +type RulesetRuleActionParametersTargetURL struct { + Value string `json:"value,omitempty"` + Expression string `json:"expression,omitempty"` +} + +type RulesetRuleActionParametersEdgeTTL struct { + Mode string `json:"mode,omitempty"` + Default *uint `json:"default,omitempty"` + StatusCodeTTL []RulesetRuleActionParametersStatusCodeTTL `json:"status_code_ttl,omitempty"` +} + +type RulesetRuleActionParametersStatusCodeTTL struct { + StatusCodeRange *RulesetRuleActionParametersStatusCodeRange `json:"status_code_range,omitempty"` + StatusCodeValue *uint `json:"status_code,omitempty"` + Value *int `json:"value,omitempty"` +} + +type RulesetRuleActionParametersStatusCodeRange struct { + From *uint `json:"from,omitempty"` + To *uint `json:"to,omitempty"` +} + +type RulesetRuleActionParametersBrowserTTL struct { + Mode string `json:"mode"` + Default *uint `json:"default,omitempty"` +} + +type RulesetRuleActionParametersServeStale struct { + DisableStaleWhileUpdating *bool `json:"disable_stale_while_updating,omitempty"` +} + +type RulesetRuleActionParametersCacheKey struct { + CacheByDeviceType *bool `json:"cache_by_device_type,omitempty"` + IgnoreQueryStringsOrder *bool `json:"ignore_query_strings_order,omitempty"` + CacheDeceptionArmor *bool `json:"cache_deception_armor,omitempty"` + CustomKey *RulesetRuleActionParametersCustomKey `json:"custom_key,omitempty"` +} + +type RulesetRuleActionParametersCacheReserve struct { + Eligible *bool `json:"eligible,omitempty"` + MinimumFileSize *uint `json:"minimum_file_size,omitempty"` +} + +type RulesetRuleActionParametersCustomKey struct { + Query *RulesetRuleActionParametersCustomKeyQuery `json:"query_string,omitempty"` + Header *RulesetRuleActionParametersCustomKeyHeader `json:"header,omitempty"` + Cookie *RulesetRuleActionParametersCustomKeyCookie `json:"cookie,omitempty"` + User *RulesetRuleActionParametersCustomKeyUser `json:"user,omitempty"` + Host *RulesetRuleActionParametersCustomKeyHost `json:"host,omitempty"` +} + +type RulesetRuleActionParametersCustomKeyHeader struct { + RulesetRuleActionParametersCustomKeyFields + ExcludeOrigin *bool `json:"exclude_origin,omitempty"` +} + +type RulesetRuleActionParametersCustomKeyCookie RulesetRuleActionParametersCustomKeyFields + +type RulesetRuleActionParametersCustomKeyFields struct { + Include []string `json:"include,omitempty"` + CheckPresence []string `json:"check_presence,omitempty"` +} + +type RulesetRuleActionParametersCustomKeyQuery struct { + Include *RulesetRuleActionParametersCustomKeyList `json:"include,omitempty"` + Exclude *RulesetRuleActionParametersCustomKeyList `json:"exclude,omitempty"` + Ignore *bool `json:"ignore,omitempty"` +} + +type RulesetRuleActionParametersCustomKeyList struct { + List []string + All bool +} + +func (s *RulesetRuleActionParametersCustomKeyList) UnmarshalJSON(data []byte) error { + var all string + if err := json.Unmarshal(data, &all); err == nil { + s.All = all == "*" + return nil + } + var list []string + if err := json.Unmarshal(data, &list); err == nil { + s.List = list + } + + return nil +} + +func (s RulesetRuleActionParametersCustomKeyList) MarshalJSON() ([]byte, error) { + if s.All { + return json.Marshal("*") + } + return json.Marshal(s.List) +} + +type RulesetRuleActionParametersCustomKeyUser struct { + DeviceType *bool `json:"device_type,omitempty"` + Geo *bool `json:"geo,omitempty"` + Lang *bool `json:"lang,omitempty"` +} + +type RulesetRuleActionParametersCustomKeyHost struct { + Resolved *bool `json:"resolved,omitempty"` +} + +// RulesetRuleActionParametersBlockResponse holds the BlockResponse struct +// for an action parameter. +type RulesetRuleActionParametersBlockResponse struct { + StatusCode uint16 `json:"status_code"` + ContentType string `json:"content_type"` + Content string `json:"content"` +} + +// RulesetRuleActionParametersURI holds the URI struct for an action parameter. +type RulesetRuleActionParametersURI struct { + Path *RulesetRuleActionParametersURIPath `json:"path,omitempty"` + Query *RulesetRuleActionParametersURIQuery `json:"query,omitempty"` + Origin *bool `json:"origin,omitempty"` +} + +// RulesetRuleActionParametersURIPath holds the path specific portion of a URI +// action parameter. +type RulesetRuleActionParametersURIPath struct { + Value string `json:"value,omitempty"` + Expression string `json:"expression,omitempty"` +} + +// RulesetRuleActionParametersURIQuery holds the query specific portion of a URI +// action parameter. +type RulesetRuleActionParametersURIQuery struct { + Value *string `json:"value,omitempty"` + Expression string `json:"expression,omitempty"` +} + +// RulesetRuleActionParametersHTTPHeader is the definition for define action +// parameters that involve HTTP headers. +type RulesetRuleActionParametersHTTPHeader struct { + Operation string `json:"operation,omitempty"` + Value string `json:"value,omitempty"` + Expression string `json:"expression,omitempty"` +} + +type RulesetRuleActionParametersOverrides struct { + Enabled *bool `json:"enabled,omitempty"` + Action string `json:"action,omitempty"` + SensitivityLevel string `json:"sensitivity_level,omitempty"` + Categories []RulesetRuleActionParametersCategories `json:"categories,omitempty"` + Rules []RulesetRuleActionParametersRules `json:"rules,omitempty"` +} + +type RulesetRuleActionParametersCategories struct { + Category string `json:"category"` + Action string `json:"action,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type RulesetRuleActionParametersRules struct { + ID string `json:"id"` + Action string `json:"action,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ScoreThreshold int `json:"score_threshold,omitempty"` + SensitivityLevel string `json:"sensitivity_level,omitempty"` +} + +// RulesetRuleActionParametersMatchedData holds the structure for WAF based +// payload logging. +type RulesetRuleActionParametersMatchedData struct { + PublicKey string `json:"public_key,omitempty"` +} + +// RulesetRuleActionParametersOrigin is the definition for route action +// parameters that involve origin overrides. +type RulesetRuleActionParametersOrigin struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` +} + +// RulesetRuleActionParametersSni is the definition for the route action +// parameters that involve SNI overrides. +type RulesetRuleActionParametersSni struct { + Value string `json:"value"` +} + +// RulesetRuleActionParametersCompressionAlgorithm defines a compression +// algorithm for the compress_response action. +type RulesetRuleActionParametersCompressionAlgorithm struct { + Name string `json:"name"` +} + +type Polish int + +const ( + _ Polish = iota + PolishOff + PolishLossless + PolishLossy +) + +func (p Polish) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +func (p Polish) String() string { + return [...]string{"off", "lossless", "lossy"}[p-1] +} + +func (p *Polish) UnmarshalJSON(data []byte) error { + var ( + s string + err error + ) + err = json.Unmarshal(data, &s) + if err != nil { + return err + } + v, err := PolishFromString(s) + if err != nil { + return err + } + *p = *v + return nil +} + +func PolishFromString(s string) (*Polish, error) { + s = strings.ToLower(s) + var v Polish + switch s { + case "off": + v = PolishOff + case "lossless": + v = PolishLossless + case "lossy": + v = PolishLossy + default: + return nil, fmt.Errorf("unknown variant for polish: %s", s) + } + return &v, nil +} + +func (p Polish) IntoRef() *Polish { + return &p +} + +type SecurityLevel int + +const ( + _ SecurityLevel = iota + SecurityLevelOff + SecurityLevelEssentiallyOff + SecurityLevelLow + SecurityLevelMedium + SecurityLevelHigh + SecurityLevelHelp +) + +func (p SecurityLevel) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +func (p SecurityLevel) String() string { + return [...]string{"off", "essentially_off", "low", "medium", "high", "under_attack"}[p-1] +} + +func (p *SecurityLevel) UnmarshalJSON(data []byte) error { + var ( + s string + err error + ) + err = json.Unmarshal(data, &s) + if err != nil { + return err + } + v, err := SecurityLevelFromString(s) + if err != nil { + return err + } + *p = *v + return nil +} + +func SecurityLevelFromString(s string) (*SecurityLevel, error) { + s = strings.ToLower(s) + var v SecurityLevel + switch s { + case "off": + v = SecurityLevelOff + case "essentially_off": + v = SecurityLevelEssentiallyOff + case "low": + v = SecurityLevelLow + case "medium": + v = SecurityLevelMedium + case "high": + v = SecurityLevelHigh + case "under_attack": + v = SecurityLevelHelp + default: + return nil, fmt.Errorf("unknown variant for security_level: %s", s) + } + return &v, nil +} + +func (p SecurityLevel) IntoRef() *SecurityLevel { + return &p +} + +type SSL int + +const ( + _ SSL = iota + SSLOff + SSLFlexible + SSLFull + SSLStrict + SSLOriginPull +) + +func (p SSL) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +func (p SSL) String() string { + return [...]string{"off", "flexible", "full", "strict", "origin_pull"}[p-1] +} + +func (p *SSL) UnmarshalJSON(data []byte) error { + var ( + s string + err error + ) + err = json.Unmarshal(data, &s) + if err != nil { + return err + } + v, err := SSLFromString(s) + if err != nil { + return err + } + *p = *v + return nil +} + +func SSLFromString(s string) (*SSL, error) { + s = strings.ToLower(s) + var v SSL + switch s { + case "off": + v = SSLOff + case "flexible": + v = SSLFlexible + case "full": + v = SSLFull + case "strict": + v = SSLStrict + case "origin_pull": + v = SSLOriginPull + default: + return nil, fmt.Errorf("unknown variant for ssl: %s", s) + } + return &v, nil +} + +func (p SSL) IntoRef() *SSL { + return &p +} + +// RulesetRule contains information about a single Ruleset Rule. +type RulesetRule struct { + ID string `json:"id,omitempty"` + Version *string `json:"version,omitempty"` + Action string `json:"action"` + ActionParameters *RulesetRuleActionParameters `json:"action_parameters,omitempty"` + Expression string `json:"expression"` + Description string `json:"description,omitempty"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Ref string `json:"ref,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + ScoreThreshold int `json:"score_threshold,omitempty"` + RateLimit *RulesetRuleRateLimit `json:"ratelimit,omitempty"` + ExposedCredentialCheck *RulesetRuleExposedCredentialCheck `json:"exposed_credential_check,omitempty"` + Logging *RulesetRuleLogging `json:"logging,omitempty"` +} + +// RulesetRuleRateLimit contains the structure of a HTTP rate limit Ruleset Rule. +type RulesetRuleRateLimit struct { + Characteristics []string `json:"characteristics,omitempty"` + RequestsPerPeriod int `json:"requests_per_period,omitempty"` + ScorePerPeriod int `json:"score_per_period,omitempty"` + ScoreResponseHeaderName string `json:"score_response_header_name,omitempty"` + Period int `json:"period,omitempty"` + MitigationTimeout int `json:"mitigation_timeout,omitempty"` + CountingExpression string `json:"counting_expression,omitempty"` + RequestsToOrigin bool `json:"requests_to_origin,omitempty"` +} + +// RulesetRuleExposedCredentialCheck contains the structure of an exposed +// credential check Ruleset Rule. +type RulesetRuleExposedCredentialCheck struct { + UsernameExpression string `json:"username_expression,omitempty"` + PasswordExpression string `json:"password_expression,omitempty"` +} + +// RulesetRuleLogging contains the logging configuration for the rule. +type RulesetRuleLogging struct { + Enabled *bool `json:"enabled,omitempty"` +} + +// UpdateRulesetRequest is the representation of a Ruleset update. +type UpdateRulesetRequest struct { + Description string `json:"description"` + Rules []RulesetRule `json:"rules"` +} + +// ListRulesetResponse contains all Rulesets. +type ListRulesetResponse struct { + Response + Result []Ruleset `json:"result"` +} + +// GetRulesetResponse contains a single Ruleset. +type GetRulesetResponse struct { + Response + Result Ruleset `json:"result"` +} + +// CreateRulesetResponse contains response data when creating a new Ruleset. +type CreateRulesetResponse struct { + Response + Result Ruleset `json:"result"` +} + +// UpdateRulesetResponse contains response data when updating an existing +// Ruleset. +type UpdateRulesetResponse struct { + Response + Result Ruleset `json:"result"` +} + +type ListRulesetsParams struct{} + +type CreateRulesetParams struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Kind string `json:"kind,omitempty"` + Phase string `json:"phase,omitempty"` + Rules []RulesetRule `json:"rules"` +} + +type UpdateRulesetParams struct { + ID string `json:"-"` + Description string `json:"description"` + Rules []RulesetRule `json:"rules"` +} + +type UpdateEntrypointRulesetParams struct { + Phase string `json:"-"` + Description string `json:"description,omitempty"` + Rules []RulesetRule `json:"rules"` +} + +// ListRulesets lists all Rulesets in a given zone or account depending on the +// ResourceContainer type provided. +// +// API reference: https://developers.cloudflare.com/api/operations/listAccountRulesets +// API reference: https://developers.cloudflare.com/api/operations/listZoneRulesets +func (api *API) ListRulesets(ctx context.Context, rc *ResourceContainer, params ListRulesetsParams) ([]Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Ruleset{}, err + } + + result := ListRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []Ruleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetRuleset fetches a single ruleset. +// +// API reference: https://developers.cloudflare.com/api/operations/getAccountRuleset +// API reference: https://developers.cloudflare.com/api/operations/getZoneRuleset +func (api *API) GetRuleset(ctx context.Context, rc *ResourceContainer, rulesetID string) (Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets/%s", rc.Level, rc.Identifier, rulesetID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Ruleset{}, err + } + + result := GetRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// CreateRuleset creates a new ruleset. +// +// API reference: https://developers.cloudflare.com/api/operations/createAccountRuleset +// API reference: https://developers.cloudflare.com/api/operations/createZoneRuleset +func (api *API) CreateRuleset(ctx context.Context, rc *ResourceContainer, params CreateRulesetParams) (Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets", rc.Level, rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return Ruleset{}, err + } + + result := CreateRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteRuleset removes a ruleset based on the ruleset ID. +// +// API reference: https://developers.cloudflare.com/api/operations/deleteAccountRuleset +// API reference: https://developers.cloudflare.com/api/operations/deleteZoneRuleset +func (api *API) DeleteRuleset(ctx context.Context, rc *ResourceContainer, rulesetID string) error { + uri := fmt.Sprintf("/%s/%s/rulesets/%s", rc.Level, rc.Identifier, rulesetID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + // The API is not implementing the standard response blob but returns an + // empty response (204) in case of a success. So we are checking for the + // response body size here. + if len(res) > 0 { + return fmt.Errorf(errMakeRequestError+": %w", errors.New(string(res))) + } + + return nil +} + +// UpdateRuleset updates a ruleset based on the ruleset ID. +// +// API reference: https://developers.cloudflare.com/api/operations/updateAccountRuleset +// API reference: https://developers.cloudflare.com/api/operations/updateZoneRuleset +func (api *API) UpdateRuleset(ctx context.Context, rc *ResourceContainer, params UpdateRulesetParams) (Ruleset, error) { + if params.ID == "" { + return Ruleset{}, ErrMissingResourceIdentifier + } + + uri := fmt.Sprintf("/%s/%s/rulesets/%s", rc.Level, rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return Ruleset{}, err + } + + result := UpdateRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// GetEntrypointRuleset returns an entry point ruleset base on the phase. +// +// API reference: https://developers.cloudflare.com/api/operations/getAccountEntrypointRuleset +// API reference: https://developers.cloudflare.com/api/operations/getZoneEntrypointRuleset +func (api *API) GetEntrypointRuleset(ctx context.Context, rc *ResourceContainer, phase string) (Ruleset, error) { + uri := fmt.Sprintf("/%s/%s/rulesets/phases/%s/entrypoint", rc.Level, rc.Identifier, phase) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Ruleset{}, err + } + + result := GetRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateEntrypointRuleset updates an entry point ruleset phase based on the +// phase. +// +// API reference: https://developers.cloudflare.com/api/operations/updateAccountEntrypointRuleset +// API reference: https://developers.cloudflare.com/api/operations/updateZoneEntrypointRuleset +func (api *API) UpdateEntrypointRuleset(ctx context.Context, rc *ResourceContainer, params UpdateEntrypointRulesetParams) (Ruleset, error) { + if params.Phase == "" { + return Ruleset{}, ErrMissingRulesetPhase + } + + uri := fmt.Sprintf("/%s/%s/rulesets/phases/%s/entrypoint", rc.Level, rc.Identifier, params.Phase) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return Ruleset{}, err + } + + result := GetRulesetResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return Ruleset{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} diff --git a/pkg/cloudflare-go/rulesets_test.go b/pkg/cloudflare-go/rulesets_test.go new file mode 100644 index 000000000..dbaf2b4f1 --- /dev/null +++ b/pkg/cloudflare-go/rulesets_test.go @@ -0,0 +1,870 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListRulesets(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "my example ruleset", + "description": "Test magic transit ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + want := []Ruleset{ + { + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseMagicTransit), + }, + } + + zoneActual, err := client.ListRulesets(context.Background(), ZoneIdentifier(testZoneID), ListRulesetsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.ListRulesets(context.Background(), AccountIdentifier(testAccountID), ListRulesetsParams{}) + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset_MagicTransit(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "my example ruleset", + "description": "Test magic transit ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + want := Ruleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseMagicTransit), + } + + zoneActual, err := client.GetRuleset(context.Background(), ZoneIdentifier(testZoneID), "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetRuleset(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset_WAF(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "70339d97bdb34195bbf054b1ebe81f76", + "name": "Cloudflare Normalization Ruleset", + "description": "Created by the Cloudflare security team, this ruleset provides normalization on the URL path", + "kind": "managed", + "version": "1", + "rules": [ + { + "id": "78723a9e0c7c4c6dbec5684cb766231d", + "version": "1", + "action": "rewrite", + "action_parameters": { + "uri": { + "path": { + "expression": "normalize_url_path(raw.http.request.uri.path)" + }, + "origin": false + } + }, + "description": "Normalization on the URL path, without propagating it to the origin", + "last_updated": "2020-12-18T09:28:09.655749Z", + "ref": "272936dc447b41fe976255ff6b768ec0", + "enabled": true + } + ], + "last_updated": "2020-12-18T09:28:09.655749Z", + "phase": "http_request_sanitize" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-18T09:28:09.655749Z") + + rules := []RulesetRule{{ + ID: "78723a9e0c7c4c6dbec5684cb766231d", + Version: StringPtr("1"), + Action: string(RulesetRuleActionRewrite), + ActionParameters: &RulesetRuleActionParameters{ + URI: &RulesetRuleActionParametersURI{ + Path: &RulesetRuleActionParametersURIPath{ + Expression: "normalize_url_path(raw.http.request.uri.path)", + }, + Origin: BoolPtr(false), + }, + }, + Description: "Normalization on the URL path, without propagating it to the origin", + LastUpdated: &lastUpdated, + Ref: "272936dc447b41fe976255ff6b768ec0", + Enabled: BoolPtr(true), + }} + + want := Ruleset{ + ID: "70339d97bdb34195bbf054b1ebe81f76", + Name: "Cloudflare Normalization Ruleset", + Description: "Created by the Cloudflare security team, this ruleset provides normalization on the URL path", + Kind: string(RulesetKindManaged), + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseHTTPRequestSanitize), + Rules: rules, + } + + zoneActual, err := client.GetRuleset(context.Background(), ZoneIdentifier(testZoneID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetRuleset(context.Background(), AccountIdentifier(testAccountID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset_SetCacheSettings(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "70339d97bdb34195bbf054b1ebe81f76", + "name": "Cloudflare Cache Rules Ruleset", + "description": "This ruleset provides cache settings modifications", + "kind": "zone", + "version": "1", + "rules": [ + { + "id": "78723a9e0c7c4c6dbec5684cb766231d", + "version": "1", + "action": "set_cache_settings", + "action_parameters": { + "cache": true, + "edge_ttl":{"mode":"respect_origin","default":60,"status_code_ttl":[{"status_code":200,"value":30},{"status_code_range":{"from":201,"to":300},"value":20}]}, + "browser_ttl":{"mode":"override_origin","default":10}, + "serve_stale":{"disable_stale_while_updating":true}, + "respect_strong_etags":true, + "cache_key":{ + "cache_deception_armor":true, + "ignore_query_strings_order":true, + "custom_key": { + "query_string":{"include":"*"}, + "header":{"include":["habc","hdef"],"check_presence":["hfizz","hbuzz"],"exclude_origin":true}, + "cookie":{"include":["cabc","cdef"],"check_presence":["cfizz","cbuzz"]}, + "user":{ + "device_type":true, + "geo":true, + "lang":true + }, + "host":{ + "resolved":true + } + } + }, + "additional_cacheable_ports": [1,2,3,4], + "origin_cache_control": true, + "read_timeout": 1000, + "origin_error_page_passthru":true, + "cache_reserve": { + "eligible": true, + "minimum_file_size": 1000 + } + }, + "description": "Set all available cache settings in one rule", + "last_updated": "2020-12-18T09:28:09.655749Z", + "ref": "272936dc447b41fe976255ff6b768ec0", + "enabled": true + } + ], + "last_updated": "2020-12-18T09:28:09.655749Z", + "phase": "http_request_cache_settings" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-18T09:28:09.655749Z") + + rules := []RulesetRule{{ + ID: "78723a9e0c7c4c6dbec5684cb766231d", + Version: StringPtr("1"), + Action: string(RulesetRuleActionSetCacheSettings), + ActionParameters: &RulesetRuleActionParameters{ + Cache: BoolPtr(true), + EdgeTTL: &RulesetRuleActionParametersEdgeTTL{ + Mode: "respect_origin", + Default: UintPtr(60), + StatusCodeTTL: []RulesetRuleActionParametersStatusCodeTTL{ + { + StatusCodeValue: UintPtr(200), + Value: IntPtr(30), + }, + { + StatusCodeRange: &RulesetRuleActionParametersStatusCodeRange{ + From: UintPtr(201), + To: UintPtr(300), + }, + Value: IntPtr(20), + }, + }, + }, + BrowserTTL: &RulesetRuleActionParametersBrowserTTL{ + Mode: "override_origin", + Default: UintPtr(10), + }, + ServeStale: &RulesetRuleActionParametersServeStale{ + DisableStaleWhileUpdating: BoolPtr(true), + }, + RespectStrongETags: BoolPtr(true), + CacheKey: &RulesetRuleActionParametersCacheKey{ + IgnoreQueryStringsOrder: BoolPtr(true), + CacheDeceptionArmor: BoolPtr(true), + CustomKey: &RulesetRuleActionParametersCustomKey{ + Query: &RulesetRuleActionParametersCustomKeyQuery{ + Include: &RulesetRuleActionParametersCustomKeyList{ + All: true, + }, + }, + Header: &RulesetRuleActionParametersCustomKeyHeader{ + RulesetRuleActionParametersCustomKeyFields: RulesetRuleActionParametersCustomKeyFields{ + Include: []string{"habc", "hdef"}, + CheckPresence: []string{"hfizz", "hbuzz"}, + }, + ExcludeOrigin: BoolPtr(true), + }, + Cookie: &RulesetRuleActionParametersCustomKeyCookie{ + Include: []string{"cabc", "cdef"}, + CheckPresence: []string{"cfizz", "cbuzz"}, + }, + User: &RulesetRuleActionParametersCustomKeyUser{ + DeviceType: BoolPtr(true), + Geo: BoolPtr(true), + Lang: BoolPtr(true), + }, + Host: &RulesetRuleActionParametersCustomKeyHost{ + Resolved: BoolPtr(true), + }, + }, + }, + AdditionalCacheablePorts: []int{1, 2, 3, 4}, + OriginCacheControl: BoolPtr(true), + ReadTimeout: UintPtr(1000), + OriginErrorPagePassthru: BoolPtr(true), + CacheReserve: &RulesetRuleActionParametersCacheReserve{ + Eligible: BoolPtr(true), + MinimumFileSize: UintPtr(1000), + }, + }, + Description: "Set all available cache settings in one rule", + LastUpdated: &lastUpdated, + Ref: "272936dc447b41fe976255ff6b768ec0", + Enabled: BoolPtr(true), + }} + + want := Ruleset{ + ID: "70339d97bdb34195bbf054b1ebe81f76", + Name: "Cloudflare Cache Rules Ruleset", + Description: "This ruleset provides cache settings modifications", + Kind: string(RulesetKindZone), + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseHTTPRequestCacheSettings), + Rules: rules, + } + + zoneActual, err := client.GetRuleset(context.Background(), ZoneIdentifier(testZoneID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetRuleset(context.Background(), AccountIdentifier(testAccountID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset_SetConfig(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "70339d97bdb34195bbf054b1ebe81f76", + "name": "Cloudflare Config Rules Ruleset", + "description": "This ruleset provides config rules modifications", + "kind": "zone", + "version": "1", + "rules": [ + { + "id": "78723a9e0c7c4c6dbec5684cb766231d", + "version": "1", + "action": "set_config", + "action_parameters": { + "automatic_https_rewrites":true, + "autominify":{"html":true, "css":true, "js":true}, + "bic":true, + "disable_apps":true, + "polish":"off", + "disable_zaraz":true, + "disable_railgun":true, + "email_obfuscation":true, + "mirage":true, + "opportunistic_encryption":true, + "rocket_loader":true, + "security_level":"off", + "server_side_excludes":true, + "ssl":"off", + "sxg":true, + "hotlink_protection":true, + "fonts":true, + "disable_rum":true + }, + "description": "Set all available config rules in one rule", + "last_updated": "2020-12-18T09:28:09.655749Z", + "ref": "272936dc447b41fe976255ff6b768ec0", + "enabled": true + } + ], + "last_updated": "2020-12-18T09:28:09.655749Z", + "phase": "http_config_settings" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-18T09:28:09.655749Z") + + rules := []RulesetRule{{ + ID: "78723a9e0c7c4c6dbec5684cb766231d", + Version: StringPtr("1"), + Action: string(RulesetRuleActionSetConfig), + ActionParameters: &RulesetRuleActionParameters{ + AutomaticHTTPSRewrites: BoolPtr(true), + AutoMinify: &RulesetRuleActionParametersAutoMinify{ + HTML: true, + CSS: true, + JS: true, + }, + BrowserIntegrityCheck: BoolPtr(true), + DisableApps: BoolPtr(true), + DisableZaraz: BoolPtr(true), + DisableRailgun: BoolPtr(true), + DisableRUM: BoolPtr(true), + EmailObfuscation: BoolPtr(true), + Fonts: BoolPtr(true), + Mirage: BoolPtr(true), + OpportunisticEncryption: BoolPtr(true), + Polish: PolishOff.IntoRef(), + RocketLoader: BoolPtr(true), + SecurityLevel: SecurityLevelOff.IntoRef(), + ServerSideExcludes: BoolPtr(true), + SSL: SSLOff.IntoRef(), + SXG: BoolPtr(true), + HotLinkProtection: BoolPtr(true), + }, + Description: "Set all available config rules in one rule", + LastUpdated: &lastUpdated, + Ref: "272936dc447b41fe976255ff6b768ec0", + Enabled: BoolPtr(true), + }} + + want := Ruleset{ + ID: "70339d97bdb34195bbf054b1ebe81f76", + Name: "Cloudflare Config Rules Ruleset", + Description: "This ruleset provides config rules modifications", + Kind: string(RulesetKindZone), + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseHTTPConfigSettings), + Rules: rules, + } + + zoneActual, err := client.GetRuleset(context.Background(), ZoneIdentifier(testZoneID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetRuleset(context.Background(), AccountIdentifier(testAccountID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset_RedirectFromValue(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "70339d97bdb34195bbf054b1ebe81f76", + "name": "Cloudflare Redirect Rules Ruleset", + "description": "This ruleset provides redirect from value", + "kind": "zone", + "version": "1", + "rules": [ + { + "id": "78723a9e0c7c4c6dbec5684cb766231d", + "version": "1", + "action": "redirect", + "action_parameters": { + "from_value": { + "status_code": 301, + "target_url": { + "value": "some_host.com" + }, + "preserve_query_string": true + } + }, + "description": "Set dynamic redirect from value", + "last_updated": "2020-12-18T09:28:09.655749Z", + "ref": "272936dc447b41fe976255ff6b768ec0", + "enabled": true + } + ], + "last_updated": "2020-12-18T09:28:09.655749Z", + "phase": "http_request_dynamic_redirect" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-18T09:28:09.655749Z") + + rules := []RulesetRule{{ + ID: "78723a9e0c7c4c6dbec5684cb766231d", + Version: StringPtr("1"), + Action: string(RulesetRuleActionRedirect), + ActionParameters: &RulesetRuleActionParameters{ + FromValue: &RulesetRuleActionParametersFromValue{ + StatusCode: 301, + TargetURL: RulesetRuleActionParametersTargetURL{ + Value: "some_host.com", + }, + PreserveQueryString: BoolPtr(true), + }, + }, + Description: "Set dynamic redirect from value", + LastUpdated: &lastUpdated, + Ref: "272936dc447b41fe976255ff6b768ec0", + Enabled: BoolPtr(true), + }} + + want := Ruleset{ + ID: "70339d97bdb34195bbf054b1ebe81f76", + Name: "Cloudflare Redirect Rules Ruleset", + Description: "This ruleset provides redirect from value", + Kind: string(RulesetKindZone), + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseHTTPRequestDynamicRedirect), + Rules: rules, + } + + zoneActual, err := client.GetRuleset(context.Background(), ZoneIdentifier(testZoneID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetRuleset(context.Background(), AccountIdentifier(testAccountID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestGetRuleset_CompressResponse(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "70339d97bdb34195bbf054b1ebe81f76", + "name": "Cloudflare compress response ruleset", + "description": "This ruleset provides response compression rules", + "kind": "zone", + "version": "1", + "rules": [ + { + "id": "78723a9e0c7c4c6dbec5684cb766231d", + "version": "1", + "action": "compress_response", + "action_parameters": { + "algorithms": [ { "name": "brotli" }, { "name": "default" } ] + }, + "description": "Compress response rule", + "last_updated": "2020-12-18T09:28:09.655749Z", + "ref": "272936dc447b41fe976255ff6b768ec0", + "enabled": true + } + ], + "last_updated": "2020-12-18T09:28:09.655749Z", + "phase": "http_response_compression" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/b232b534beea4e00a21dcbb7a8a545e9", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-18T09:28:09.655749Z") + + rules := []RulesetRule{{ + ID: "78723a9e0c7c4c6dbec5684cb766231d", + Version: StringPtr("1"), + Action: string(RulesetRuleActionCompressResponse), + ActionParameters: &RulesetRuleActionParameters{ + Algorithms: []RulesetRuleActionParametersCompressionAlgorithm{ + {Name: "brotli"}, + {Name: "default"}, + }, + }, + Description: "Compress response rule", + LastUpdated: &lastUpdated, + Ref: "272936dc447b41fe976255ff6b768ec0", + Enabled: BoolPtr(true), + }} + + want := Ruleset{ + ID: "70339d97bdb34195bbf054b1ebe81f76", + Name: "Cloudflare compress response ruleset", + Description: "This ruleset provides response compression rules", + Kind: string(RulesetKindZone), + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseHTTPResponseCompression), + Rules: rules, + } + + zoneActual, err := client.GetRuleset(context.Background(), ZoneIdentifier(testZoneID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.GetRuleset(context.Background(), AccountIdentifier(testAccountID), "b232b534beea4e00a21dcbb7a8a545e9") + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestCreateRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "my example ruleset", + "description": "Test magic transit ruleset", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + rules := []RulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: StringPtr("1"), + Action: string(RulesetRuleActionSkip), + ActionParameters: &RulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: BoolPtr(true), + }} + + newRuleset := CreateRulesetParams{ + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Phase: string(RulesetPhaseMagicTransit), + Rules: rules, + } + + want := Ruleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "my example ruleset", + Description: "Test magic transit ruleset", + Kind: "root", + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseMagicTransit), + Rules: rules, + } + + zoneActual, err := client.CreateRuleset(context.Background(), ZoneIdentifier(testZoneID), newRuleset) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.CreateRuleset(context.Background(), AccountIdentifier(testAccountID), newRuleset) + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} + +func TestDeleteRuleset(t *testing.T) { + setup() + defer teardown() + + 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, ``) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + zErr := client.DeleteRuleset(context.Background(), ZoneIdentifier(testZoneID), "2c0fc9fa937b11eaa1b71c4d701ab86e") + assert.NoError(t, zErr) + + aErr := client.DeleteRuleset(context.Background(), AccountIdentifier(testAccountID), "2c0fc9fa937b11eaa1b71c4d701ab86e") + assert.NoError(t, aErr) +} + +func TestUpdateRuleset(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "2c0fc9fa937b11eaa1b71c4d701ab86e", + "name": "ruleset1", + "description": "Test Firewall Ruleset Update", + "kind": "root", + "version": "1", + "last_updated": "2020-12-02T20:24:07.776073Z", + "phase": "magic_transit", + "rules": [ + { + "id": "62449e2e0de149619edb35e59c10d801", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "tcp.dstport in { 32768..65535 }", + "description": "Allow TCP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + }, + { + "id": "62449e2e0de149619edb35e59c10d802", + "version": "1", + "action": "skip", + "action_parameters":{ + "ruleset":"current" + }, + "expression": "udp.dstport in { 32768..65535 }", + "description": "Allow UDP Ephemeral Ports", + "last_updated": "2020-12-02T20:24:07.776073Z", + "ref": "72449e2e0de149619edb35e59c10d801", + "enabled": true + } + ] + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + mux.HandleFunc("/zones/"+testZoneID+"/rulesets/2c0fc9fa937b11eaa1b71c4d701ab86e", handler) + + lastUpdated, _ := time.Parse(time.RFC3339, "2020-12-02T20:24:07.776073Z") + + rules := []RulesetRule{{ + ID: "62449e2e0de149619edb35e59c10d801", + Version: StringPtr("1"), + Action: string(RulesetRuleActionSkip), + ActionParameters: &RulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "tcp.dstport in { 32768..65535 }", + Description: "Allow TCP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: BoolPtr(true), + }, { + ID: "62449e2e0de149619edb35e59c10d802", + Version: StringPtr("1"), + Action: string(RulesetRuleActionSkip), + ActionParameters: &RulesetRuleActionParameters{ + Ruleset: "current", + }, + Expression: "udp.dstport in { 32768..65535 }", + Description: "Allow UDP Ephemeral Ports", + LastUpdated: &lastUpdated, + Ref: "72449e2e0de149619edb35e59c10d801", + Enabled: BoolPtr(true), + }} + + updated := UpdateRulesetParams{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Description: "Test Firewall Ruleset Update", + Rules: rules, + } + + want := Ruleset{ + ID: "2c0fc9fa937b11eaa1b71c4d701ab86e", + Name: "ruleset1", + Description: "Test Firewall Ruleset Update", + Kind: "root", + Version: StringPtr("1"), + LastUpdated: &lastUpdated, + Phase: string(RulesetPhaseMagicTransit), + Rules: rules, + } + + zoneActual, err := client.UpdateRuleset(context.Background(), ZoneIdentifier(testZoneID), updated) + if assert.NoError(t, err) { + assert.Equal(t, want, zoneActual) + } + + accountActual, err := client.UpdateRuleset(context.Background(), AccountIdentifier(testAccountID), updated) + if assert.NoError(t, err) { + assert.Equal(t, want, accountActual) + } +} diff --git a/pkg/cloudflare-go/scripts/changelog.tmpl b/pkg/cloudflare-go/scripts/changelog.tmpl new file mode 100644 index 000000000..e5b8afa8b --- /dev/null +++ b/pkg/cloudflare-go/scripts/changelog.tmpl @@ -0,0 +1,39 @@ +{{- if index .NotesByType "breaking-change" }} +BREAKING CHANGES: + +{{range index .NotesByType "breaking-change" -}} +{{ template "note" .}} +{{ end -}} +{{- end -}} + +{{- if .NotesByType.note }} +NOTES: + +{{range .NotesByType.note -}} +{{ template "note" .}} +{{ end -}} +{{- end -}} + +{{- if .NotesByType.enhancement }} +ENHANCEMENTS: + +{{range .NotesByType.enhancement | sort -}} +{{ template "note" .}} +{{ end -}} +{{- end -}} + +{{- if .NotesByType.bug }} +BUG FIXES: + +{{range .NotesByType.bug | sort -}} +{{ template "note" . }} +{{ end -}} +{{- end -}} + +{{- if .NotesByType.dependency }} +DEPENDENCIES: + +{{range .NotesByType.dependency | sort -}} +{{ template "note" . }} +{{ end -}} +{{- end -}} diff --git a/pkg/cloudflare-go/scripts/generate-changelog-entry.sh b/pkg/cloudflare-go/scripts/generate-changelog-entry.sh new file mode 100755 index 000000000..cfe9583a3 --- /dev/null +++ b/pkg/cloudflare-go/scripts/generate-changelog-entry.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# +# Generate a changelog entry for a proposed PR by grabbing the next available +# auto incrementing ID in GitHub. + +if ! command -v curl &> /dev/null +then + echo "jq not be found" + exit 1 +fi + +if ! command -v jq &> /dev/null +then + echo "jq not be found" + exit 1 +fi + +current_pr=$(curl -s "https://api.github.com/repos/cloudflare/cloudflare-go/issues?state=all&per_page=1" | jq -r ".[].number") +next_pr=$(($current_pr + 1)) +changelog_path=".changelog/$next_pr.txt" + +echo "==> What type of change is this? (enhancement, bug, breaking-change)" +read entry_type + +echo "==> What is the summary of this change? Example: dns: updated X to do Y" +read entry_summary + +touch $(pwd)/$changelog_path +cat > $(pwd)/$changelog_path < $CHANGELOG_TMP_FILE_NAME +echo "$CHANGELOG" >> $CHANGELOG_TMP_FILE_NAME +echo >> $CHANGELOG_TMP_FILE_NAME +echo "$PREVIOUS_CHANGELOG" >> $CHANGELOG_TMP_FILE_NAME + +cp $CHANGELOG_TMP_FILE_NAME $CHANGELOG_FILE_NAME + +rm $CHANGELOG_TMP_FILE_NAME + +echo "Successfully generated changelog." + +exit 0 diff --git a/pkg/cloudflare-go/scripts/release-note.tmpl b/pkg/cloudflare-go/scripts/release-note.tmpl new file mode 100644 index 000000000..de55697a2 --- /dev/null +++ b/pkg/cloudflare-go/scripts/release-note.tmpl @@ -0,0 +1,3 @@ +{{- define "note" -}} +* {{.Body}} ([#{{- .Issue -}}](https://github.com/cloudflare/cloudflare-go/issues/{{- .Issue -}})) +{{- end -}} diff --git a/pkg/cloudflare-go/secondary_dns_primaries.go b/pkg/cloudflare-go/secondary_dns_primaries.go new file mode 100644 index 000000000..427a41aca --- /dev/null +++ b/pkg/cloudflare-go/secondary_dns_primaries.go @@ -0,0 +1,165 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +const ( + errSecondaryDNSInvalidPrimaryID = "secondary DNS primary ID is required" + errSecondaryDNSInvalidPrimaryIP = "secondary DNS primary IP invalid" + errSecondaryDNSInvalidPrimaryPort = "secondary DNS primary port invalid" +) + +// SecondaryDNSPrimary is the representation of the DNS Primary. +type SecondaryDNSPrimary struct { + ID string `json:"id,omitempty"` + IP string `json:"ip"` + Port int `json:"port"` + IxfrEnable bool `json:"ixfr_enable"` + TsigID string `json:"tsig_id"` + Name string `json:"name"` +} + +// SecondaryDNSPrimaryDetailResponse is the API representation of a single +// secondary DNS primary response. +type SecondaryDNSPrimaryDetailResponse struct { + Response + Result SecondaryDNSPrimary `json:"result"` +} + +// SecondaryDNSPrimaryListResponse is the API representation of all secondary DNS +// primaries. +type SecondaryDNSPrimaryListResponse struct { + Response + Result []SecondaryDNSPrimary `json:"result"` +} + +// GetSecondaryDNSPrimary returns a single secondary DNS primary. +// +// API reference: https://api.cloudflare.com/#secondary-dns-primary--primary-details +func (api *API) GetSecondaryDNSPrimary(ctx context.Context, accountID, primaryID string) (SecondaryDNSPrimary, error) { + uri := fmt.Sprintf("/accounts/%s/secondary_dns/primaries/%s", accountID, primaryID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return SecondaryDNSPrimary{}, err + } + + var r SecondaryDNSPrimaryDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return SecondaryDNSPrimary{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListSecondaryDNSPrimaries returns all secondary DNS primaries for an account. +// +// API reference: https://api.cloudflare.com/#secondary-dns-primary--list-primaries +func (api *API) ListSecondaryDNSPrimaries(ctx context.Context, accountID string) ([]SecondaryDNSPrimary, error) { + uri := fmt.Sprintf("/accounts/%s/secondary_dns/primaries", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []SecondaryDNSPrimary{}, err + } + + var r SecondaryDNSPrimaryListResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []SecondaryDNSPrimary{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateSecondaryDNSPrimary creates a secondary DNS primary. +// +// API reference: https://api.cloudflare.com/#secondary-dns-primary--create-primary +func (api *API) CreateSecondaryDNSPrimary(ctx context.Context, accountID string, primary SecondaryDNSPrimary) (SecondaryDNSPrimary, error) { + if err := validateRequiredSecondaryDNSPrimaries(primary); err != nil { + return SecondaryDNSPrimary{}, err + } + + uri := fmt.Sprintf("/accounts/%s/secondary_dns/primaries", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, SecondaryDNSPrimary{ + IP: primary.IP, + Port: primary.Port, + IxfrEnable: primary.IxfrEnable, + TsigID: primary.TsigID, + Name: primary.Name, + }) + if err != nil { + return SecondaryDNSPrimary{}, err + } + + var r SecondaryDNSPrimaryDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return SecondaryDNSPrimary{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateSecondaryDNSPrimary creates a secondary DNS primary. +// +// API reference: https://api.cloudflare.com/#secondary-dns-primary--update-primary +func (api *API) UpdateSecondaryDNSPrimary(ctx context.Context, accountID string, primary SecondaryDNSPrimary) (SecondaryDNSPrimary, error) { + if primary.ID == "" { + return SecondaryDNSPrimary{}, errors.New(errSecondaryDNSInvalidPrimaryID) + } + + if err := validateRequiredSecondaryDNSPrimaries(primary); err != nil { + return SecondaryDNSPrimary{}, err + } + + uri := fmt.Sprintf("/accounts/%s/secondary_dns/primaries/%s", accountID, primary.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, SecondaryDNSPrimary{ + IP: primary.IP, + Port: primary.Port, + IxfrEnable: primary.IxfrEnable, + TsigID: primary.TsigID, + Name: primary.Name, + }) + if err != nil { + return SecondaryDNSPrimary{}, err + } + + var r SecondaryDNSPrimaryDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return SecondaryDNSPrimary{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteSecondaryDNSPrimary deletes a secondary DNS primary. +// +// API reference: https://api.cloudflare.com/#secondary-dns-primary--delete-primary +func (api *API) DeleteSecondaryDNSPrimary(ctx context.Context, accountID, primaryID string) error { + uri := fmt.Sprintf("/zones/%s/secondary_dns/primaries/%s", accountID, primaryID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return err + } + + return nil +} + +func validateRequiredSecondaryDNSPrimaries(p SecondaryDNSPrimary) error { + if p.IP == "" { + return errors.New(errSecondaryDNSInvalidPrimaryIP) + } + + if p.Port == 0 { + return errors.New(errSecondaryDNSInvalidPrimaryPort) + } + + return nil +} diff --git a/pkg/cloudflare-go/secondary_dns_primaries_test.go b/pkg/cloudflare-go/secondary_dns_primaries_test.go new file mode 100644 index 000000000..594d4a28f --- /dev/null +++ b/pkg/cloudflare-go/secondary_dns_primaries_test.go @@ -0,0 +1,204 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSecondaryDNSPrimary(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "23ff594956f20c2a721606e94745a8aa", + "ip": "192.0.2.53", + "port": 53, + "ixfr_enable": false, + "tsig_id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "my-primary-1" + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/primaries/23ff594956f20c2a721606e94745a8aa", handler) + want := SecondaryDNSPrimary{ + ID: "23ff594956f20c2a721606e94745a8aa", + IP: "192.0.2.53", + Port: 53, + IxfrEnable: false, + TsigID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "my-primary-1", + } + + actual, err := client.GetSecondaryDNSPrimary(context.Background(), "01a7362d577a6c3019a474fd6f485823", "23ff594956f20c2a721606e94745a8aa") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListSecondaryDNSPrimaries(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [{ + "id": "23ff594956f20c2a721606e94745a8aa", + "ip": "192.0.2.53", + "port": 53, + "ixfr_enable": false, + "tsig_id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "my-primary-1" + }] + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/primaries", handler) + want := []SecondaryDNSPrimary{{ + ID: "23ff594956f20c2a721606e94745a8aa", + IP: "192.0.2.53", + Port: 53, + IxfrEnable: false, + TsigID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "my-primary-1", + }} + + actual, err := client.ListSecondaryDNSPrimaries(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSecondaryDNSPrimary(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "23ff594956f20c2a721606e94745a8aa", + "ip": "192.0.2.53", + "port": 53, + "ixfr_enable": false, + "tsig_id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "my-primary-1" + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/primaries", handler) + want := SecondaryDNSPrimary{ + ID: "23ff594956f20c2a721606e94745a8aa", + IP: "192.0.2.53", + Port: 53, + IxfrEnable: false, + TsigID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "my-primary-1", + } + + actual, err := client.CreateSecondaryDNSPrimary(context.Background(), "01a7362d577a6c3019a474fd6f485823", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSecondaryDNSPrimary(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "23ff594956f20c2a721606e94745a8aa", + "ip": "192.0.2.53", + "port": 53, + "ixfr_enable": false, + "tsig_id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "my-primary-1" + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/primaries/23ff594956f20c2a721606e94745a8aa", handler) + want := SecondaryDNSPrimary{ + ID: "23ff594956f20c2a721606e94745a8aa", + IP: "192.0.2.53", + Port: 53, + IxfrEnable: false, + TsigID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "my-primary-1", + } + + actual, err := client.UpdateSecondaryDNSPrimary(context.Background(), "01a7362d577a6c3019a474fd6f485823", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteSecondaryDNSPrimary(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "23ff594956f20c2a721606e94745a8aa" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/secondary_dns/primaries/23ff594956f20c2a721606e94745a8aa", handler) + + err := client.DeleteSecondaryDNSPrimary(context.Background(), "01a7362d577a6c3019a474fd6f485823", "23ff594956f20c2a721606e94745a8aa") + assert.NoError(t, err) +} + +func TestValidateRequiredSecondaryDNSPrimaries(t *testing.T) { + p1 := SecondaryDNSPrimary{} + err1 := validateRequiredSecondaryDNSPrimaries(p1) + assert.EqualError(t, err1, errSecondaryDNSInvalidPrimaryIP) + + p2 := SecondaryDNSPrimary{IP: "192.0.2.53"} + err2 := validateRequiredSecondaryDNSPrimaries(p2) + assert.EqualError(t, err2, errSecondaryDNSInvalidPrimaryPort) + + p3 := SecondaryDNSPrimary{IP: "192.0.2.53", Port: 53} + err3 := validateRequiredSecondaryDNSPrimaries(p3) + assert.NoError(t, err3) +} diff --git a/pkg/cloudflare-go/secondary_dns_tsig.go b/pkg/cloudflare-go/secondary_dns_tsig.go new file mode 100644 index 000000000..07b76bc09 --- /dev/null +++ b/pkg/cloudflare-go/secondary_dns_tsig.go @@ -0,0 +1,132 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +const ( + errSecondaryDNSTSIGMissingID = "secondary DNS TSIG ID is required" +) + +// SecondaryDNSTSIG contains the structure for a secondary DNS TSIG. +type SecondaryDNSTSIG struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Secret string `json:"secret"` + Algo string `json:"algo"` +} + +// SecondaryDNSTSIGDetailResponse is the API response for a single secondary +// DNS TSIG. +type SecondaryDNSTSIGDetailResponse struct { + Response + Result SecondaryDNSTSIG `json:"result"` +} + +// SecondaryDNSTSIGListResponse is the API response for all secondary DNS TSIGs. +type SecondaryDNSTSIGListResponse struct { + Response + Result []SecondaryDNSTSIG `json:"result"` +} + +// GetSecondaryDNSTSIG gets a single account level TSIG for a secondary DNS +// configuration. +// +// API reference: https://api.cloudflare.com/#secondary-dns-tsig--tsig-details +func (api *API) GetSecondaryDNSTSIG(ctx context.Context, accountID, tsigID string) (SecondaryDNSTSIG, error) { + uri := fmt.Sprintf("/accounts/%s/secondary_dns/tsigs/%s", accountID, tsigID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return SecondaryDNSTSIG{}, err + } + + var r SecondaryDNSTSIGDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return SecondaryDNSTSIG{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListSecondaryDNSTSIGs gets all account level TSIG for a secondary DNS +// configuration. +// +// API reference: https://api.cloudflare.com/#secondary-dns-tsig--list-tsigs +func (api *API) ListSecondaryDNSTSIGs(ctx context.Context, accountID string) ([]SecondaryDNSTSIG, error) { + uri := fmt.Sprintf("/accounts/%s/secondary_dns/tsigs", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []SecondaryDNSTSIG{}, err + } + + var r SecondaryDNSTSIGListResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []SecondaryDNSTSIG{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateSecondaryDNSTSIG creates a secondary DNS TSIG at the account level. +// +// API reference: https://api.cloudflare.com/#secondary-dns-tsig--create-tsig +func (api *API) CreateSecondaryDNSTSIG(ctx context.Context, accountID string, tsig SecondaryDNSTSIG) (SecondaryDNSTSIG, error) { + uri := fmt.Sprintf("/accounts/%s/secondary_dns/tsigs", accountID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, tsig) + + if err != nil { + return SecondaryDNSTSIG{}, err + } + + result := SecondaryDNSTSIGDetailResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return SecondaryDNSTSIG{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateSecondaryDNSTSIG updates an existing secondary DNS TSIG at +// the account level. +// +// API reference: https://api.cloudflare.com/#secondary-dns-tsig--update-tsig +func (api *API) UpdateSecondaryDNSTSIG(ctx context.Context, accountID string, tsig SecondaryDNSTSIG) (SecondaryDNSTSIG, error) { + if tsig.ID == "" { + return SecondaryDNSTSIG{}, errors.New(errSecondaryDNSTSIGMissingID) + } + + uri := fmt.Sprintf("/accounts/%s/secondary_dns/tsigs/%s", accountID, tsig.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, tsig) + + if err != nil { + return SecondaryDNSTSIG{}, err + } + + result := SecondaryDNSTSIGDetailResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return SecondaryDNSTSIG{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteSecondaryDNSTSIG deletes a secondary DNS TSIG. +// +// API reference: https://api.cloudflare.com/#secondary-dns-tsig--delete-tsig +func (api *API) DeleteSecondaryDNSTSIG(ctx context.Context, accountID, tsigID string) error { + uri := fmt.Sprintf("/accounts/%s/secondary_dns/tsigs/%s", accountID, tsigID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/secondary_dns_tsig_test.go b/pkg/cloudflare-go/secondary_dns_tsig_test.go new file mode 100644 index 000000000..3f135e046 --- /dev/null +++ b/pkg/cloudflare-go/secondary_dns_tsig_test.go @@ -0,0 +1,186 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSecondaryDNSTSIG(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "tsig.customer.cf.", + "secret": "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + "algo": "hmac-sha512." + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/tsigs/69cd1e104af3e6ed3cb344f263fd0d5a", handler) + + want := SecondaryDNSTSIG{ + ID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "tsig.customer.cf.", + Secret: "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + Algo: "hmac-sha512.", + } + + actual, err := client.GetSecondaryDNSTSIG(context.Background(), "01a7362d577a6c3019a474fd6f485823", "69cd1e104af3e6ed3cb344f263fd0d5a") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListSecondaryDNSTSIGs(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "tsig.customer.cf.", + "secret": "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + "algo": "hmac-sha512." + } + ] + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/tsigs", handler) + + want := []SecondaryDNSTSIG{{ + ID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "tsig.customer.cf.", + Secret: "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + Algo: "hmac-sha512.", + }} + + actual, err := client.ListSecondaryDNSTSIGs(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSecondaryDNSTSIG(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "tsig.customer.cf.", + "secret": "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + "algo": "hmac-sha512." + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/tsigs", handler) + + want := SecondaryDNSTSIG{ + ID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "tsig.customer.cf.", + Secret: "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + Algo: "hmac-sha512.", + } + + actual, err := client.CreateSecondaryDNSTSIG(context.Background(), "01a7362d577a6c3019a474fd6f485823", SecondaryDNSTSIG{ + Name: "tsig.customer.cf.", + Secret: "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + Algo: "hmac-sha512.", + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSecondaryDNSTSIG(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "69cd1e104af3e6ed3cb344f263fd0d5a", + "name": "tsig.customer.cf.", + "secret": "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + "algo": "hmac-sha512." + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/tsigs/69cd1e104af3e6ed3cb344f263fd0d5a", handler) + + want := SecondaryDNSTSIG{ + ID: "69cd1e104af3e6ed3cb344f263fd0d5a", + Name: "tsig.customer.cf.", + Secret: "caf79a7804b04337c9c66ccd7bef9190a1e1679b5dd03d8aa10f7ad45e1a9dab92b417896c15d4d007c7c14194538d2a5d0feffdecc5a7f0e1c570cfa700837c", + Algo: "hmac-sha512.", + } + + actual, err := client.UpdateSecondaryDNSTSIG(context.Background(), "01a7362d577a6c3019a474fd6f485823", want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteSecondaryDNSTSIG(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "269d8f4853475ca241c4e730be286b20" + } + } + `) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/secondary_dns/tsigs/69cd1e104af3e6ed3cb344f263fd0d5a", handler) + + err := client.DeleteSecondaryDNSTSIG(context.Background(), "01a7362d577a6c3019a474fd6f485823", "69cd1e104af3e6ed3cb344f263fd0d5a") + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/secondary_dns_zone.go b/pkg/cloudflare-go/secondary_dns_zone.go new file mode 100644 index 000000000..2371366c4 --- /dev/null +++ b/pkg/cloudflare-go/secondary_dns_zone.go @@ -0,0 +1,172 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +const ( + errSecondaryDNSInvalidAutoRefreshValue = "secondary DNS auto refresh value is invalid" + errSecondaryDNSInvalidZoneName = "secondary DNS zone name is invalid" + errSecondaryDNSInvalidPrimaries = "secondary DNS primaries value is invalid" +) + +// SecondaryDNSZone contains the high level structure of a secondary DNS zone. +type SecondaryDNSZone struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Primaries []string `json:"primaries,omitempty"` + AutoRefreshSeconds int `json:"auto_refresh_seconds,omitempty"` + SoaSerial int `json:"soa_serial,omitempty"` + CreatedTime time.Time `json:"created_time,omitempty"` + CheckedTime time.Time `json:"checked_time,omitempty"` + ModifiedTime time.Time `json:"modified_time,omitempty"` +} + +// SecondaryDNSZoneDetailResponse is the API response for a single secondary +// DNS zone. +type SecondaryDNSZoneDetailResponse struct { + Response + Result SecondaryDNSZone `json:"result"` +} + +// SecondaryDNSZoneAXFRResponse is the API response for a single secondary +// DNS AXFR response. +type SecondaryDNSZoneAXFRResponse struct { + Response + Result string `json:"result"` +} + +// GetSecondaryDNSZone returns the secondary DNS zone configuration for a +// single zone. +// +// API reference: https://api.cloudflare.com/#secondary-dns-secondary-zone-configuration-details +func (api *API) GetSecondaryDNSZone(ctx context.Context, zoneID string) (SecondaryDNSZone, error) { + uri := fmt.Sprintf("/zones/%s/secondary_dns", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return SecondaryDNSZone{}, err + } + + var r SecondaryDNSZoneDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return SecondaryDNSZone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateSecondaryDNSZone creates a secondary DNS zone. +// +// API reference: https://api.cloudflare.com/#secondary-dns-create-secondary-zone-configuration +func (api *API) CreateSecondaryDNSZone(ctx context.Context, zoneID string, zone SecondaryDNSZone) (SecondaryDNSZone, error) { + if err := validateRequiredSecondaryDNSZoneValues(zone); err != nil { + return SecondaryDNSZone{}, err + } + + uri := fmt.Sprintf("/zones/%s/secondary_dns", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, + SecondaryDNSZone{ + Name: zone.Name, + AutoRefreshSeconds: zone.AutoRefreshSeconds, + Primaries: zone.Primaries, + }, + ) + + if err != nil { + return SecondaryDNSZone{}, err + } + + result := SecondaryDNSZoneDetailResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return SecondaryDNSZone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// UpdateSecondaryDNSZone updates an existing secondary DNS zone. +// +// API reference: https://api.cloudflare.com/#secondary-dns-update-secondary-zone-configuration +func (api *API) UpdateSecondaryDNSZone(ctx context.Context, zoneID string, zone SecondaryDNSZone) (SecondaryDNSZone, error) { + if err := validateRequiredSecondaryDNSZoneValues(zone); err != nil { + return SecondaryDNSZone{}, err + } + + uri := fmt.Sprintf("/zones/%s/secondary_dns", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, + SecondaryDNSZone{ + Name: zone.Name, + AutoRefreshSeconds: zone.AutoRefreshSeconds, + Primaries: zone.Primaries, + }, + ) + + if err != nil { + return SecondaryDNSZone{}, err + } + + result := SecondaryDNSZoneDetailResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return SecondaryDNSZone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result, nil +} + +// DeleteSecondaryDNSZone deletes a secondary DNS zone. +// +// API reference: https://api.cloudflare.com/#secondary-dns-delete-secondary-zone-configuration +func (api *API) DeleteSecondaryDNSZone(ctx context.Context, zoneID string) error { + uri := fmt.Sprintf("/zones/%s/secondary_dns", zoneID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return err + } + + return nil +} + +// ForceSecondaryDNSZoneAXFR requests an immediate AXFR request. +// +// API reference: https://api.cloudflare.com/#secondary-dns-update-secondary-zone-configuration +func (api *API) ForceSecondaryDNSZoneAXFR(ctx context.Context, zoneID string) error { + uri := fmt.Sprintf("/zones/%s/secondary_dns/force_axfr", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + + if err != nil { + return err + } + + result := SecondaryDNSZoneAXFRResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// validateRequiredSecondaryDNSZoneValues ensures that the payload matches the +// API requirements for required fields. +func validateRequiredSecondaryDNSZoneValues(zone SecondaryDNSZone) error { + if zone.Name == "" { + return errors.New(errSecondaryDNSInvalidZoneName) + } + + if zone.AutoRefreshSeconds == 0 || zone.AutoRefreshSeconds < 0 { + return errors.New(errSecondaryDNSInvalidAutoRefreshValue) + } + + if len(zone.Primaries) == 0 { + return errors.New(errSecondaryDNSInvalidPrimaries) + } + + return nil +} diff --git a/pkg/cloudflare-go/secondary_zone_dns_test.go b/pkg/cloudflare-go/secondary_zone_dns_test.go new file mode 100644 index 000000000..4a7d94144 --- /dev/null +++ b/pkg/cloudflare-go/secondary_zone_dns_test.go @@ -0,0 +1,249 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetSecondaryDNSZone(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "269d8f4853475ca241c4e730be286b20", + "name": "www.example.com.", + "primaries": [ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "auto_refresh_seconds": 86400, + "soa_serial": 2019102400, + "created_time": "2019-10-24T17:09:42.883908+01:00", + "checked_time": "2019-10-24T17:09:42.883908+01:00", + "modified_time": "2019-10-24T17:09:42.883908+01:00" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/secondary_dns", handler) + createdOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + modifiedOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + checkedOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + want := SecondaryDNSZone{ + ID: "269d8f4853475ca241c4e730be286b20", + Name: "www.example.com.", + Primaries: []string{ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + AutoRefreshSeconds: 86400, + SoaSerial: 2019102400, + CreatedTime: createdOn, + CheckedTime: checkedOn, + ModifiedTime: modifiedOn, + } + + actual, err := client.GetSecondaryDNSZone(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSecondaryDNSZone(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "269d8f4853475ca241c4e730be286b20", + "name": "www.example.com.", + "primaries": [ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "auto_refresh_seconds": 86400, + "soa_serial": 2019102400, + "created_time": "2019-10-24T17:09:42.883908+01:00", + "checked_time": "2019-10-24T17:09:42.883908+01:00", + "modified_time": "2019-10-24T17:09:42.883908+01:00" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/secondary_dns", handler) + createdOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + modifiedOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + checkedOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + want := SecondaryDNSZone{ + ID: "269d8f4853475ca241c4e730be286b20", + Name: "www.example.com.", + Primaries: []string{ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + AutoRefreshSeconds: 86400, + SoaSerial: 2019102400, + CreatedTime: createdOn, + CheckedTime: checkedOn, + ModifiedTime: modifiedOn, + } + + newSecondaryZone := SecondaryDNSZone{ + Name: "www.example.com.", + Primaries: []string{ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + AutoRefreshSeconds: 86400, + } + + actual, err := client.CreateSecondaryDNSZone(context.Background(), "01a7362d577a6c3019a474fd6f485823", newSecondaryZone) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSecondaryDNSZone(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "269d8f4853475ca241c4e730be286b20", + "name": "www.example.com.", + "primaries": [ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194" + ], + "auto_refresh_seconds": 86400, + "soa_serial": 2019102400, + "created_time": "2019-10-24T17:09:42.883908+01:00", + "checked_time": "2019-10-24T17:09:42.883908+01:00", + "modified_time": "2019-10-24T17:09:42.883908+01:00" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/secondary_dns", handler) + createdOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + modifiedOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + checkedOn, _ := time.Parse(time.RFC3339, "2019-10-24T17:09:42.883908+01:00") + want := SecondaryDNSZone{ + ID: "269d8f4853475ca241c4e730be286b20", + Name: "www.example.com.", + Primaries: []string{ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + AutoRefreshSeconds: 86400, + SoaSerial: 2019102400, + CreatedTime: createdOn, + CheckedTime: checkedOn, + ModifiedTime: modifiedOn, + } + + updatedZone := SecondaryDNSZone{ + Name: "www.example.com.", + Primaries: []string{ + "23ff594956f20c2a721606e94745a8aa", + "00920f38ce07c2e2f4df50b1f61d4194", + }, + AutoRefreshSeconds: 86400, + } + + actual, err := client.UpdateSecondaryDNSZone(context.Background(), "01a7362d577a6c3019a474fd6f485823", updatedZone) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteSecondaryDNSZone(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "269d8f4853475ca241c4e730be286b20" + } + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/secondary_dns", handler) + + err := client.DeleteSecondaryDNSZone(context.Background(), "01a7362d577a6c3019a474fd6f485823") + assert.NoError(t, err) +} + +func TestForceSecondaryDNSZoneAXFR(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": "OK" + } + `) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/secondary_dns/force_axfr", handler) + + err := client.ForceSecondaryDNSZoneAXFR(context.Background(), "01a7362d577a6c3019a474fd6f485823") + assert.NoError(t, err) +} + +func TestValidateRequiredSecondaryDNSZoneValues(t *testing.T) { + z1 := SecondaryDNSZone{} + err1 := validateRequiredSecondaryDNSZoneValues(z1) + assert.EqualError(t, err1, errSecondaryDNSInvalidZoneName) + + z2 := SecondaryDNSZone{Name: "example.com."} + err2 := validateRequiredSecondaryDNSZoneValues(z2) + assert.EqualError(t, err2, errSecondaryDNSInvalidAutoRefreshValue) + + z3 := SecondaryDNSZone{Name: "example.com.", AutoRefreshSeconds: 1} + err3 := validateRequiredSecondaryDNSZoneValues(z3) + assert.EqualError(t, err3, errSecondaryDNSInvalidPrimaries) + + z4 := SecondaryDNSZone{Name: "example.com.", AutoRefreshSeconds: 1, Primaries: []string{"a", "b"}} + err4 := validateRequiredSecondaryDNSZoneValues(z4) + assert.NoError(t, err4) +} diff --git a/pkg/cloudflare-go/spectrum.go b/pkg/cloudflare-go/spectrum.go new file mode 100644 index 000000000..2ebfb41d5 --- /dev/null +++ b/pkg/cloudflare-go/spectrum.go @@ -0,0 +1,368 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/goccy/go-json" +) + +// ProxyProtocol implements json.Unmarshaler in order to support deserializing of the deprecated boolean +// value for `proxy_protocol`. +type ProxyProtocol string + +// UnmarshalJSON handles deserializing of both the deprecated boolean value and the current string value +// for the `proxy_protocol` field. +func (p *ProxyProtocol) UnmarshalJSON(data []byte) error { + var raw interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + switch pp := raw.(type) { + case string: + *p = ProxyProtocol(pp) + case bool: + if pp { + *p = "v1" + } else { + *p = "off" + } + default: + return fmt.Errorf("invalid type for proxy_protocol field: %T", pp) + } + return nil +} + +// SpectrumApplicationOriginPort defines a union of a single port or range of ports. +type SpectrumApplicationOriginPort struct { + Port, Start, End uint16 +} + +// ErrOriginPortInvalid is a common error for failing to parse a single port or port range. +var ErrOriginPortInvalid = errors.New("invalid origin port") + +func (p *SpectrumApplicationOriginPort) parse(s string) error { + switch split := strings.Split(s, "-"); len(split) { + case 1: + i, err := strconv.ParseUint(split[0], 10, 16) + if err != nil { + return err + } + p.Port = uint16(i) + case 2: + start, err := strconv.ParseUint(split[0], 10, 16) + if err != nil { + return err + } + end, err := strconv.ParseUint(split[1], 10, 16) + if err != nil { + return err + } + if start >= end { + return ErrOriginPortInvalid + } + p.Start = uint16(start) + p.End = uint16(end) + default: + return ErrOriginPortInvalid + } + return nil +} + +// UnmarshalJSON converts a byte slice into a single port or port range. +func (p *SpectrumApplicationOriginPort) UnmarshalJSON(b []byte) error { + var port interface{} + if err := json.Unmarshal(b, &port); err != nil { + return err + } + + switch i := port.(type) { + case float64: + p.Port = uint16(i) + case string: + if err := p.parse(i); err != nil { + return err + } + } + + return nil +} + +// MarshalJSON converts a single port or port range to a suitable byte slice. +func (p *SpectrumApplicationOriginPort) MarshalJSON() ([]byte, error) { + if p.End > 0 { + return json.Marshal(fmt.Sprintf("%d-%d", p.Start, p.End)) + } + return json.Marshal(p.Port) +} + +// SpectrumApplication defines a single Spectrum Application. +type SpectrumApplication struct { + DNS SpectrumApplicationDNS `json:"dns,omitempty"` + OriginDirect []string `json:"origin_direct,omitempty"` + ID string `json:"id,omitempty"` + Protocol string `json:"protocol,omitempty"` + TrafficType string `json:"traffic_type,omitempty"` + TLS string `json:"tls,omitempty"` + ProxyProtocol ProxyProtocol `json:"proxy_protocol,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + OriginDNS *SpectrumApplicationOriginDNS `json:"origin_dns,omitempty"` + OriginPort *SpectrumApplicationOriginPort `json:"origin_port,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + EdgeIPs *SpectrumApplicationEdgeIPs `json:"edge_ips,omitempty"` + ArgoSmartRouting bool `json:"argo_smart_routing,omitempty"` + IPv4 bool `json:"ipv4,omitempty"` + IPFirewall bool `json:"ip_firewall,omitempty"` +} + +// UnmarshalJSON handles setting the `ProxyProtocol` field based on the value of the deprecated `spp` field. +func (a *SpectrumApplication) UnmarshalJSON(data []byte) error { + var body map[string]interface{} + if err := json.Unmarshal(data, &body); err != nil { + return err + } + + var app spectrumApplicationRaw + if err := json.Unmarshal(data, &app); err != nil { + return err + } + + if spp, ok := body["spp"]; ok && spp.(bool) { + app.ProxyProtocol = "simple" + } + + *a = SpectrumApplication(app) + return nil +} + +// spectrumApplicationRaw is used to inspect an application body to support the deprecated boolean value for `spp`. +type spectrumApplicationRaw SpectrumApplication + +// SpectrumApplicationDNS holds the external DNS configuration for a Spectrum +// Application. +type SpectrumApplicationDNS struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// SpectrumApplicationOriginDNS holds the origin DNS configuration for a Spectrum +// Application. +type SpectrumApplicationOriginDNS struct { + Name string `json:"name"` +} + +// SpectrumApplicationDetailResponse is the structure of the detailed response +// from the API. +type SpectrumApplicationDetailResponse struct { + Response + Result SpectrumApplication `json:"result"` +} + +// SpectrumApplicationsDetailResponse is the structure of the detailed response +// from the API. +type SpectrumApplicationsDetailResponse struct { + Response + Result []SpectrumApplication `json:"result"` +} + +// SpectrumApplicationEdgeIPs represents configuration for Bring-Your-Own-IP +// https://developers.cloudflare.com/spectrum/getting-started/byoip/ +type SpectrumApplicationEdgeIPs struct { + Type SpectrumApplicationEdgeType `json:"type"` + Connectivity *SpectrumApplicationConnectivity `json:"connectivity,omitempty"` + IPs []net.IP `json:"ips,omitempty"` +} + +// SpectrumApplicationEdgeType for possible Edge configurations. +type SpectrumApplicationEdgeType string + +const ( + // SpectrumEdgeTypeDynamic IP config. + SpectrumEdgeTypeDynamic SpectrumApplicationEdgeType = "dynamic" + // SpectrumEdgeTypeStatic IP config. + SpectrumEdgeTypeStatic SpectrumApplicationEdgeType = "static" +) + +// UnmarshalJSON function for SpectrumApplicationEdgeType enum. +func (t *SpectrumApplicationEdgeType) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + + newEdgeType := SpectrumApplicationEdgeType(strings.ToLower(s)) + switch newEdgeType { + case SpectrumEdgeTypeDynamic, SpectrumEdgeTypeStatic: + *t = newEdgeType + return nil + } + + return errors.New(errUnmarshalError) +} + +func (t SpectrumApplicationEdgeType) String() string { + return string(t) +} + +// SpectrumApplicationConnectivity specifies IP address type on the edge configuration. +type SpectrumApplicationConnectivity string + +const ( + // SpectrumConnectivityAll specifies IPv4/6 edge IP. + SpectrumConnectivityAll SpectrumApplicationConnectivity = "all" + // SpectrumConnectivityIPv4 specifies IPv4 edge IP. + SpectrumConnectivityIPv4 SpectrumApplicationConnectivity = "ipv4" + // SpectrumConnectivityIPv6 specifies IPv6 edge IP. + SpectrumConnectivityIPv6 SpectrumApplicationConnectivity = "ipv6" + // SpectrumConnectivityStatic specifies static edge IP configuration. + SpectrumConnectivityStatic SpectrumApplicationConnectivity = "static" +) + +func (c SpectrumApplicationConnectivity) String() string { + return string(c) +} + +// UnmarshalJSON function for SpectrumApplicationConnectivity enum. +func (c *SpectrumApplicationConnectivity) UnmarshalJSON(b []byte) error { + var s string + err := json.Unmarshal(b, &s) + if err != nil { + return err + } + + newConnectivity := SpectrumApplicationConnectivity(strings.ToLower(s)) + if newConnectivity.Dynamic() { + *c = newConnectivity + return nil + } + + return errors.New(errUnmarshalError) +} + +// Dynamic checks if address family is specified as dynamic config. +func (c SpectrumApplicationConnectivity) Dynamic() bool { + switch c { + case SpectrumConnectivityAll, SpectrumConnectivityIPv4, SpectrumConnectivityIPv6: + return true + } + return false +} + +// Static checks if address family is specified as static config. +func (c SpectrumApplicationConnectivity) Static() bool { + return c == SpectrumConnectivityStatic +} + +// SpectrumApplications fetches all of the Spectrum applications for a zone. +// +// API reference: https://developers.cloudflare.com/spectrum/api-reference/#list-spectrum-applications +func (api *API) SpectrumApplications(ctx context.Context, zoneID string) ([]SpectrumApplication, error) { + uri := fmt.Sprintf("/zones/%s/spectrum/apps", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []SpectrumApplication{}, err + } + + var spectrumApplications SpectrumApplicationsDetailResponse + err = json.Unmarshal(res, &spectrumApplications) + if err != nil { + return []SpectrumApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return spectrumApplications.Result, nil +} + +// SpectrumApplication fetches a single Spectrum application based on the ID. +// +// API reference: https://developers.cloudflare.com/spectrum/api-reference/#list-spectrum-applications +func (api *API) SpectrumApplication(ctx context.Context, zoneID string, applicationID string) (SpectrumApplication, error) { + uri := fmt.Sprintf( + "/zones/%s/spectrum/apps/%s", + zoneID, + applicationID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return SpectrumApplication{}, err + } + + var spectrumApplication SpectrumApplicationDetailResponse + err = json.Unmarshal(res, &spectrumApplication) + if err != nil { + return SpectrumApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return spectrumApplication.Result, nil +} + +// CreateSpectrumApplication creates a new Spectrum application. +// +// API reference: https://developers.cloudflare.com/spectrum/api-reference/#create-a-spectrum-application +func (api *API) CreateSpectrumApplication(ctx context.Context, zoneID string, appDetails SpectrumApplication) (SpectrumApplication, error) { + uri := fmt.Sprintf("/zones/%s/spectrum/apps", zoneID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, appDetails) + if err != nil { + return SpectrumApplication{}, err + } + + var spectrumApplication SpectrumApplicationDetailResponse + err = json.Unmarshal(res, &spectrumApplication) + if err != nil { + return SpectrumApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return spectrumApplication.Result, nil +} + +// UpdateSpectrumApplication updates an existing Spectrum application. +// +// API reference: https://developers.cloudflare.com/spectrum/api-reference/#update-a-spectrum-application +func (api *API) UpdateSpectrumApplication(ctx context.Context, zoneID, appID string, appDetails SpectrumApplication) (SpectrumApplication, error) { + uri := fmt.Sprintf( + "/zones/%s/spectrum/apps/%s", + zoneID, + appID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, appDetails) + if err != nil { + return SpectrumApplication{}, err + } + + var spectrumApplication SpectrumApplicationDetailResponse + err = json.Unmarshal(res, &spectrumApplication) + if err != nil { + return SpectrumApplication{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return spectrumApplication.Result, nil +} + +// DeleteSpectrumApplication removes a Spectrum application based on the ID. +// +// API reference: https://developers.cloudflare.com/spectrum/api-reference/#delete-a-spectrum-application +func (api *API) DeleteSpectrumApplication(ctx context.Context, zoneID string, applicationID string) error { + uri := fmt.Sprintf( + "/zones/%s/spectrum/apps/%s", + zoneID, + applicationID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/spectrum_test.go b/pkg/cloudflare-go/spectrum_test.go new file mode 100644 index 000000000..85fa20cb6 --- /dev/null +++ b/pkg/cloudflare-go/spectrum_test.go @@ -0,0 +1,549 @@ +package cloudflare + +import ( + "context" + "fmt" + "net" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestSpectrumApplication(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/22", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_direct": [ + "tcp://192.0.2.1:22" + ], + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "off", + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps/f68579455bd947efb65ffa1bcf33b52c", handler) + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Protocol: "tcp/22", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Name: "spectrum.example.com", + Type: "CNAME", + }, + OriginDirect: []string{"tcp://192.0.2.1:22"}, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "off", + } + + actual, err := client.SpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f68579455bd947efb65ffa1bcf33b52c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSpectrumApplications(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/22", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_direct": [ + "tcp://192.0.2.1:22" + ], + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "off", + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + } + ], + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps", handler) + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := []SpectrumApplication{ + { + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Protocol: "tcp/22", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Name: "spectrum.example.com", + Type: "CNAME", + }, + OriginDirect: []string{"tcp://192.0.2.1:22"}, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "off", + }, + } + + actual, err := client.SpectrumApplications(context.Background(), "01a7362d577a6c3019a474fd6f485823") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSpectrumApplication(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/23", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum1.example.com" + }, + "origin_direct": [ + "tcp://192.0.2.1:23" + ], + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "full", + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps/f68579455bd947efb65ffa1bcf33b52c", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + Protocol: "tcp/23", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Type: "CNAME", + Name: "spectrum1.example.com", + }, + OriginDirect: []string{"tcp://192.0.2.1:23"}, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "full", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.UpdateSpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f68579455bd947efb65ffa1bcf33b52c", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSpectrumApplication(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/22", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_direct": [ + "tcp://192.0.2.1:22" + ], + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "full", + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + Protocol: "tcp/22", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Type: "CNAME", + Name: "spectrum.example.com", + }, + OriginDirect: []string{"tcp://192.0.2.1:22"}, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "full", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.CreateSpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateSpectrumApplication_OriginDNS(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "5683dc9a12ba4dc6bceaca011bcafcf5", + "protocol": "tcp/22", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_dns": { + "name" : "spectrum.origin.example.com" + }, + "origin_port": 2022, + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "full", + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps", handler) + + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "5683dc9a12ba4dc6bceaca011bcafcf5", + Protocol: "tcp/22", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Type: "CNAME", + Name: "spectrum.example.com", + }, + OriginDNS: &SpectrumApplicationOriginDNS{ + Name: "spectrum.origin.example.com", + }, + OriginPort: &SpectrumApplicationOriginPort{ + Port: 2022, + }, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "full", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } + + actual, err := client.CreateSpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteSpectrumApplication(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "40d67c87c6cd4b889a4fd57805225e85" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps/f68579455bd947efb65ffa1bcf33b52c", handler) + + err := client.DeleteSpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f68579455bd947efb65ffa1bcf33b52c") + assert.NoError(t, err) +} + +func TestSpectrumApplicationProxyProtocolDeprecations(t *testing.T) { + for _, testCase := range []struct { + actualProxyProtocol bool + actualSPP bool + expectedProxyProtocol ProxyProtocol + }{ + { + actualProxyProtocol: false, + actualSPP: false, + expectedProxyProtocol: "off", + }, + { + actualProxyProtocol: true, + actualSPP: false, + expectedProxyProtocol: "v1", + }, + { + actualProxyProtocol: false, + actualSPP: true, + expectedProxyProtocol: "simple", + }, + } { + setup() + + 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.Fprintf(w, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/22", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_direct": [ + "tcp://192.0.2.1:22" + ], + "ip_firewall": true, + "proxy_protocol": %v, + "spp": %v, + "tls": "off", + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`, testCase.actualProxyProtocol, testCase.actualSPP) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps/f68579455bd947efb65ffa1bcf33b52c", handler) + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Protocol: "tcp/22", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Name: "spectrum.example.com", + Type: "CNAME", + }, + OriginDirect: []string{"tcp://192.0.2.1:22"}, + IPFirewall: true, + ProxyProtocol: testCase.expectedProxyProtocol, + TLS: "off", + } + + actual, err := client.SpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f68579455bd947efb65ffa1bcf33b52c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + teardown() + } +} + +func TestSpectrumApplicationEdgeIPs(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/22", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_direct": [ + "tcp://192.0.2.1:22" + ], + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "off", + "edge_ips": { + "type": "static", + "ips": [ + "192.0.2.1", + "2001:db8::1" + ] + }, + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps/f68579455bd947efb65ffa1bcf33b52c", handler) + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Protocol: "tcp/22", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Name: "spectrum.example.com", + Type: "CNAME", + }, + OriginDirect: []string{"tcp://192.0.2.1:22"}, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "off", + EdgeIPs: &SpectrumApplicationEdgeIPs{ + Type: SpectrumEdgeTypeStatic, + IPs: []net.IP{net.ParseIP("192.0.2.1"), net.ParseIP("2001:db8::1")}, + }, + } + + actual, err := client.SpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f68579455bd947efb65ffa1bcf33b52c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSpectrumApplicationPortRange(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "f68579455bd947efb65ffa1bcf33b52c", + "protocol": "tcp/22-23", + "ipv4": true, + "dns": { + "type": "CNAME", + "name": "spectrum.example.com" + }, + "origin_dns": { + "name": "cloudflare.com" + }, + "origin_port": "2022-2023", + "ip_firewall": true, + "proxy_protocol": "off", + "tls": "off", + "edge_ips": { + "type": "static", + "ips": [ + "192.0.2.1", + "2001:db8::1" + ] + }, + "created_on": "2018-03-28T21:25:55.643771Z", + "modified_on": "2018-03-28T21:25:55.643771Z" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/spectrum/apps/f68579455bd947efb65ffa1bcf33b52c", handler) + createdOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2018-03-28T21:25:55.643771Z") + want := SpectrumApplication{ + ID: "f68579455bd947efb65ffa1bcf33b52c", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + Protocol: "tcp/22-23", + IPv4: true, + DNS: SpectrumApplicationDNS{ + Name: "spectrum.example.com", + Type: "CNAME", + }, + OriginDNS: &SpectrumApplicationOriginDNS{ + Name: "cloudflare.com", + }, + OriginPort: &SpectrumApplicationOriginPort{ + Start: 2022, + End: 2023, + }, + IPFirewall: true, + ProxyProtocol: "off", + TLS: "off", + EdgeIPs: &SpectrumApplicationEdgeIPs{ + Type: SpectrumEdgeTypeStatic, + IPs: []net.IP{net.ParseIP("192.0.2.1"), net.ParseIP("2001:db8::1")}, + }, + } + + actual, err := client.SpectrumApplication(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f68579455bd947efb65ffa1bcf33b52c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/split_tunnel.go b/pkg/cloudflare-go/split_tunnel.go new file mode 100644 index 000000000..3124aa74c --- /dev/null +++ b/pkg/cloudflare-go/split_tunnel.go @@ -0,0 +1,107 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// SplitTunnelResponse represents the response from the get split +// tunnel endpoints. +type SplitTunnelResponse struct { + Response + Result []SplitTunnel `json:"result"` +} + +// SplitTunnel represents the individual tunnel struct. +type SplitTunnel struct { + Address string `json:"address,omitempty"` + Host string `json:"host,omitempty"` + Description string `json:"description,omitempty"` +} + +// ListSplitTunnel returns all include or exclude split tunnel within an account. +// +// API reference for include: https://api.cloudflare.com/#device-policy-get-split-tunnel-include-list +// API reference for exclude: https://api.cloudflare.com/#device-policy-get-split-tunnel-exclude-list +func (api *API) ListSplitTunnels(ctx context.Context, accountID string, mode string) ([]SplitTunnel, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s", AccountRouteRoot, accountID, mode) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []SplitTunnel{}, err + } + + var splitTunnelResponse SplitTunnelResponse + err = json.Unmarshal(res, &splitTunnelResponse) + if err != nil { + return []SplitTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return splitTunnelResponse.Result, nil +} + +// UpdateSplitTunnel updates the existing split tunnel policy. +// +// API reference for include: https://api.cloudflare.com/#device-policy-set-split-tunnel-include-list +// API reference for exclude: https://api.cloudflare.com/#device-policy-set-split-tunnel-exclude-list +func (api *API) UpdateSplitTunnel(ctx context.Context, accountID string, mode string, tunnels []SplitTunnel) ([]SplitTunnel, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s", AccountRouteRoot, accountID, mode) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, tunnels) + if err != nil { + return []SplitTunnel{}, err + } + + var splitTunnelResponse SplitTunnelResponse + err = json.Unmarshal(res, &splitTunnelResponse) + if err != nil { + return []SplitTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return splitTunnelResponse.Result, nil +} + +// ListSplitTunnelDeviceSettingsPolicy returns all include or exclude split tunnel within a device settings policy +// +// API reference for include: https://api.cloudflare.com/#device-policy-get-split-tunnel-include-list +// API reference for exclude: https://api.cloudflare.com/#device-policy-get-split-tunnel-exclude-list +func (api *API) ListSplitTunnelsDeviceSettingsPolicy(ctx context.Context, accountID, policyID string, mode string) ([]SplitTunnel, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s/%s", AccountRouteRoot, accountID, policyID, mode) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []SplitTunnel{}, err + } + + var splitTunnelResponse SplitTunnelResponse + err = json.Unmarshal(res, &splitTunnelResponse) + if err != nil { + return []SplitTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return splitTunnelResponse.Result, nil +} + +// UpdateSplitTunnelDeviceSettingsPolicy updates the existing split tunnel policy within a device settings policy +// +// API reference for include: https://api.cloudflare.com/#device-policy-set-split-tunnel-include-list +// API reference for exclude: https://api.cloudflare.com/#device-policy-set-split-tunnel-exclude-list +func (api *API) UpdateSplitTunnelDeviceSettingsPolicy(ctx context.Context, accountID, policyID string, mode string, tunnels []SplitTunnel) ([]SplitTunnel, error) { + uri := fmt.Sprintf("/%s/%s/devices/policy/%s/%s", AccountRouteRoot, accountID, policyID, mode) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, tunnels) + if err != nil { + return []SplitTunnel{}, err + } + + var splitTunnelResponse SplitTunnelResponse + err = json.Unmarshal(res, &splitTunnelResponse) + if err != nil { + return []SplitTunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return splitTunnelResponse.Result, nil +} diff --git a/pkg/cloudflare-go/split_tunnel_test.go b/pkg/cloudflare-go/split_tunnel_test.go new file mode 100644 index 000000000..63c6448fb --- /dev/null +++ b/pkg/cloudflare-go/split_tunnel_test.go @@ -0,0 +1,300 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitTunnelIncludeHost(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "host": "*.example.com", + "description": "default" + } + ] + } + `) + } + + want := []SplitTunnel{{ + Host: "*.example.com", + Description: "default", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/include", handler) + + actual, err := client.ListSplitTunnels(context.Background(), testAccountID, "include") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSplitTunnelIncludeAddress(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "address": "192.0.2.0/24", + "description": "TEST-NET-1" + } + ] + } + `) + } + + want := []SplitTunnel{{ + Address: "192.0.2.0/24", + Description: "TEST-NET-1", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/include", handler) + + actual, err := client.ListSplitTunnels(context.Background(), testAccountID, "include") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSplitTunnelInclude(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "address": "192.0.2.0/24", + "description": "TEST-NET-1" + }, + { + "address": "198.51.100.0/24", + "description": "TEST-NET-2" + }, + { + "host": "*.example.com", + "description": "example host name" + } + ] + } + `) + } + + tunnels := []SplitTunnel{ + { + Address: "192.0.2.0/24", + Description: "TEST-NET-1", + }, + { + Address: "198.51.100.0/24", + Description: "TEST-NET-2", + }, + { + Host: "*.example.com", + Description: "example host name", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/include", handler) + + actual, err := client.UpdateSplitTunnel(context.Background(), testAccountID, "include", tunnels) + + if assert.NoError(t, err) { + assert.Equal(t, tunnels, actual) + } +} + +func TestSplitTunnelExcludeHost(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "host": "*.example.com", + "description": "default" + } + ] + } + `) + } + + want := []SplitTunnel{{ + Host: "*.example.com", + Description: "default", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/exclude", handler) + + actual, err := client.ListSplitTunnels(context.Background(), testAccountID, "exclude") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestSplitTunnelExcludeAddress(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "address": "192.0.2.0/24", + "description": "TEST-NET-1" + } + ] + } + `) + } + + want := []SplitTunnel{{ + Address: "192.0.2.0/24", + Description: "TEST-NET-1", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/exclude", handler) + + actual, err := client.ListSplitTunnels(context.Background(), testAccountID, "exclude") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateSplitTunnelExclude(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "address": "192.0.2.0/24", + "description": "TEST-NET-1" + }, + { + "address": "198.51.100.0/24", + "description": "TEST-NET-2" + }, + { + "host": "*.example.com", + "description": "example host name" + } + ] + } + `) + } + + tunnels := []SplitTunnel{ + { + Address: "192.0.2.0/24", + Description: "TEST-NET-1", + }, + { + Address: "198.51.100.0/24", + Description: "TEST-NET-2", + }, + { + Host: "*.example.com", + Description: "example host name", + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/exclude", handler) + + actual, err := client.UpdateSplitTunnel(context.Background(), testAccountID, "exclude", tunnels) + + if assert.NoError(t, err) { + assert.Equal(t, tunnels, actual) + } +} + +func TestSplitTunnelsDeviceSettingsPolicy(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "host": "*.example.com", + "description": "default" + } + ] + } + `) + } + + want := []SplitTunnel{{ + Host: "*.example.com", + Description: "default", + }} + + policyID := "a842fa8a-a583-482e-9cd9-eb43362949fd" + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/policy/"+policyID+"/include", handler) + + actual, err := client.ListSplitTunnelsDeviceSettingsPolicy(context.Background(), testAccountID, policyID, "include") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/ssl.go b/pkg/cloudflare-go/ssl.go new file mode 100644 index 000000000..3d1b5cfdb --- /dev/null +++ b/pkg/cloudflare-go/ssl.go @@ -0,0 +1,173 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// ZoneCustomSSL represents custom SSL certificate metadata. +type ZoneCustomSSL struct { + ID string `json:"id"` + Hosts []string `json:"hosts"` + Issuer string `json:"issuer"` + Signature string `json:"signature"` + Status string `json:"status"` + BundleMethod string `json:"bundle_method"` + GeoRestrictions *ZoneCustomSSLGeoRestrictions `json:"geo_restrictions,omitempty"` + ZoneID string `json:"zone_id"` + UploadedOn time.Time `json:"uploaded_on"` + ModifiedOn time.Time `json:"modified_on"` + ExpiresOn time.Time `json:"expires_on"` + Priority int `json:"priority"` + KeylessServer KeylessSSL `json:"keyless_server"` +} + +// ZoneCustomSSLGeoRestrictions represents the parameter to create or update +// geographic restrictions on a custom ssl certificate. +type ZoneCustomSSLGeoRestrictions struct { + Label string `json:"label"` +} + +// zoneCustomSSLResponse represents the response from the zone SSL details endpoint. +type zoneCustomSSLResponse struct { + Response + Result ZoneCustomSSL `json:"result"` +} + +// zoneCustomSSLsResponse represents the response from the zone SSL list endpoint. +type zoneCustomSSLsResponse struct { + Response + Result []ZoneCustomSSL `json:"result"` +} + +// ZoneCustomSSLOptions represents the parameters to create or update an existing +// custom SSL configuration. +type ZoneCustomSSLOptions struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` + BundleMethod string `json:"bundle_method,omitempty"` + GeoRestrictions *ZoneCustomSSLGeoRestrictions `json:"geo_restrictions,omitempty"` + Type string `json:"type,omitempty"` +} + +// ZoneCustomSSLPriority represents a certificate's ID and priority. It is a +// subset of ZoneCustomSSL used for patch requests. +type ZoneCustomSSLPriority struct { + ID string `json:"ID"` + Priority int `json:"priority"` +} + +// CreateSSL allows you to add a custom SSL certificate to the given zone. +// +// API reference: https://api.cloudflare.com/#custom-ssl-for-a-zone-create-ssl-configuration +func (api *API) CreateSSL(ctx context.Context, zoneID string, options ZoneCustomSSLOptions) (ZoneCustomSSL, error) { + uri := fmt.Sprintf("/zones/%s/custom_certificates", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, options) + if err != nil { + return ZoneCustomSSL{}, err + } + var r zoneCustomSSLResponse + if err := json.Unmarshal(res, &r); err != nil { + return ZoneCustomSSL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListSSL lists the custom certificates for the given zone. +// +// API reference: https://api.cloudflare.com/#custom-ssl-for-a-zone-list-ssl-configurations +func (api *API) ListSSL(ctx context.Context, zoneID string) ([]ZoneCustomSSL, error) { + uri := fmt.Sprintf("/zones/%s/custom_certificates", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r zoneCustomSSLsResponse + if err := json.Unmarshal(res, &r); err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// SSLDetails returns the configuration details for a custom SSL certificate. +// +// API reference: https://api.cloudflare.com/#custom-ssl-for-a-zone-ssl-configuration-details +func (api *API) SSLDetails(ctx context.Context, zoneID, certificateID string) (ZoneCustomSSL, error) { + uri := fmt.Sprintf("/zones/%s/custom_certificates/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneCustomSSL{}, err + } + var r zoneCustomSSLResponse + if err := json.Unmarshal(res, &r); err != nil { + return ZoneCustomSSL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateSSL updates (replaces) a custom SSL certificate. +// +// API reference: https://api.cloudflare.com/#custom-ssl-for-a-zone-update-ssl-configuration +func (api *API) UpdateSSL(ctx context.Context, zoneID, certificateID string, options ZoneCustomSSLOptions) (ZoneCustomSSL, error) { + uri := fmt.Sprintf("/zones/%s/custom_certificates/%s", zoneID, certificateID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, options) + if err != nil { + return ZoneCustomSSL{}, err + } + var r zoneCustomSSLResponse + if err := json.Unmarshal(res, &r); err != nil { + return ZoneCustomSSL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ReprioritizeSSL allows you to change the priority (which is served for a given +// request) of custom SSL certificates associated with the given zone. +// +// API reference: https://api.cloudflare.com/#custom-ssl-for-a-zone-re-prioritize-ssl-certificates +func (api *API) ReprioritizeSSL(ctx context.Context, zoneID string, p []ZoneCustomSSLPriority) ([]ZoneCustomSSL, error) { + uri := fmt.Sprintf("/zones/%s/custom_certificates/prioritize", zoneID) + params := struct { + Certificates []ZoneCustomSSLPriority `json:"certificates"` + }{ + Certificates: p, + } + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return nil, err + } + var r zoneCustomSSLsResponse + if err := json.Unmarshal(res, &r); err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteSSL deletes a custom SSL certificate from the given zone. +// +// API reference: https://api.cloudflare.com/#custom-ssl-for-a-zone-delete-an-ssl-certificate +func (api *API) DeleteSSL(ctx context.Context, zoneID, certificateID string) error { + uri := fmt.Sprintf("/zones/%s/custom_certificates/%s", zoneID, certificateID) + if _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil); err != nil { + return err + } + return nil +} + +// SSLValidationRecord displays Domain Control Validation tokens. +type SSLValidationRecord struct { + CnameTarget string `json:"cname_target,omitempty"` + CnameName string `json:"cname,omitempty"` + + TxtName string `json:"txt_name,omitempty"` + TxtValue string `json:"txt_value,omitempty"` + + HTTPUrl string `json:"http_url,omitempty"` + HTTPBody string `json:"http_body,omitempty"` + + Emails []string `json:"emails,omitempty"` +} diff --git a/pkg/cloudflare-go/ssl_test.go b/pkg/cloudflare-go/ssl_test.go new file mode 100644 index 000000000..e7fb3798a --- /dev/null +++ b/pkg/cloudflare-go/ssl_test.go @@ -0,0 +1,390 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCreateSSL(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if assert.NoError(t, err) { + assert.JSONEq(t, `{"certificate":"-----BEGIN CERTIFICATE----- MIIDtTCCAp2gAwIBAgIJAM15n7fdxhRtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAlVTMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQwHhcNMTQwMzExMTkyMTU5WhcNMTQwNDEwMTkyMTU5WjBF MQswCQYDVQQGEwJVUzETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAvq3sKsHpeduJHimOK+fvQdKsI8z8A05MZyyLp2/R/GE8FjNv+hkVY1WQ LIyTNNQH7CJecE1nbTfo8Y56S7x/rhxC6/DJ8MIulapFPnorq46KU6yRxiM0MQ3N nTJHlHA2ozZta6YBBfVfhHWl1F0IfNbXCLKvGwWWMbCx43OfW6KTkbRnE6gFWKuO fSO5h2u5TaWVuSIzBvYs7Vza6m+gtYAvKAJV2nSZ+eSEFPDo29corOy8+huEOUL8 5FAw4BFPsr1TlrlGPFitduQUHGrSL7skk1ESGza0to3bOtrodKei2s9bk5MXm7lZ qI+WZJX4Zu9+mzZhc9pCVi8r/qlXuQIDAQABo4GnMIGkMB0GA1UdDgQWBBRvavf+ sWM4IwKiH9X9w1vl6nUVRDB1BgNVHSMEbjBsgBRvavf+sWM4IwKiH9X9w1vl6nUV RKFJpEcwRTELMAkGA1UEBhMCVVMxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAM15n7fdxhRtMAwGA1UdEwQF MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBABY2ZzBaW0dMsAAT7tPJzrVWVzQx6KU4 UEBLudIlWPlkAwTnINCWR/8eNjCCmGA4heUdHmazdpPa8RzwOmc0NT1NQqzSyktt vTqb4iHD7+8f9MqJ9/FssCfTtqr/Qst/hGH4Wmdf1EJ/6FqYAAb5iRlPgshFZxU8 uXtA8hWn6fK6eISD9HBdcAFToUvKNZ1BIDPvh9f95Ine8ar6yGd56TUNrHR8eHBs ESxz5ddVR/oWRysNJ+aGAyYqHS8S/ttmC7r4XCAHqXptkHPCGRqkAhsterYhd4I8 /cBzejUobNCjjHFbtkAL/SjxZOLW+pNkZwfeYdM8iPkD54Uua1v2tdw= -----END CERTIFICATE-----","geo_restrictions":{"label":"us"},"private_key":"-----BEGIN RSA PRIVATE KEY-----MIIEowIBAAKCAQEAl 1cSc0vfcJLI4ZdWjiZZqy86Eof4czCwilyjXdvHqbdgDjz9H6K/0FX78EzVdfyExESptPCDl5YYjvcZyAWlgNfYEpFpGeoh/pTFW3hlyKImh4EgBXbDrR251J Ew2Nf56X3duibI6X20gKZA6cvdmWeKh MOOXuh1bSPU3dkb4YOF/fng5iGrx0q3txdMQXTPMZ1uXHFcBH7idgViYesXUBhdll3GP1N Y8laq0yrqh 8HMsZK m27MebqonbNmjOqE218lVEvjCdRO6xvNXrO6vNJBoGn2eGwZ8BVd0mTA3Tj43/2cmxQFY9FLq56cCXqYI1fbRRib ZLrjSNkwIDAQABAoIBABfAjjsjjxc0NxcYvKOMUb9Rpj8Sx6U/o/tDC5u XmsGX37aaJmC5yw9BQiAxgvXtQryEl5uoNoqOdsxzKV6yM0vPcwKEJVBd4G6yx6AjVJZnc2qf72erR7BbA2CQh scMDRBKE041HhgTBRNP6roim0SOgYP5JZIrGAQXNIkyE0fZc5gZNUt388ne/mjWM6Xi08BDGurLC68nsdt7Nd UYqeBVxo2EqChp5vKYZYEcG8h9XBj4u4NIwg1Mty2JqX30uBjoHvF5w/pMs8lG uvj6JR9I 19wtCuccbAJl 4cUq03UQoIDmwejea oC8A8WJr3vVpODDWrvAsjllGPBECgYEAyQRa6edYO6bsSvgbM13qXW9OQTn9YmgzfN24Ux1D66TQU6sBSLdfSHshDhTCi Ax 698aJNRWujAakA2DDgspSx98aRnHbF zvY7i7iWGesN6uN0zL 6/MK5uWoieGZRjgk230fLk00l4/FK1mJIp0apr0Lis9xmDjP5AaUPTUUCgYEAwXuhTHZWPT6v8YwOksjbuK UDkIIvyMux53kb73vrkgMboS4DB1zMLNyG 9EghS414CFROUwGl4ZUKboH1Jo5G34y8VgDuHjirTqL2H6 zNpML iMrWCXjpFKkxwPbeQnEAZ 5Rud4d PTyXAt71blZHE9tZ4KHy8cU1iKc9APcCgYAIqKZd4vg7AZK2G//X85iv06aUSrIudfyZyVcyRVVyphPPNtOEVVnGXn9rAtvqeIrOo52BR68 cj4vlXp hkDuEH QVBuY/NdQhOzFtPrKPQTJdGjIlQ2x65Vidj7r3sRukNkLPyV2v D885zcpTkp83JFuWTYiIrg275DIuAI3QKBgAglM0IrzS g3vlVQxvM1ussgRgkkYeybHq82 wUW 3DXLqeXb0s1DedplUkuoabZriz0Wh4GZFSmtA5ZpZC uV697lkYsndmp2xRhaekllW7bu pY5q88URwO2p8CO5AZ6CWFWuBwSDML5VOapGRqDRgwaD oGpb7fb7IgHOls7AoGBAJnL6Q8t35uYJ8J8hY7wso88IE04z6VaT8WganxcndesWER9eFQDHDDy//ZYeyt6M41uIY CL Vkm9Kwl/bHLJKdnOE1a9NdE6mtfah0Bk2u/YOuzyu5mmcgZiX X/OZuEbGmmbZOR1FCuIyrNYfwYohhcZP7/r0Ia/1GpkHc3Bi-----END RSA PRIVATE KEY-----","bundle_method":"ubiquitous"}`, string(b)) + } + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "7e7b8deba8538af625850b7b2530034c", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "geo_restrictions": { + "label": "us" + }, + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/custom_certificates", handler) + + hosts := make([]string, 1, 4) + hosts[0] = "example.com" + uploadedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2016-01-01T05:20:00Z") + want := ZoneCustomSSL{ + ID: "7e7b8deba8538af625850b7b2530034c", + Hosts: hosts, + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + Status: "active", + BundleMethod: "ubiquitous", + GeoRestrictions: &ZoneCustomSSLGeoRestrictions{Label: "us"}, + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + UploadedOn: uploadedOn, + ModifiedOn: modifiedOn, + ExpiresOn: expiresOn, + Priority: 1, + } + + actual, err := client.CreateSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", ZoneCustomSSLOptions{ + Certificate: "-----BEGIN CERTIFICATE----- MIIDtTCCAp2gAwIBAgIJAM15n7fdxhRtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAlVTMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQwHhcNMTQwMzExMTkyMTU5WhcNMTQwNDEwMTkyMTU5WjBF MQswCQYDVQQGEwJVUzETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAvq3sKsHpeduJHimOK+fvQdKsI8z8A05MZyyLp2/R/GE8FjNv+hkVY1WQ LIyTNNQH7CJecE1nbTfo8Y56S7x/rhxC6/DJ8MIulapFPnorq46KU6yRxiM0MQ3N nTJHlHA2ozZta6YBBfVfhHWl1F0IfNbXCLKvGwWWMbCx43OfW6KTkbRnE6gFWKuO fSO5h2u5TaWVuSIzBvYs7Vza6m+gtYAvKAJV2nSZ+eSEFPDo29corOy8+huEOUL8 5FAw4BFPsr1TlrlGPFitduQUHGrSL7skk1ESGza0to3bOtrodKei2s9bk5MXm7lZ qI+WZJX4Zu9+mzZhc9pCVi8r/qlXuQIDAQABo4GnMIGkMB0GA1UdDgQWBBRvavf+ sWM4IwKiH9X9w1vl6nUVRDB1BgNVHSMEbjBsgBRvavf+sWM4IwKiH9X9w1vl6nUV RKFJpEcwRTELMAkGA1UEBhMCVVMxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAM15n7fdxhRtMAwGA1UdEwQF MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBABY2ZzBaW0dMsAAT7tPJzrVWVzQx6KU4 UEBLudIlWPlkAwTnINCWR/8eNjCCmGA4heUdHmazdpPa8RzwOmc0NT1NQqzSyktt vTqb4iHD7+8f9MqJ9/FssCfTtqr/Qst/hGH4Wmdf1EJ/6FqYAAb5iRlPgshFZxU8 uXtA8hWn6fK6eISD9HBdcAFToUvKNZ1BIDPvh9f95Ine8ar6yGd56TUNrHR8eHBs ESxz5ddVR/oWRysNJ+aGAyYqHS8S/ttmC7r4XCAHqXptkHPCGRqkAhsterYhd4I8 /cBzejUobNCjjHFbtkAL/SjxZOLW+pNkZwfeYdM8iPkD54Uua1v2tdw= -----END CERTIFICATE-----", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----MIIEowIBAAKCAQEAl 1cSc0vfcJLI4ZdWjiZZqy86Eof4czCwilyjXdvHqbdgDjz9H6K/0FX78EzVdfyExESptPCDl5YYjvcZyAWlgNfYEpFpGeoh/pTFW3hlyKImh4EgBXbDrR251J Ew2Nf56X3duibI6X20gKZA6cvdmWeKh MOOXuh1bSPU3dkb4YOF/fng5iGrx0q3txdMQXTPMZ1uXHFcBH7idgViYesXUBhdll3GP1N Y8laq0yrqh 8HMsZK m27MebqonbNmjOqE218lVEvjCdRO6xvNXrO6vNJBoGn2eGwZ8BVd0mTA3Tj43/2cmxQFY9FLq56cCXqYI1fbRRib ZLrjSNkwIDAQABAoIBABfAjjsjjxc0NxcYvKOMUb9Rpj8Sx6U/o/tDC5u XmsGX37aaJmC5yw9BQiAxgvXtQryEl5uoNoqOdsxzKV6yM0vPcwKEJVBd4G6yx6AjVJZnc2qf72erR7BbA2CQh scMDRBKE041HhgTBRNP6roim0SOgYP5JZIrGAQXNIkyE0fZc5gZNUt388ne/mjWM6Xi08BDGurLC68nsdt7Nd UYqeBVxo2EqChp5vKYZYEcG8h9XBj4u4NIwg1Mty2JqX30uBjoHvF5w/pMs8lG uvj6JR9I 19wtCuccbAJl 4cUq03UQoIDmwejea oC8A8WJr3vVpODDWrvAsjllGPBECgYEAyQRa6edYO6bsSvgbM13qXW9OQTn9YmgzfN24Ux1D66TQU6sBSLdfSHshDhTCi Ax 698aJNRWujAakA2DDgspSx98aRnHbF zvY7i7iWGesN6uN0zL 6/MK5uWoieGZRjgk230fLk00l4/FK1mJIp0apr0Lis9xmDjP5AaUPTUUCgYEAwXuhTHZWPT6v8YwOksjbuK UDkIIvyMux53kb73vrkgMboS4DB1zMLNyG 9EghS414CFROUwGl4ZUKboH1Jo5G34y8VgDuHjirTqL2H6 zNpML iMrWCXjpFKkxwPbeQnEAZ 5Rud4d PTyXAt71blZHE9tZ4KHy8cU1iKc9APcCgYAIqKZd4vg7AZK2G//X85iv06aUSrIudfyZyVcyRVVyphPPNtOEVVnGXn9rAtvqeIrOo52BR68 cj4vlXp hkDuEH QVBuY/NdQhOzFtPrKPQTJdGjIlQ2x65Vidj7r3sRukNkLPyV2v D885zcpTkp83JFuWTYiIrg275DIuAI3QKBgAglM0IrzS g3vlVQxvM1ussgRgkkYeybHq82 wUW 3DXLqeXb0s1DedplUkuoabZriz0Wh4GZFSmtA5ZpZC uV697lkYsndmp2xRhaekllW7bu pY5q88URwO2p8CO5AZ6CWFWuBwSDML5VOapGRqDRgwaD oGpb7fb7IgHOls7AoGBAJnL6Q8t35uYJ8J8hY7wso88IE04z6VaT8WganxcndesWER9eFQDHDDy//ZYeyt6M41uIY CL Vkm9Kwl/bHLJKdnOE1a9NdE6mtfah0Bk2u/YOuzyu5mmcgZiX X/OZuEbGmmbZOR1FCuIyrNYfwYohhcZP7/r0Ia/1GpkHc3Bi-----END RSA PRIVATE KEY-----", + BundleMethod: "ubiquitous", + GeoRestrictions: &ZoneCustomSSLGeoRestrictions{Label: "us"}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.CreateSSL(context.Background(), "bar", ZoneCustomSSLOptions{}) + assert.Error(t, err) +} + +func TestListSSL(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "7e7b8deba8538af625850b7b2530034c", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/custom_certificates", handler) + + hosts := make([]string, 1, 4) + hosts[0] = "example.com" + uploadedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2016-01-01T05:20:00Z") + + want := make([]ZoneCustomSSL, 1, 4) + want[0] = ZoneCustomSSL{ + ID: "7e7b8deba8538af625850b7b2530034c", + Hosts: hosts, + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + Status: "active", + BundleMethod: "ubiquitous", + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + UploadedOn: uploadedOn, + ModifiedOn: modifiedOn, + ExpiresOn: expiresOn, + Priority: 1, + } + + actual, err := client.ListSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.ListSSL(context.Background(), "bar") + assert.Error(t, err) +} + +func TestSSLDetails(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "7e7b8deba8538af625850b7b2530034c", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/custom_certificates/7e7b8deba8538af625850b7b2530034c", handler) + + hosts := make([]string, 1, 4) + hosts[0] = "example.com" + uploadedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2016-01-01T05:20:00Z") + want := ZoneCustomSSL{ + ID: "7e7b8deba8538af625850b7b2530034c", + Hosts: hosts, + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + Status: "active", + BundleMethod: "ubiquitous", + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + UploadedOn: uploadedOn, + ModifiedOn: modifiedOn, + ExpiresOn: expiresOn, + Priority: 1, + } + + actual, err := client.SSLDetails(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "7e7b8deba8538af625850b7b2530034c") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.SSLDetails(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "bar") + assert.Error(t, err) +} + +func TestUpdateSSL(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{"certificate":"-----BEGIN CERTIFICATE----- MIIDtTCCAp2gAwIBAgIJAM15n7fdxhRtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAlVTMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQwHhcNMTQwMzExMTkyMTU5WhcNMTQwNDEwMTkyMTU5WjBF MQswCQYDVQQGEwJVUzETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAvq3sKsHpeduJHimOK+fvQdKsI8z8A05MZyyLp2/R/GE8FjNv+hkVY1WQ LIyTNNQH7CJecE1nbTfo8Y56S7x/rhxC6/DJ8MIulapFPnorq46KU6yRxiM0MQ3N nTJHlHA2ozZta6YBBfVfhHWl1F0IfNbXCLKvGwWWMbCx43OfW6KTkbRnE6gFWKuO fSO5h2u5TaWVuSIzBvYs7Vza6m+gtYAvKAJV2nSZ+eSEFPDo29corOy8+huEOUL8 5FAw4BFPsr1TlrlGPFitduQUHGrSL7skk1ESGza0to3bOtrodKei2s9bk5MXm7lZ qI+WZJX4Zu9+mzZhc9pCVi8r/qlXuQIDAQABo4GnMIGkMB0GA1UdDgQWBBRvavf+ sWM4IwKiH9X9w1vl6nUVRDB1BgNVHSMEbjBsgBRvavf+sWM4IwKiH9X9w1vl6nUV RKFJpEcwRTELMAkGA1UEBhMCVVMxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAM15n7fdxhRtMAwGA1UdEwQF MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBABY2ZzBaW0dMsAAT7tPJzrVWVzQx6KU4 UEBLudIlWPlkAwTnINCWR/8eNjCCmGA4heUdHmazdpPa8RzwOmc0NT1NQqzSyktt vTqb4iHD7+8f9MqJ9/FssCfTtqr/Qst/hGH4Wmdf1EJ/6FqYAAb5iRlPgshFZxU8 uXtA8hWn6fK6eISD9HBdcAFToUvKNZ1BIDPvh9f95Ine8ar6yGd56TUNrHR8eHBs ESxz5ddVR/oWRysNJ+aGAyYqHS8S/ttmC7r4XCAHqXptkHPCGRqkAhsterYhd4I8 /cBzejUobNCjjHFbtkAL/SjxZOLW+pNkZwfeYdM8iPkD54Uua1v2tdw= -----END CERTIFICATE-----","geo_restrictions":{"label":"us"},"private_key":"-----BEGIN RSA PRIVATE KEY-----MIIEowIBAAKCAQEAl 1cSc0vfcJLI4ZdWjiZZqy86Eof4czCwilyjXdvHqbdgDjz9H6K/0FX78EzVdfyExESptPCDl5YYjvcZyAWlgNfYEpFpGeoh/pTFW3hlyKImh4EgBXbDrR251J Ew2Nf56X3duibI6X20gKZA6cvdmWeKh MOOXuh1bSPU3dkb4YOF/fng5iGrx0q3txdMQXTPMZ1uXHFcBH7idgViYesXUBhdll3GP1N Y8laq0yrqh 8HMsZK m27MebqonbNmjOqE218lVEvjCdRO6xvNXrO6vNJBoGn2eGwZ8BVd0mTA3Tj43/2cmxQFY9FLq56cCXqYI1fbRRib ZLrjSNkwIDAQABAoIBABfAjjsjjxc0NxcYvKOMUb9Rpj8Sx6U/o/tDC5u XmsGX37aaJmC5yw9BQiAxgvXtQryEl5uoNoqOdsxzKV6yM0vPcwKEJVBd4G6yx6AjVJZnc2qf72erR7BbA2CQh scMDRBKE041HhgTBRNP6roim0SOgYP5JZIrGAQXNIkyE0fZc5gZNUt388ne/mjWM6Xi08BDGurLC68nsdt7Nd UYqeBVxo2EqChp5vKYZYEcG8h9XBj4u4NIwg1Mty2JqX30uBjoHvF5w/pMs8lG uvj6JR9I 19wtCuccbAJl 4cUq03UQoIDmwejea oC8A8WJr3vVpODDWrvAsjllGPBECgYEAyQRa6edYO6bsSvgbM13qXW9OQTn9YmgzfN24Ux1D66TQU6sBSLdfSHshDhTCi Ax 698aJNRWujAakA2DDgspSx98aRnHbF zvY7i7iWGesN6uN0zL 6/MK5uWoieGZRjgk230fLk00l4/FK1mJIp0apr0Lis9xmDjP5AaUPTUUCgYEAwXuhTHZWPT6v8YwOksjbuK UDkIIvyMux53kb73vrkgMboS4DB1zMLNyG 9EghS414CFROUwGl4ZUKboH1Jo5G34y8VgDuHjirTqL2H6 zNpML iMrWCXjpFKkxwPbeQnEAZ 5Rud4d PTyXAt71blZHE9tZ4KHy8cU1iKc9APcCgYAIqKZd4vg7AZK2G//X85iv06aUSrIudfyZyVcyRVVyphPPNtOEVVnGXn9rAtvqeIrOo52BR68 cj4vlXp hkDuEH QVBuY/NdQhOzFtPrKPQTJdGjIlQ2x65Vidj7r3sRukNkLPyV2v D885zcpTkp83JFuWTYiIrg275DIuAI3QKBgAglM0IrzS g3vlVQxvM1ussgRgkkYeybHq82 wUW 3DXLqeXb0s1DedplUkuoabZriz0Wh4GZFSmtA5ZpZC uV697lkYsndmp2xRhaekllW7bu pY5q88URwO2p8CO5AZ6CWFWuBwSDML5VOapGRqDRgwaD oGpb7fb7IgHOls7AoGBAJnL6Q8t35uYJ8J8hY7wso88IE04z6VaT8WganxcndesWER9eFQDHDDy//ZYeyt6M41uIY CL Vkm9Kwl/bHLJKdnOE1a9NdE6mtfah0Bk2u/YOuzyu5mmcgZiX X/OZuEbGmmbZOR1FCuIyrNYfwYohhcZP7/r0Ia/1GpkHc3Bi-----END RSA PRIVATE KEY-----","bundle_method":"ubiquitous"}`, string(b)) + } + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "7e7b8deba8538af625850b7b2530034c", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "geo_restrictions": { + "label": "us" + }, + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/custom_certificates/7e7b8deba8538af625850b7b2530034c", handler) + + hosts := make([]string, 1, 4) + hosts[0] = "example.com" + uploadedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2016-01-01T05:20:00Z") + want := ZoneCustomSSL{ + ID: "7e7b8deba8538af625850b7b2530034c", + Hosts: hosts, + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + Status: "active", + BundleMethod: "ubiquitous", + GeoRestrictions: &ZoneCustomSSLGeoRestrictions{Label: "us"}, + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + UploadedOn: uploadedOn, + ModifiedOn: modifiedOn, + ExpiresOn: expiresOn, + Priority: 1, + } + + actual, err := client.UpdateSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "7e7b8deba8538af625850b7b2530034c", ZoneCustomSSLOptions{ + Certificate: "-----BEGIN CERTIFICATE----- MIIDtTCCAp2gAwIBAgIJAM15n7fdxhRtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAlVTMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQwHhcNMTQwMzExMTkyMTU5WhcNMTQwNDEwMTkyMTU5WjBF MQswCQYDVQQGEwJVUzETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAvq3sKsHpeduJHimOK+fvQdKsI8z8A05MZyyLp2/R/GE8FjNv+hkVY1WQ LIyTNNQH7CJecE1nbTfo8Y56S7x/rhxC6/DJ8MIulapFPnorq46KU6yRxiM0MQ3N nTJHlHA2ozZta6YBBfVfhHWl1F0IfNbXCLKvGwWWMbCx43OfW6KTkbRnE6gFWKuO fSO5h2u5TaWVuSIzBvYs7Vza6m+gtYAvKAJV2nSZ+eSEFPDo29corOy8+huEOUL8 5FAw4BFPsr1TlrlGPFitduQUHGrSL7skk1ESGza0to3bOtrodKei2s9bk5MXm7lZ qI+WZJX4Zu9+mzZhc9pCVi8r/qlXuQIDAQABo4GnMIGkMB0GA1UdDgQWBBRvavf+ sWM4IwKiH9X9w1vl6nUVRDB1BgNVHSMEbjBsgBRvavf+sWM4IwKiH9X9w1vl6nUV RKFJpEcwRTELMAkGA1UEBhMCVVMxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAM15n7fdxhRtMAwGA1UdEwQF MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBABY2ZzBaW0dMsAAT7tPJzrVWVzQx6KU4 UEBLudIlWPlkAwTnINCWR/8eNjCCmGA4heUdHmazdpPa8RzwOmc0NT1NQqzSyktt vTqb4iHD7+8f9MqJ9/FssCfTtqr/Qst/hGH4Wmdf1EJ/6FqYAAb5iRlPgshFZxU8 uXtA8hWn6fK6eISD9HBdcAFToUvKNZ1BIDPvh9f95Ine8ar6yGd56TUNrHR8eHBs ESxz5ddVR/oWRysNJ+aGAyYqHS8S/ttmC7r4XCAHqXptkHPCGRqkAhsterYhd4I8 /cBzejUobNCjjHFbtkAL/SjxZOLW+pNkZwfeYdM8iPkD54Uua1v2tdw= -----END CERTIFICATE-----", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----MIIEowIBAAKCAQEAl 1cSc0vfcJLI4ZdWjiZZqy86Eof4czCwilyjXdvHqbdgDjz9H6K/0FX78EzVdfyExESptPCDl5YYjvcZyAWlgNfYEpFpGeoh/pTFW3hlyKImh4EgBXbDrR251J Ew2Nf56X3duibI6X20gKZA6cvdmWeKh MOOXuh1bSPU3dkb4YOF/fng5iGrx0q3txdMQXTPMZ1uXHFcBH7idgViYesXUBhdll3GP1N Y8laq0yrqh 8HMsZK m27MebqonbNmjOqE218lVEvjCdRO6xvNXrO6vNJBoGn2eGwZ8BVd0mTA3Tj43/2cmxQFY9FLq56cCXqYI1fbRRib ZLrjSNkwIDAQABAoIBABfAjjsjjxc0NxcYvKOMUb9Rpj8Sx6U/o/tDC5u XmsGX37aaJmC5yw9BQiAxgvXtQryEl5uoNoqOdsxzKV6yM0vPcwKEJVBd4G6yx6AjVJZnc2qf72erR7BbA2CQh scMDRBKE041HhgTBRNP6roim0SOgYP5JZIrGAQXNIkyE0fZc5gZNUt388ne/mjWM6Xi08BDGurLC68nsdt7Nd UYqeBVxo2EqChp5vKYZYEcG8h9XBj4u4NIwg1Mty2JqX30uBjoHvF5w/pMs8lG uvj6JR9I 19wtCuccbAJl 4cUq03UQoIDmwejea oC8A8WJr3vVpODDWrvAsjllGPBECgYEAyQRa6edYO6bsSvgbM13qXW9OQTn9YmgzfN24Ux1D66TQU6sBSLdfSHshDhTCi Ax 698aJNRWujAakA2DDgspSx98aRnHbF zvY7i7iWGesN6uN0zL 6/MK5uWoieGZRjgk230fLk00l4/FK1mJIp0apr0Lis9xmDjP5AaUPTUUCgYEAwXuhTHZWPT6v8YwOksjbuK UDkIIvyMux53kb73vrkgMboS4DB1zMLNyG 9EghS414CFROUwGl4ZUKboH1Jo5G34y8VgDuHjirTqL2H6 zNpML iMrWCXjpFKkxwPbeQnEAZ 5Rud4d PTyXAt71blZHE9tZ4KHy8cU1iKc9APcCgYAIqKZd4vg7AZK2G//X85iv06aUSrIudfyZyVcyRVVyphPPNtOEVVnGXn9rAtvqeIrOo52BR68 cj4vlXp hkDuEH QVBuY/NdQhOzFtPrKPQTJdGjIlQ2x65Vidj7r3sRukNkLPyV2v D885zcpTkp83JFuWTYiIrg275DIuAI3QKBgAglM0IrzS g3vlVQxvM1ussgRgkkYeybHq82 wUW 3DXLqeXb0s1DedplUkuoabZriz0Wh4GZFSmtA5ZpZC uV697lkYsndmp2xRhaekllW7bu pY5q88URwO2p8CO5AZ6CWFWuBwSDML5VOapGRqDRgwaD oGpb7fb7IgHOls7AoGBAJnL6Q8t35uYJ8J8hY7wso88IE04z6VaT8WganxcndesWER9eFQDHDDy//ZYeyt6M41uIY CL Vkm9Kwl/bHLJKdnOE1a9NdE6mtfah0Bk2u/YOuzyu5mmcgZiX X/OZuEbGmmbZOR1FCuIyrNYfwYohhcZP7/r0Ia/1GpkHc3Bi-----END RSA PRIVATE KEY-----", + BundleMethod: "ubiquitous", + GeoRestrictions: &ZoneCustomSSLGeoRestrictions{Label: "us"}, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } + + _, err = client.UpdateSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "bar", ZoneCustomSSLOptions{}) + assert.Error(t, err) +} + +func TestReprioritizeSSL(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{"certificates":[{"ID":"5a7805061c76ada191ed06f989cc3dac","priority":2},{"ID":"9a7806061c88ada191ed06f989cc3dac","priority":1}]}`, string(b)) + } + + w.Header().Set("content-type", "application/json") + // XXX: Test response flow properly. + // Current response assertion uses generic example from the documentation, + // rather than responding to the actual PUT request. + // https://api.cloudflare.com/#custom-ssl-for-a-zone-re-prioritize-ssl-certificates + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "7e7b8deba8538af625850b7b2530034c", + "hosts": [ + "example.com" + ], + "issuer": "GlobalSign", + "signature": "SHA256WithRSA", + "status": "active", + "bundle_method": "ubiquitous", + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "uploaded_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "expires_on": "2016-01-01T05:20:00Z", + "priority": 1 + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/custom_certificates/prioritize", handler) + + hosts := make([]string, 1, 4) + hosts[0] = "example.com" + uploadedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + expiresOn, _ := time.Parse(time.RFC3339, "2016-01-01T05:20:00Z") + + want := make([]ZoneCustomSSL, 1, 4) + want[0] = ZoneCustomSSL{ + ID: "7e7b8deba8538af625850b7b2530034c", + Hosts: hosts, + Issuer: "GlobalSign", + Signature: "SHA256WithRSA", + Status: "active", + BundleMethod: "ubiquitous", + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + UploadedOn: uploadedOn, + ModifiedOn: modifiedOn, + ExpiresOn: expiresOn, + Priority: 1, + } + + actual, err := client.ReprioritizeSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", []ZoneCustomSSLPriority{ + {ID: "5a7805061c76ada191ed06f989cc3dac", Priority: 2}, + {ID: "9a7806061c88ada191ed06f989cc3dac", Priority: 1}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteSSL(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "id": "7e7b8deba8538af625850b7b2530034c" + }`) + } + + mux.HandleFunc("/zones/023e105f4ecef8ad9ca31a8372d0c353/custom_certificates/7e7b8deba8538af625850b7b2530034c", handler) + + err := client.DeleteSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "7e7b8deba8538af625850b7b2530034c") + assert.NoError(t, err, "Expected to successfully delete certificate ID '7e7b8deba8538af625850b7b2530034c', received error instead") + + err = client.DeleteSSL(context.Background(), "023e105f4ecef8ad9ca31a8372d0c353", "bar") + assert.Error(t, err, "Expected to error when attempting to delete certificate ID 'bar', did not receive error instead") +} diff --git a/pkg/cloudflare-go/stack_rulesest.go b/pkg/cloudflare-go/stack_rulesest.go new file mode 100644 index 000000000..cf91e15fd --- /dev/null +++ b/pkg/cloudflare-go/stack_rulesest.go @@ -0,0 +1,29 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// DeleteRulesetRule removes a ruleset rule based on the ruleset ID + +// ruleset rule ID. +// +// API reference: https://developers.cloudflare.com/api/operations/deleteZoneRulesetRule +func (api *API) DeleteRulesetRule(ctx context.Context, rc *ResourceContainer, rulesetID, rulesetRuleID string) error { + uri := fmt.Sprintf("/%s/%s/rulesets/%s/rules/%s", rc.Level, rc.Identifier, rulesetID, rulesetRuleID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + // The API is not implementing the standard response blob but returns an + // empty response (204) in case of a success. So we are checking for the + // response body size here. + if len(res) > 0 { + return fmt.Errorf(errMakeRequestError+": %w", errors.New(string(res))) + } + + return nil +} diff --git a/pkg/cloudflare-go/stream.go b/pkg/cloudflare-go/stream.go new file mode 100644 index 000000000..388064996 --- /dev/null +++ b/pkg/cloudflare-go/stream.go @@ -0,0 +1,574 @@ +package cloudflare + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/goccy/go-json" +) + +var ( + // ErrMissingUploadURL is for when a URL is required but missing. + ErrMissingUploadURL = errors.New("required url missing") + // ErrMissingMaxDuration is for when MaxDuration is required but missing. + ErrMissingMaxDuration = errors.New("required max duration missing") + // ErrMissingVideoID is for when VideoID is required but missing. + ErrMissingVideoID = errors.New("required video id missing") + // ErrMissingFilePath is for when FilePath is required but missing. + ErrMissingFilePath = errors.New("required file path missing") + // ErrMissingTusResumable is for when TusResumable is required but missing. + ErrMissingTusResumable = errors.New("required tus resumable missing") + // ErrInvalidTusResumable is for when TusResumable is invalid. + ErrInvalidTusResumable = errors.New("invalid tus resumable") + // ErrMarshallingTUSMetadata is for when TUS metadata cannot be marshalled. + ErrMarshallingTUSMetadata = errors.New("error marshalling TUS metadata") + // ErrMissingUploadLength is for when UploadLength is required but missing. + ErrMissingUploadLength = errors.New("required upload length missing") + // ErrInvalidStatusCode is for when the status code is invalid. + ErrInvalidStatusCode = errors.New("invalid status code") +) + +type TusProtocolVersion string + +const ( + TusProtocolVersion1_0_0 TusProtocolVersion = "1.0.0" +) + +// StreamVideo represents a stream video. +type StreamVideo struct { + AllowedOrigins []string `json:"allowedOrigins,omitempty"` + Created *time.Time `json:"created,omitempty"` + Duration float64 `json:"duration,omitempty"` + Input StreamVideoInput `json:"input,omitempty"` + MaxDurationSeconds int `json:"maxDurationSeconds,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` + Modified *time.Time `json:"modified,omitempty"` + UploadExpiry *time.Time `json:"uploadExpiry,omitempty"` + Playback StreamVideoPlayback `json:"playback,omitempty"` + Preview string `json:"preview,omitempty"` + ReadyToStream bool `json:"readyToStream,omitempty"` + RequireSignedURLs bool `json:"requireSignedURLs,omitempty"` + Size int `json:"size,omitempty"` + Status StreamVideoStatus `json:"status,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + ThumbnailTimestampPct float64 `json:"thumbnailTimestampPct,omitempty"` + UID string `json:"uid,omitempty"` + Creator string `json:"creator,omitempty"` + LiveInput string `json:"liveInput,omitempty"` + Uploaded *time.Time `json:"uploaded,omitempty"` + ScheduledDeletion *time.Time `json:"scheduledDeletion,omitempty"` + Watermark StreamVideoWatermark `json:"watermark,omitempty"` + NFT StreamVideoNFTParameters `json:"nft,omitempty"` +} + +// StreamVideoInput represents the video input values of a stream video. +type StreamVideoInput struct { + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +// StreamVideoPlayback represents the playback URLs for a video. +type StreamVideoPlayback struct { + HLS string `json:"hls,omitempty"` + Dash string `json:"dash,omitempty"` +} + +// StreamVideoStatus represents the status of a stream video. +type StreamVideoStatus struct { + State string `json:"state,omitempty"` + PctComplete string `json:"pctComplete,omitempty"` + ErrorReasonCode string `json:"errorReasonCode,omitempty"` + ErrorReasonText string `json:"errorReasonText,omitempty"` +} + +// StreamVideoWatermark represents a watermark for a stream video. +type StreamVideoWatermark struct { + UID string `json:"uid,omitempty"` + Size int `json:"size,omitempty"` + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` + Created *time.Time `json:"created,omitempty"` + DownloadedFrom string `json:"downloadedFrom,omitempty"` + Name string `json:"name,omitempty"` + Opacity float64 `json:"opacity,omitempty"` + Padding float64 `json:"padding,omitempty"` + Scale float64 `json:"scale,omitempty"` + Position string `json:"position,omitempty"` +} + +// StreamVideoNFTParameters represents a NFT for a stream video. +type StreamVideoNFTParameters struct { + AccountID string + VideoID string + Contract string `json:"contract,omitempty"` + Token int `json:"token,omitempty"` +} + +// StreamUploadFromURLParameters are the parameters used when uploading a video from URL. +type StreamUploadFromURLParameters struct { + AccountID string + VideoID string + URL string `json:"url"` + Creator string `json:"creator,omitempty"` + ThumbnailTimestampPct float64 `json:"thumbnailTimestampPct,omitempty"` + AllowedOrigins []string `json:"allowedOrigins,omitempty"` + RequireSignedURLs bool `json:"requireSignedURLs,omitempty"` + Watermark UploadVideoURLWatermark `json:"watermark,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` + ScheduledDeletion *time.Time `json:"scheduledDeletion,omitempty"` +} + +// StreamCreateVideoParameters are parameters used when creating a video. +type StreamCreateVideoParameters struct { + AccountID string + MaxDurationSeconds int `json:"maxDurationSeconds,omitempty"` + Expiry *time.Time `json:"expiry,omitempty"` + Creator string `json:"creator,omitempty"` + ThumbnailTimestampPct float64 `json:"thumbnailTimestampPct,omitempty"` + AllowedOrigins []string `json:"allowedOrigins,omitempty"` + RequireSignedURLs bool `json:"requireSignedURLs,omitempty"` + Watermark UploadVideoURLWatermark `json:"watermark,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` + ScheduledDeletion *time.Time `json:"scheduledDeletion,omitempty"` +} + +// UploadVideoURLWatermark represents UID of an existing watermark. +type UploadVideoURLWatermark struct { + UID string `json:"uid,omitempty"` +} + +// StreamVideoCreate represents parameters returned after creating a video. +type StreamVideoCreate struct { + UploadURL string `json:"uploadURL,omitempty"` + UID string `json:"uid,omitempty"` + Watermark StreamVideoWatermark `json:"watermark,omitempty"` + ScheduledDeletion *time.Time `json:"scheduledDeletion,omitempty"` +} + +// StreamParameters are the basic parameters needed. +type StreamParameters struct { + AccountID string + VideoID string +} + +// StreamUploadFileParameters are parameters needed for file upload of a video. +type StreamUploadFileParameters struct { + AccountID string + VideoID string + FilePath string + ScheduledDeletion *time.Time +} + +// StreamListParameters represents parameters used when listing stream videos. +type StreamListParameters struct { + AccountID string + VideoID string + After *time.Time `url:"after,omitempty"` + Before *time.Time `url:"before,omitempty"` + Creator string `url:"creator,omitempty"` + IncludeCounts bool `url:"include_counts,omitempty"` + Search string `url:"search,omitempty"` + Limit int `url:"limit,omitempty"` + Asc bool `url:"asc,omitempty"` + Status string `url:"status,omitempty"` +} + +// StreamSignedURLParameters represent parameters used when creating a signed URL. +type StreamSignedURLParameters struct { + AccountID string + VideoID string + ID string `json:"id,omitempty"` + PEM string `json:"pem,omitempty"` + EXP int `json:"exp,omitempty"` + NBF int `json:"nbf,omitempty"` + Downloadable bool `json:"downloadable,omitempty"` + AccessRules []StreamAccessRule `json:"accessRules,omitempty"` +} + +type StreamInitiateTUSUploadParameters struct { + DirectUserUpload bool `url:"direct_user,omitempty"` + TusResumable TusProtocolVersion `url:"-"` + UploadLength int64 `url:"-"` + UploadCreator string `url:"-"` + Metadata TUSUploadMetadata `url:"-"` +} + +type StreamInitiateTUSUploadResponse struct { + ResponseHeaders http.Header +} + +type TUSUploadMetadata struct { + Name string `json:"name,omitempty"` + MaxDurationSeconds int `json:"maxDurationSeconds,omitempty"` + RequireSignedURLs bool `json:"requiresignedurls,omitempty"` + AllowedOrigins string `json:"allowedorigins,omitempty"` + ThumbnailTimestampPct float64 `json:"thumbnailtimestamppct,omitempty"` + ScheduledDeletion *time.Time `json:"scheduledDeletion,omitempty"` + Expiry *time.Time `json:"expiry,omitempty"` + Watermark string `json:"watermark,omitempty"` +} + +func (t TUSUploadMetadata) ToTUSCsv() (string, error) { + var metadataValues []string + if t.Name != "" { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "name", base64.StdEncoding.EncodeToString([]byte(t.Name)))) + } + if t.MaxDurationSeconds != 0 { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "maxDurationSeconds", base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(t.MaxDurationSeconds))))) + } + if t.RequireSignedURLs { + metadataValues = append(metadataValues, "requiresignedurls") + } + if t.AllowedOrigins != "" { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "allowedorigins", base64.StdEncoding.EncodeToString([]byte(t.AllowedOrigins)))) + } + if t.ThumbnailTimestampPct != 0 { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "thumbnailtimestamppct", base64.StdEncoding.EncodeToString([]byte(strconv.FormatFloat(t.ThumbnailTimestampPct, 'f', -1, 64))))) + } + if t.ScheduledDeletion != nil { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "scheduledDeletion", base64.StdEncoding.EncodeToString([]byte(t.ScheduledDeletion.Format(time.RFC3339))))) + } + if t.Expiry != nil { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "expiry", base64.StdEncoding.EncodeToString([]byte(t.Expiry.Format(time.RFC3339))))) + } + if t.Watermark != "" { + metadataValues = append(metadataValues, fmt.Sprintf("%s %s", "watermark", base64.StdEncoding.EncodeToString([]byte(t.Watermark)))) + } + + if len(metadataValues) > 0 { + return strings.Join(metadataValues, ","), nil + } + + return "", nil +} + +// StreamVideoResponse represents an API response of a stream video. +type StreamVideoResponse struct { + Response + Result StreamVideo `json:"result,omitempty"` +} + +// StreamVideoCreateResponse represents an API response of creating a stream video. +type StreamVideoCreateResponse struct { + Response + Result StreamVideoCreate `json:"result,omitempty"` +} + +// StreamListResponse represents the API response from a StreamListRequest. +type StreamListResponse struct { + Response + Result []StreamVideo `json:"result,omitempty"` + Total string `json:"total,omitempty"` + Range string `json:"range,omitempty"` +} + +// StreamSignedURLResponse represents an API response for a signed URL. +type StreamSignedURLResponse struct { + Response + Result struct { + Token string `json:"token,omitempty"` + } +} + +// StreamAccessRule represents the accessRules when creating a signed URL. +type StreamAccessRule struct { + Type string `json:"type"` + Country []string `json:"country,omitempty"` + Action string `json:"action"` + IP []string `json:"ip,omitempty"` +} + +// StreamUploadFromURL send a video URL to it will be downloaded and made available on Stream. +// +// API Reference: https://api.cloudflare.com/#stream-videos-upload-a-video-from-a-url +func (api *API) StreamUploadFromURL(ctx context.Context, params StreamUploadFromURLParameters) (StreamVideo, error) { + if params.AccountID == "" { + return StreamVideo{}, ErrMissingAccountID + } + + if params.URL == "" { + return StreamVideo{}, ErrMissingUploadURL + } + + uri := fmt.Sprintf("/accounts/%s/stream/copy", params.AccountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return StreamVideo{}, err + } + + var streamVideoResponse StreamVideoResponse + if err := json.Unmarshal(res, &streamVideoResponse); err != nil { + return StreamVideo{}, err + } + return streamVideoResponse.Result, nil +} + +// StreamUploadVideoFile uploads a video from a path to the file. +// +// API Reference: https://api.cloudflare.com/#stream-videos-upload-a-video-using-a-single-http-request +func (api *API) StreamUploadVideoFile(ctx context.Context, params StreamUploadFileParameters) (StreamVideo, error) { + if params.AccountID == "" { + return StreamVideo{}, ErrMissingAccountID + } + + if params.FilePath == "" { + return StreamVideo{}, ErrMissingFilePath + } + + uri := fmt.Sprintf("/accounts/%s/stream", params.AccountID) + + // Create new multipart writer + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + formFile, err := writer.CreateFormFile("file", params.FilePath) + if err != nil { + return StreamVideo{}, err + } + file, err := os.Open(params.FilePath) + if err != nil { + return StreamVideo{}, err + } + if _, err := io.Copy(formFile, file); err != nil { + return StreamVideo{}, err + } + if err := writer.Close(); err != nil { + return StreamVideo{}, err + } + + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, body, http.Header{ + "Accept": []string{"application/json"}, + "Content-Type": []string{writer.FormDataContentType()}, + }) + if err != nil { + return StreamVideo{}, err + } + + var streamVideoResponse StreamVideoResponse + if err := json.Unmarshal(res, &streamVideoResponse); err != nil { + return StreamVideo{}, err + } + return streamVideoResponse.Result, nil +} + +// StreamCreateVideoDirectURL creates a video and returns an authenticated URL. +// +// API Reference: https://api.cloudflare.com/#stream-videos-create-a-video-and-get-authenticated-direct-upload-url +func (api *API) StreamCreateVideoDirectURL(ctx context.Context, params StreamCreateVideoParameters) (StreamVideoCreate, error) { + if params.AccountID == "" { + return StreamVideoCreate{}, ErrMissingAccountID + } + + if params.MaxDurationSeconds == 0 { + return StreamVideoCreate{}, ErrMissingMaxDuration + } + + uri := fmt.Sprintf("/accounts/%s/stream/direct_upload", params.AccountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return StreamVideoCreate{}, err + } + + var streamVideoCreateResponse StreamVideoCreateResponse + if err := json.Unmarshal(res, &streamVideoCreateResponse); err != nil { + return StreamVideoCreate{}, err + } + return streamVideoCreateResponse.Result, nil +} + +// StreamListVideos list videos currently in stream. +// +// API reference: https://api.cloudflare.com/#stream-videos-list-videos +func (api *API) StreamListVideos(ctx context.Context, params StreamListParameters) ([]StreamVideo, error) { + if params.AccountID == "" { + return []StreamVideo{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/stream", params.AccountID), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []StreamVideo{}, err + } + + var streamListResponse StreamListResponse + if err := json.Unmarshal(res, &streamListResponse); err != nil { + return []StreamVideo{}, err + } + return streamListResponse.Result, nil +} + +// StreamInitiateTUSVideoUpload generates a direct upload TUS url for a video. +// +// API Reference: https://developers.cloudflare.com/api/operations/stream-videos-initiate-video-uploads-using-tus +func (api *API) StreamInitiateTUSVideoUpload(ctx context.Context, rc *ResourceContainer, params StreamInitiateTUSUploadParameters) (StreamInitiateTUSUploadResponse, error) { + if rc.Level != AccountRouteLevel { + return StreamInitiateTUSUploadResponse{}, ErrRequiredAccountLevelResourceContainer + } + + headers := http.Header{} + if params.TusResumable == "" { + return StreamInitiateTUSUploadResponse{}, ErrMissingTusResumable + } else if params.TusResumable != TusProtocolVersion1_0_0 { + return StreamInitiateTUSUploadResponse{}, ErrInvalidTusResumable + } else { + headers.Set("Tus-Resumable", string(params.TusResumable)) + } + + if params.UploadLength == 0 { + return StreamInitiateTUSUploadResponse{}, ErrMissingUploadLength + } else { + headers.Set("Upload-Length", strconv.FormatInt(params.UploadLength, 10)) + } + + if params.UploadCreator != "" { + headers.Set("Upload-Creator", params.UploadCreator) + } + + metadataTusCsv, err := params.Metadata.ToTUSCsv() + if err != nil { + return StreamInitiateTUSUploadResponse{}, ErrMarshallingTUSMetadata + } + if metadataTusCsv != "" { + headers.Set("Upload-Metadata", metadataTusCsv) + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/stream", rc.Identifier), params) + res, err := api.makeRequestWithAuthTypeAndHeadersComplete(ctx, http.MethodPost, uri, nil, api.authType, headers) + if err != nil { + return StreamInitiateTUSUploadResponse{}, err + } + + if res.StatusCode != http.StatusCreated { + return StreamInitiateTUSUploadResponse{}, ErrInvalidStatusCode + } + + return StreamInitiateTUSUploadResponse{ResponseHeaders: res.Headers}, nil +} + +// StreamGetVideo gets the details for a specific video. +// +// API Reference: https://api.cloudflare.com/#stream-videos-video-details +func (api *API) StreamGetVideo(ctx context.Context, options StreamParameters) (StreamVideo, error) { + if options.AccountID == "" { + return StreamVideo{}, ErrMissingAccountID + } + + if options.VideoID == "" { + return StreamVideo{}, ErrMissingVideoID + } + + uri := fmt.Sprintf("/accounts/%s/stream/%s", options.AccountID, options.VideoID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return StreamVideo{}, err + } + var streamVideoResponse StreamVideoResponse + if err := json.Unmarshal(res, &streamVideoResponse); err != nil { + return StreamVideo{}, err + } + return streamVideoResponse.Result, nil +} + +// StreamEmbedHTML gets an HTML fragment to embed on a web page. +// +// API Reference: https://api.cloudflare.com/#stream-videos-embed-code-html +func (api *API) StreamEmbedHTML(ctx context.Context, options StreamParameters) (string, error) { + if options.AccountID == "" { + return "", ErrMissingAccountID + } + + if options.VideoID == "" { + return "", ErrMissingVideoID + } + + uri := fmt.Sprintf("/accounts/%s/stream/%s/embed", options.AccountID, options.VideoID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + if err != nil { + return "", err + } + return string(res), nil +} + +// StreamDeleteVideo deletes a video. +// +// API Reference: https://api.cloudflare.com/#stream-videos-delete-video +func (api *API) StreamDeleteVideo(ctx context.Context, options StreamParameters) error { + if options.AccountID == "" { + return ErrMissingAccountID + } + + if options.VideoID == "" { + return ErrMissingVideoID + } + + uri := fmt.Sprintf("/accounts/%s/stream/%s", options.AccountID, options.VideoID) + if _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil); err != nil { + return err + } + return nil +} + +// StreamAssociateNFT associates a video to a token and contract address. +// +// API Reference: https://api.cloudflare.com/#stream-videos-associate-video-to-an-nft +func (api *API) StreamAssociateNFT(ctx context.Context, options StreamVideoNFTParameters) (StreamVideo, error) { + if options.AccountID == "" { + return StreamVideo{}, ErrMissingAccountID + } + + if options.VideoID == "" { + return StreamVideo{}, ErrMissingVideoID + } + + uri := fmt.Sprintf("/accounts/%s/stream/%s", options.AccountID, options.VideoID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, options) + if err != nil { + return StreamVideo{}, err + } + var streamVideoResponse StreamVideoResponse + if err := json.Unmarshal(res, &streamVideoResponse); err != nil { + return StreamVideo{}, err + } + return streamVideoResponse.Result, nil +} + +// StreamCreateSignedURL creates a signed URL token for a video. +// +// API Reference: https://api.cloudflare.com/#stream-videos-associate-video-to-an-nft +func (api *API) StreamCreateSignedURL(ctx context.Context, params StreamSignedURLParameters) (string, error) { + if params.AccountID == "" { + return "", ErrMissingAccountID + } + if params.VideoID == "" { + return "", ErrMissingVideoID + } + + uri := fmt.Sprintf("/accounts/%s/stream/%s/token", params.AccountID, params.VideoID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + + if err != nil { + return "", err + } + var streamSignedResponse StreamSignedURLResponse + if err := json.Unmarshal(res, &streamSignedResponse); err != nil { + return "", err + } + return streamSignedResponse.Result.Token, nil +} diff --git a/pkg/cloudflare-go/stream_test.go b/pkg/cloudflare-go/stream_test.go new file mode 100644 index 000000000..510e2bb5a --- /dev/null +++ b/pkg/cloudflare-go/stream_test.go @@ -0,0 +1,659 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + singleStreamResponse = ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "allowedOrigins": [ + "example.com" + ], + "created": "2014-01-02T02:20:00Z", + "duration": 300.5, + "input": { + "height": 1080, + "width": 1920 + }, + "maxDurationSeconds": 300, + "meta": { + "name": "My First Stream Video" + }, + "modified": "2014-01-02T02:20:00Z", + "uploadExpiry": "2014-01-02T02:20:00Z", + "playback": { + "hls": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.m3u8", + "dash": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.mpd" + }, + "preview": "https://watch.cloudflarestream.com/ea95132c15732412d22c1476fa83f27a", + "readyToStream": true, + "requireSignedURLs": true, + "size": 4190963, + "status": { + "state": "inprogress", + "pctComplete": "51", + "errorReasonCode": "ERR_NON_VIDEO", + "errorReasonText": "The file was not recognized as a valid video file." + }, + "thumbnail": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/thumbnails/thumbnail.jpg", + "thumbnailTimestampPct": 0.529241, + "uid": "ea95132c15732412d22c1476fa83f27a", + "creator": "creator-id_abcde12345", + "liveInput": "fc0a8dc887b16759bfd9ad922230a014", + "uploaded": "2014-01-02T02:20:00Z", + "watermark": { + "uid": "ea95132c15732412d22c1476fa83f27a", + "size": 29472, + "height": 600, + "width": 400, + "created": "2014-01-02T02:20:00Z", + "downloadedFrom": "https://company.com/logo.png", + "name": "Marketing Videos", + "opacity": 0.75, + "padding": 0.1, + "scale": 0.1, + "position": "center" + }, + "nft": { + "contract": "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85", + "token": 5 + }, + "scheduledDeletion": "2014-01-02T02:20:00Z" + } +} +` + testVideoID = "ea95132c15732412d22c1476fa83f27a" +) + +var ( + TestVideoStruct = createTestVideo() +) + +func createTestVideo() StreamVideo { + created, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + modified, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + uploadexpiry, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + uploaded, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + scheduledDuration, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + + return StreamVideo{ + AllowedOrigins: []string{"example.com"}, + Created: &created, + Duration: 300.5, + Input: StreamVideoInput{Height: 1080, Width: 1920}, + MaxDurationSeconds: 300, + Modified: &modified, + UploadExpiry: &uploadexpiry, + Playback: StreamVideoPlayback{Dash: "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.mpd", HLS: "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.m3u8"}, + Preview: "https://watch.cloudflarestream.com/ea95132c15732412d22c1476fa83f27a", + ReadyToStream: true, + RequireSignedURLs: true, + Size: 4190963, + Status: StreamVideoStatus{ + State: "inprogress", + PctComplete: "51", + ErrorReasonCode: "ERR_NON_VIDEO", + ErrorReasonText: "The file was not recognized as a valid video file.", + }, + Thumbnail: "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/thumbnails/thumbnail.jpg", + ThumbnailTimestampPct: 0.529241, + UID: "ea95132c15732412d22c1476fa83f27a", + Creator: "creator-id_abcde12345", + LiveInput: "fc0a8dc887b16759bfd9ad922230a014", + Uploaded: &uploaded, + Watermark: StreamVideoWatermark{ + UID: "ea95132c15732412d22c1476fa83f27a", + Size: 29472, + Height: 600, + Width: 400, + Created: &created, + DownloadedFrom: "https://company.com/logo.png", + Name: "Marketing Videos", + Opacity: 0.75, + Padding: 0.1, + Scale: 0.1, + Position: "center", + }, + Meta: map[string]interface{}{ + "name": "My First Stream Video", + }, + NFT: StreamVideoNFTParameters{ + Token: 5, + Contract: "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85", + }, + ScheduledDeletion: &scheduledDuration, + } +} + +func TestStream_StreamUploadFromURL(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream/copy", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, singleStreamResponse) + }) + + // Make sure missing account ID is thrown + _, err := client.StreamUploadFromURL(context.Background(), StreamUploadFromURLParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing upload URL is thrown + _, err = client.StreamUploadFromURL(context.Background(), StreamUploadFromURLParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingUploadURL, err) + } + + scheduledDuration, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + + want := TestVideoStruct + input := StreamUploadFromURLParameters{ + AccountID: testAccountID, + URL: "https://example.com/myvideo.mp4", + Meta: map[string]interface{}{ + "name": "My First Stream Video", + }, + ScheduledDeletion: &scheduledDuration, + } + + out, err := client.StreamUploadFromURL(context.Background(), input) + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} + +func TestStream_UploadVideoFile(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, singleStreamResponse) + }) + + // Make sure missing account ID is thrown + _, err := client.StreamUploadVideoFile(context.Background(), StreamUploadFileParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure missing file path is thrown + _, err = client.StreamUploadVideoFile(context.Background(), StreamUploadFileParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingFilePath, err) + } + + scheduledDuration, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + + input := StreamUploadFileParameters{ + AccountID: testAccountID, + VideoID: testVideoID, + FilePath: "stream_test.go", + ScheduledDeletion: &scheduledDuration, + } + + out, err := client.StreamUploadVideoFile(context.Background(), input) + + want := TestVideoStruct + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} + +func TestStream_CreateVideoDirectURL(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream/direct_upload", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "uploadURL": "www.example.com/samplepath", + "uid": "ea95132c15732412d22c1476fa83f27a", + "watermark": { + "uid": "ea95132c15732412d22c1476fa83f27a", + "size": 29472, + "height": 600, + "width": 400, + "created": "2014-01-02T02:20:00Z", + "downloadedFrom": "https://company.com/logo.png", + "name": "Marketing Videos", + "opacity": 0.75, + "padding": 0.1, + "scale": 0.1, + "position": "center" + }, + "scheduledDeletion": "2014-01-02T02:20:00Z" + } +} +`) + }) + + // Make sure AccountID is required + _, err := client.StreamCreateVideoDirectURL(context.Background(), StreamCreateVideoParameters{}) + + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure MaxDuration is required + _, err = client.StreamCreateVideoDirectURL(context.Background(), StreamCreateVideoParameters{AccountID: testAccountID}) + + if assert.Error(t, err) { + assert.Equal(t, ErrMissingMaxDuration, err) + } + + scheduledDuration, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + + input := StreamCreateVideoParameters{ + AccountID: testAccountID, + MaxDurationSeconds: 300, + Meta: map[string]interface{}{ + "name": "My First Stream Video", + }, + ScheduledDeletion: &scheduledDuration, + } + + out, err := client.StreamCreateVideoDirectURL(context.Background(), input) + + created, _ := time.Parse(time.RFC3339, "2014-01-02T02:20:00Z") + + want := StreamVideoCreate{ + UploadURL: "www.example.com/samplepath", + UID: "ea95132c15732412d22c1476fa83f27a", + Watermark: StreamVideoWatermark{ + UID: "ea95132c15732412d22c1476fa83f27a", + Size: 29472, + Height: 600, + Width: 400, + Created: &created, + DownloadedFrom: "https://company.com/logo.png", + Name: "Marketing Videos", + Opacity: 0.75, + Padding: 0.1, + Scale: 0.1, + Position: "center", + }, + ScheduledDeletion: &scheduledDuration, + } + + if assert.NoError(t, err) { + assert.Equal(t, out, want, "structs not equal") + } +} + +func TestStream_ListVideos(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream", 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, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": [{ + "allowedOrigins": [ + "example.com" + ], + "created": "2014-01-02T02:20:00Z", + "duration": 300.5, + "input": { + "height": 1080, + "width": 1920 + }, + "maxDurationSeconds": 300, + "meta": { + "name": "My First Stream Video" + }, + "modified": "2014-01-02T02:20:00Z", + "uploadExpiry": "2014-01-02T02:20:00Z", + "playback": { + "hls": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.m3u8", + "dash": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/manifest/video.mpd" + }, + "preview": "https://watch.cloudflarestream.com/ea95132c15732412d22c1476fa83f27a", + "readyToStream": true, + "requireSignedURLs": true, + "size": 4190963, + "status": { + "state": "inprogress", + "pctComplete": "51", + "errorReasonCode": "ERR_NON_VIDEO", + "errorReasonText": "The file was not recognized as a valid video file." + }, + "thumbnail": "https://videodelivery.net/ea95132c15732412d22c1476fa83f27a/thumbnails/thumbnail.jpg", + "thumbnailTimestampPct": 0.529241, + "uid": "ea95132c15732412d22c1476fa83f27a", + "creator": "creator-id_abcde12345", + "liveInput": "fc0a8dc887b16759bfd9ad922230a014", + "uploaded": "2014-01-02T02:20:00Z", + "watermark": { + "uid": "ea95132c15732412d22c1476fa83f27a", + "size": 29472, + "height": 600, + "width": 400, + "created": "2014-01-02T02:20:00Z", + "downloadedFrom": "https://company.com/logo.png", + "name": "Marketing Videos", + "opacity": 0.75, + "padding": 0.1, + "scale": 0.1, + "position": "center" + }, + "nft": { + "contract": "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85", + "token": 5 + }, + "scheduledDeletion": "2014-01-02T02:20:00Z" + }] +} +`) + }) + + // Make sure AccountID is required + _, err := client.StreamListVideos(context.Background(), StreamListParameters{}) + + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + out, err := client.StreamListVideos(context.Background(), StreamListParameters{AccountID: testAccountID}) + want := TestVideoStruct + + if assert.NoError(t, err) { + assert.Equal(t, len(out), 1, "length of videos is not one") + assert.Equal(t, out[0], want, "structs not equal") + } +} + +func TestStream_GetVideo(t *testing.T) { + setup() + defer teardown() + mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID, 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, singleStreamResponse) + }) + + // Make sure AccountID is required + _, err := client.StreamGetVideo(context.Background(), StreamParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure VideoID is required + _, err = client.StreamGetVideo(context.Background(), StreamParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingVideoID, err) + } + + input := StreamParameters{AccountID: testAccountID, VideoID: testVideoID} + out, err := client.StreamGetVideo(context.Background(), input) + + want := TestVideoStruct + + if assert.NoError(t, err) { + assert.Equal(t, want, out, "structs not equal") + } +} + +func TestStream_DeleteVideo(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID, 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": {} + }`) + }) + + // Make sure AccountID is required + err := client.StreamDeleteVideo(context.Background(), StreamParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure VideoID is required + err = client.StreamDeleteVideo(context.Background(), StreamParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingVideoID, err) + } + + input := StreamParameters{AccountID: testAccountID, VideoID: testVideoID} + err = client.StreamDeleteVideo(context.Background(), input) + require.NoError(t, err) +} + +func TestStream_EmbedHTML(t *testing.T) { + setup() + defer teardown() + + streamHTML := `` + mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID+"/embed", 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", "text/html") + fmt.Fprint(w, streamHTML) + }) + + // Make sure AccountID is required + _, err := client.StreamEmbedHTML(context.Background(), StreamParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure VideoID is required + _, err = client.StreamEmbedHTML(context.Background(), StreamParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingVideoID, err) + } + + input := StreamParameters{AccountID: testAccountID, VideoID: testVideoID} + out, err := client.StreamEmbedHTML(context.Background(), input) + if assert.NoError(t, err) { + assert.Equal(t, streamHTML, out, "bad html output") + } +} + +func TestStream_AssociateNFT(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + fmt.Fprint(w, singleStreamResponse) + }) + + // Make sure AccountID is required + _, err := client.StreamAssociateNFT(context.Background(), StreamVideoNFTParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure VideoID is required + _, err = client.StreamAssociateNFT(context.Background(), StreamVideoNFTParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingVideoID, err) + } + + input := StreamVideoNFTParameters{AccountID: testAccountID, VideoID: testVideoID, Token: 5, Contract: "0x57f1887a8bf19b14fc0d912b9b2acc9af147ea85"} + out, err := client.StreamAssociateNFT(context.Background(), input) + + want := TestVideoStruct + + if assert.NoError(t, err) { + assert.Equal(t, want, out, "structs not equal") + } +} + +func TestStream_CreateSignedURL(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream/"+testVideoID+"/token", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIn0.eyJzdWIiOiJlYTk1MTMyYzE1NzMyNDEyZDIyYzE0NzZmYTgzZjI3YSIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIiwiZXhwIjoiMTUzNzQ2MDM2NSIsIm5iZiI6IjE1Mzc0NTMxNjUifQ.OZhqOARADn1iubK6GKcn25hN3nU-hCFF5q9w2C4yup0C4diG7aMIowiRpP-eDod8dbAJubsiFuTKrqPcmyCKWYsiv0TQueukqbQlF7HCO1TV-oF6El5-7ldJ46eD-ZQ0XgcIYEKrQOYFF8iDQbqPm3REWd6BnjKZdeVrLzuRaiSnZ9qqFpGu5dfxIY9-nZKDubJHqCr3Imtb211VIG_b9MdtO92JjvkDS-rxT_pkEfTZSafl1OU-98A7KBGtPSJHz2dHORIrUiTA6on4eIXTj9aFhGiir4rSn-rn0OjPRTtJMWIDMoQyE_fwrSYzB7MPuzL2t82BWaEbHZTfixBm5A" + } +}`) + }) + + // Make sure AccountID is required + _, err := client.StreamCreateSignedURL(context.Background(), StreamSignedURLParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + // Make sure VideoID is required + _, err = client.StreamCreateSignedURL(context.Background(), StreamSignedURLParameters{AccountID: testAccountID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingVideoID, err) + } + + input := StreamSignedURLParameters{AccountID: testAccountID, VideoID: testVideoID} + out, err := client.StreamCreateSignedURL(context.Background(), input) + + want := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIn0.eyJzdWIiOiJlYTk1MTMyYzE1NzMyNDEyZDIyYzE0NzZmYTgzZjI3YSIsImtpZCI6ImU5ZGI5OTBhODI2NjZkZDU3MWM3N2Y5NDRhNWM1YzhkIiwiZXhwIjoiMTUzNzQ2MDM2NSIsIm5iZiI6IjE1Mzc0NTMxNjUifQ.OZhqOARADn1iubK6GKcn25hN3nU-hCFF5q9w2C4yup0C4diG7aMIowiRpP-eDod8dbAJubsiFuTKrqPcmyCKWYsiv0TQueukqbQlF7HCO1TV-oF6El5-7ldJ46eD-ZQ0XgcIYEKrQOYFF8iDQbqPm3REWd6BnjKZdeVrLzuRaiSnZ9qqFpGu5dfxIY9-nZKDubJHqCr3Imtb211VIG_b9MdtO92JjvkDS-rxT_pkEfTZSafl1OU-98A7KBGtPSJHz2dHORIrUiTA6on4eIXTj9aFhGiir4rSn-rn0OjPRTtJMWIDMoQyE_fwrSYzB7MPuzL2t82BWaEbHZTfixBm5A" + + if assert.NoError(t, err) { + assert.Equal(t, want, out, "structs not equal") + } +} + +func TestStream_TUSUploadMetadataToTUSCsv(t *testing.T) { + md := TUSUploadMetadata{ + Name: "test.mp4", + } + csv, err := md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=", csv) + + md.RequireSignedURLs = true + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,requiresignedurls", csv) + + md.AllowedOrigins = "example.com" + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,requiresignedurls,allowedorigins ZXhhbXBsZS5jb20=", csv) + + md.ThumbnailTimestampPct = 0.5 + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,requiresignedurls,allowedorigins ZXhhbXBsZS5jb20=,thumbnailtimestamppct MC41", csv) + + scheduleDeletion, _ := time.Parse(time.RFC3339, "2023-10-01T02:20:00Z") + md.ScheduledDeletion = &scheduleDeletion + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,requiresignedurls,allowedorigins ZXhhbXBsZS5jb20=,thumbnailtimestamppct MC41,scheduledDeletion MjAyMy0xMC0wMVQwMjoyMDowMFo=", csv) + + expiry, _ := time.Parse(time.RFC3339, "2023-09-25T02:45:00Z") + md.Expiry = &expiry + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,requiresignedurls,allowedorigins ZXhhbXBsZS5jb20=,thumbnailtimestamppct MC41,scheduledDeletion MjAyMy0xMC0wMVQwMjoyMDowMFo=,expiry MjAyMy0wOS0yNVQwMjo0NTowMFo=", csv) + + md.Watermark = "watermark-profile-uid" + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,requiresignedurls,allowedorigins ZXhhbXBsZS5jb20=,thumbnailtimestamppct MC41,scheduledDeletion MjAyMy0xMC0wMVQwMjoyMDowMFo=,expiry MjAyMy0wOS0yNVQwMjo0NTowMFo=,watermark d2F0ZXJtYXJrLXByb2ZpbGUtdWlk", csv) + + md.MaxDurationSeconds = 300 + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "name dGVzdC5tcDQ=,maxDurationSeconds MzAw,requiresignedurls,allowedorigins ZXhhbXBsZS5jb20=,thumbnailtimestamppct MC41,scheduledDeletion MjAyMy0xMC0wMVQwMjoyMDowMFo=,expiry MjAyMy0wOS0yNVQwMjo0NTowMFo=,watermark d2F0ZXJtYXJrLXByb2ZpbGUtdWlk", csv) + + // empty metadata should return empty string + md = TUSUploadMetadata{} + csv, err = md.ToTUSCsv() + assert.NoError(t, err) + assert.Equal(t, "", csv) +} + +func TestStream_StreamInitiateTUSVideoUpload(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/stream", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + // Make sure Tus-Resumable header is set + assert.Equal(t, "1.0.0", r.Header.Get("Tus-Resumable")) + // Make sure Upload-Length header is set + assert.Equal(t, "123", r.Header.Get("Upload-Length")) + // set the response headers + // if query param direct_user=true, then return the direct url in the header + if r.URL.Query().Get("direct_user") == "true" { + w.Header().Set("Location", "https://upload.videodelivery.net/tus/90c68cb5cd4fd5350b1962279c90bec0?tusv2=true") + } else { + w.Header().Set("Location", "https://api.cloudflare.com/client/v4/accounts/"+testAccountID+"/media/278f2a7e763c73dedc064b965d2cfbed?tusv2=true") + } + + w.Header().Set("stream-media-id", "278f2a7e763c73dedc064b965d2cfbed") + w.Header().Set("Tus-Resumable", "1.0.0") + w.WriteHeader(http.StatusCreated) + }) + + // Make sure Tus-Resumable header is set + params := StreamInitiateTUSUploadParameters{} + _, err := client.StreamInitiateTUSVideoUpload(context.Background(), AccountIdentifier(testAccountID), params) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingTusResumable, err) + } + params.TusResumable = TusProtocolVersion1_0_0 + + // Make sure Upload-Length header is set + _, err = client.StreamInitiateTUSVideoUpload(context.Background(), AccountIdentifier(testAccountID), params) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingUploadLength, err) + } + params.UploadLength = 123 + + out, err := client.StreamInitiateTUSVideoUpload(context.Background(), AccountIdentifier(testAccountID), params) + if assert.NoError(t, err) { + assert.Equal(t, "https://api.cloudflare.com/client/v4/accounts/"+testAccountID+"/media/278f2a7e763c73dedc064b965d2cfbed?tusv2=true", out.ResponseHeaders.Get("Location")) + assert.Equal(t, "278f2a7e763c73dedc064b965d2cfbed", out.ResponseHeaders.Get("stream-media-id")) + assert.Equal(t, "1.0.0", out.ResponseHeaders.Get("Tus-Resumable")) + } + + params.DirectUserUpload = true + out, err = client.StreamInitiateTUSVideoUpload(context.Background(), AccountIdentifier(testAccountID), params) + if assert.NoError(t, err) { + assert.Equal(t, "https://upload.videodelivery.net/tus/90c68cb5cd4fd5350b1962279c90bec0?tusv2=true", out.ResponseHeaders.Get("Location")) + assert.Equal(t, "278f2a7e763c73dedc064b965d2cfbed", out.ResponseHeaders.Get("stream-media-id")) + assert.Equal(t, "1.0.0", out.ResponseHeaders.Get("Tus-Resumable")) + } +} diff --git a/pkg/cloudflare-go/sts.go b/pkg/cloudflare-go/sts.go new file mode 100644 index 000000000..350e5cb08 --- /dev/null +++ b/pkg/cloudflare-go/sts.go @@ -0,0 +1,101 @@ +package cloudflare + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/goccy/go-json" + + "github.com/hashicorp/go-retryablehttp" +) + +var ( + ErrSTSFailure = errors.New("failed to fetch security token") + ErrSTSHTTPFailure = errors.New("failed making securtiy token issuer call") + ErrSTSHTTPResponseError = errors.New("security token request returned a failure") + ErrSTSMissingServiceSecret = errors.New("service secret missing but is required") + ErrSTSMissingServiceTag = errors.New("service tag missing but is required") + ErrSTSMissingIssuerHostname = errors.New("issuer hostname missing but is required") + ErrSTSMissingServicePath = errors.New("issuer path missing but is required") +) + +// IssuerConfiguration allows the configuration of the issuance provider. +type IssuerConfiguration struct { + Hostname string + Path string +} + +// SecurityTokenConfiguration holds the configuration for requesting a security +// token from the service. +type SecurityTokenConfiguration struct { + Issuer *IssuerConfiguration + ServiceTag string + Secret string +} + +type securityToken struct { + Token string `json:"json_web_token"` +} + +type securityTokenResponse struct { + Result securityToken `json:"result"` + Response +} + +// fetchSTSCredentials provides a way to authenticate with the security token +// service and issue a usable token for the system. +func fetchSTSCredentials(stsConfig *SecurityTokenConfiguration) (string, error) { + if stsConfig.Secret == "" { + return "", ErrSTSMissingServiceSecret + } + + if stsConfig.ServiceTag == "" { + return "", ErrSTSMissingServiceTag + } + + if stsConfig.Issuer.Hostname == "" { + return "", ErrSTSMissingIssuerHostname + } + + if stsConfig.Issuer.Path == "" { + return "", ErrSTSMissingServicePath + } + + retryableClient := retryablehttp.NewClient() + retryableClient.RetryMax = 3 + stsClient := retryableClient.StandardClient() + + uri := fmt.Sprintf("https://%s%s", stsConfig.Issuer.Hostname, stsConfig.Issuer.Path) + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return "", fmt.Errorf("HTTP request creation failed: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+stsConfig.ServiceTag+stsConfig.Secret) + + resp, err := stsClient.Do(req) + if err != nil { + return "", ErrSTSHTTPFailure + } + + var respBody []byte + respBody, err = io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + resp.Body.Close() + + var stsTokenResponse *securityTokenResponse + err = json.Unmarshal(respBody, &stsTokenResponse) + if err != nil { + return "", fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !stsTokenResponse.Success { + return "", ErrSTSHTTPResponseError + } + + return stsTokenResponse.Result.Token, nil +} diff --git a/pkg/cloudflare-go/teams_accounts.go b/pkg/cloudflare-go/teams_accounts.go new file mode 100644 index 000000000..3472fa487 --- /dev/null +++ b/pkg/cloudflare-go/teams_accounts.go @@ -0,0 +1,338 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type TeamsAccount struct { + GatewayTag string `json:"gateway_tag"` // Internal teams ID + ProviderName string `json:"provider_name"` // Auth provider + ID string `json:"id"` // cloudflare account ID +} + +// TeamsAccountResponse is the API response, containing information on teams +// account. +type TeamsAccountResponse struct { + Response + Result TeamsAccount `json:"result"` +} + +// TeamsConfigResponse is the API response, containing information on teams +// account config. +type TeamsConfigResponse struct { + Response + Result TeamsConfiguration `json:"result"` +} + +// TeamsConfiguration data model. +type TeamsConfiguration struct { + Settings TeamsAccountSettings `json:"settings"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type TeamsAccountSettings struct { + Antivirus *TeamsAntivirus `json:"antivirus,omitempty"` + TLSDecrypt *TeamsTLSDecrypt `json:"tls_decrypt,omitempty"` + ActivityLog *TeamsActivityLog `json:"activity_log,omitempty"` + BlockPage *TeamsBlockPage `json:"block_page,omitempty"` + BrowserIsolation *BrowserIsolation `json:"browser_isolation,omitempty"` + FIPS *TeamsFIPS `json:"fips,omitempty"` + ProtocolDetection *TeamsProtocolDetection `json:"protocol_detection,omitempty"` + BodyScanning *TeamsBodyScanning `json:"body_scanning,omitempty"` + ExtendedEmailMatching *TeamsExtendedEmailMatching `json:"extended_email_matching,omitempty"` + CustomCertificate *TeamsCustomCertificate `json:"custom_certificate,omitempty"` +} + +type BrowserIsolation struct { + UrlBrowserIsolationEnabled *bool `json:"url_browser_isolation_enabled,omitempty"` + NonIdentityEnabled *bool `json:"non_identity_enabled,omitempty"` +} + +type TeamsAntivirus struct { + EnabledDownloadPhase bool `json:"enabled_download_phase"` + EnabledUploadPhase bool `json:"enabled_upload_phase"` + FailClosed bool `json:"fail_closed"` + NotificationSettings *TeamsNotificationSettings `json:"notification_settings"` +} + +type TeamsFIPS struct { + TLS bool `json:"tls"` +} + +type TeamsTLSDecrypt struct { + Enabled bool `json:"enabled"` +} + +type TeamsProtocolDetection struct { + Enabled bool `json:"enabled"` +} + +type TeamsActivityLog struct { + Enabled bool `json:"enabled"` +} + +type TeamsBlockPage struct { + Enabled *bool `json:"enabled,omitempty"` + FooterText string `json:"footer_text,omitempty"` + HeaderText string `json:"header_text,omitempty"` + LogoPath string `json:"logo_path,omitempty"` + BackgroundColor string `json:"background_color,omitempty"` + Name string `json:"name,omitempty"` + MailtoAddress string `json:"mailto_address,omitempty"` + MailtoSubject string `json:"mailto_subject,omitempty"` + SuppressFooter *bool `json:"suppress_footer,omitempty"` +} + +type TeamsInspectionMode = string + +const ( + TeamsShallowInspectionMode TeamsInspectionMode = "shallow" + TeamsDeepInspectionMode TeamsInspectionMode = "deep" +) + +type TeamsBodyScanning struct { + InspectionMode TeamsInspectionMode `json:"inspection_mode,omitempty"` +} + +type TeamsExtendedEmailMatching struct { + Enabled *bool `json:"enabled,omitempty"` +} + +type TeamsCustomCertificate struct { + Enabled *bool `json:"enabled,omitempty"` + ID string `json:"id,omitempty"` + BindingStatus string `json:"binding_status,omitempty"` + QsPackId string `json:"qs_pack_id,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type TeamsRuleType = string + +const ( + TeamsHttpRuleType TeamsRuleType = "http" + TeamsDnsRuleType TeamsRuleType = "dns" + TeamsL4RuleType TeamsRuleType = "l4" +) + +type TeamsAccountLoggingConfiguration struct { + LogAll bool `json:"log_all"` + LogBlocks bool `json:"log_blocks"` +} + +type TeamsLoggingSettings struct { + LoggingSettingsByRuleType map[TeamsRuleType]TeamsAccountLoggingConfiguration `json:"settings_by_rule_type"` + RedactPii bool `json:"redact_pii,omitempty"` +} + +type TeamsDeviceSettings struct { + GatewayProxyEnabled bool `json:"gateway_proxy_enabled"` + GatewayProxyUDPEnabled bool `json:"gateway_udp_proxy_enabled"` + RootCertificateInstallationEnabled bool `json:"root_certificate_installation_enabled"` + UseZTVirtualIP *bool `json:"use_zt_virtual_ip"` +} + +type TeamsDeviceSettingsResponse struct { + Response + Result TeamsDeviceSettings `json:"result"` +} + +type TeamsLoggingSettingsResponse struct { + Response + Result TeamsLoggingSettings `json:"result"` +} + +type TeamsConnectivitySettings struct { + ICMPProxyEnabled *bool `json:"icmp_proxy_enabled"` + OfframpWARPEnabled *bool `json:"offramp_warp_enabled"` +} + +type TeamsAccountConnectivitySettingsResponse struct { + Response + Result TeamsConnectivitySettings `json:"result"` +} + +// TeamsAccount returns teams account information with internal and external ID. +// +// API reference: TBA. +func (api *API) TeamsAccount(ctx context.Context, accountID string) (TeamsAccount, error) { + uri := fmt.Sprintf("/accounts/%s/gateway", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsAccount{}, err + } + + var teamsAccountResponse TeamsAccountResponse + err = json.Unmarshal(res, &teamsAccountResponse) + if err != nil { + return TeamsAccount{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsAccountResponse.Result, nil +} + +// TeamsAccountConfiguration returns teams account configuration. +// +// API reference: TBA. +func (api *API) TeamsAccountConfiguration(ctx context.Context, accountID string) (TeamsConfiguration, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/configuration", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsConfiguration{}, err + } + + var teamsConfigResponse TeamsConfigResponse + err = json.Unmarshal(res, &teamsConfigResponse) + if err != nil { + return TeamsConfiguration{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsConfigResponse.Result, nil +} + +// TeamsAccountDeviceConfiguration returns teams account device configuration with udp status. +// +// API reference: TBA. +func (api *API) TeamsAccountDeviceConfiguration(ctx context.Context, accountID string) (TeamsDeviceSettings, error) { + uri := fmt.Sprintf("/accounts/%s/devices/settings", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsDeviceSettings{}, err + } + + var teamsDeviceResponse TeamsDeviceSettingsResponse + err = json.Unmarshal(res, &teamsDeviceResponse) + if err != nil { + return TeamsDeviceSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsDeviceResponse.Result, nil +} + +// TeamsAccountLoggingConfiguration returns teams account logging configuration. +// +// API reference: TBA. +func (api *API) TeamsAccountLoggingConfiguration(ctx context.Context, accountID string) (TeamsLoggingSettings, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/logging", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsLoggingSettings{}, err + } + + var teamsConfigResponse TeamsLoggingSettingsResponse + err = json.Unmarshal(res, &teamsConfigResponse) + if err != nil { + return TeamsLoggingSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsConfigResponse.Result, nil +} + +// TeamsAccountConnectivityConfiguration returns zero trust account connectivity settings. +// +// API reference: https://developers.cloudflare.com/api/operations/zero-trust-accounts-get-connectivity-settings +func (api *API) TeamsAccountConnectivityConfiguration(ctx context.Context, accountID string) (TeamsConnectivitySettings, error) { + uri := fmt.Sprintf("/accounts/%s/zerotrust/connectivity_settings", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsConnectivitySettings{}, err + } + + var teamsConnectivityResponse TeamsAccountConnectivitySettingsResponse + err = json.Unmarshal(res, &teamsConnectivityResponse) + if err != nil { + return TeamsConnectivitySettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsConnectivityResponse.Result, nil +} + +// TeamsAccountUpdateConfiguration updates a teams account configuration. +// +// API reference: TBA. +func (api *API) TeamsAccountUpdateConfiguration(ctx context.Context, accountID string, config TeamsConfiguration) (TeamsConfiguration, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/configuration", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, config) + if err != nil { + return TeamsConfiguration{}, err + } + + var teamsConfigResponse TeamsConfigResponse + err = json.Unmarshal(res, &teamsConfigResponse) + if err != nil { + return TeamsConfiguration{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsConfigResponse.Result, nil +} + +// TeamsAccountUpdateLoggingConfiguration updates the log settings and returns new teams account logging configuration. +// +// API reference: TBA. +func (api *API) TeamsAccountUpdateLoggingConfiguration(ctx context.Context, accountID string, config TeamsLoggingSettings) (TeamsLoggingSettings, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/logging", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, config) + if err != nil { + return TeamsLoggingSettings{}, err + } + + var teamsConfigResponse TeamsLoggingSettingsResponse + err = json.Unmarshal(res, &teamsConfigResponse) + if err != nil { + return TeamsLoggingSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsConfigResponse.Result, nil +} + +// TeamsAccountDeviceUpdateConfiguration updates teams account device configuration including udp filtering status. +// +// API reference: TBA. +func (api *API) TeamsAccountDeviceUpdateConfiguration(ctx context.Context, accountID string, settings TeamsDeviceSettings) (TeamsDeviceSettings, error) { + uri := fmt.Sprintf("/accounts/%s/devices/settings", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, settings) + if err != nil { + return TeamsDeviceSettings{}, err + } + + var teamsDeviceResponse TeamsDeviceSettingsResponse + err = json.Unmarshal(res, &teamsDeviceResponse) + if err != nil { + return TeamsDeviceSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsDeviceResponse.Result, nil +} + +// TeamsAccountConnectivityUpdateConfiguration updates zero trust account connectivity settings. +// +// API reference: https://developers.cloudflare.com/api/operations/zero-trust-accounts-patch-connectivity-settings +func (api *API) TeamsAccountConnectivityUpdateConfiguration(ctx context.Context, accountID string, settings TeamsConnectivitySettings) (TeamsConnectivitySettings, error) { + uri := fmt.Sprintf("/accounts/%s/zerotrust/connectivity_settings", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, settings) + if err != nil { + return TeamsConnectivitySettings{}, err + } + + var teamsConnectivityResponse TeamsAccountConnectivitySettingsResponse + err = json.Unmarshal(res, &teamsConnectivityResponse) + if err != nil { + return TeamsConnectivitySettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsConnectivityResponse.Result, nil +} diff --git a/pkg/cloudflare-go/teams_accounts_test.go b/pkg/cloudflare-go/teams_accounts_test.go new file mode 100644 index 000000000..5fd3686ac --- /dev/null +++ b/pkg/cloudflare-go/teams_accounts_test.go @@ -0,0 +1,399 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTeamsAccount(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "provider_name": "cf", + "gateway_tag": "1234" + } + } + `, testAccountID) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway", handler) + + actual, err := client.TeamsAccount(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, actual.ProviderName, "cf") + assert.Equal(t, actual.GatewayTag, "1234") + assert.Equal(t, actual.ID, testAccountID) + } +} + +func TestTeamsAccountConfiguration(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "settings": { + "antivirus": { + "enabled_download_phase": true, + "notification_settings": { + "enabled":true, + "msg":"msg", + "support_url":"https://hi.com" + } + }, + "tls_decrypt": { + "enabled": true + }, + "protocol_detection": { + "enabled": true + }, + "fips": { + "tls": true + }, + "activity_log": { + "enabled": true + }, + "block_page": { + "enabled": true, + "name": "Cloudflare", + "footer_text": "--footer--", + "header_text": "--header--", + "mailto_address": "admin@example.com", + "mailto_subject": "Blocked User Inquiry", + "logo_path": "https://logos.com/a.png", + "background_color": "#ff0000", + "suppress_footer": true + }, + "browser_isolation": { + "url_browser_isolation_enabled": true, + "non_identity_enabled": true + }, + "body_scanning": { + "inspection_mode": "deep" + }, + "extended_email_matching": { + "enabled": true + } + } + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/configuration", handler) + + actual, err := client.TeamsAccountConfiguration(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, actual.Settings, TeamsAccountSettings{ + Antivirus: &TeamsAntivirus{ + EnabledDownloadPhase: true, + NotificationSettings: &TeamsNotificationSettings{ + Enabled: &trueValue, + Message: "msg", + SupportURL: "https://hi.com", + }, + }, + ActivityLog: &TeamsActivityLog{Enabled: true}, + TLSDecrypt: &TeamsTLSDecrypt{Enabled: true}, + ProtocolDetection: &TeamsProtocolDetection{Enabled: true}, + FIPS: &TeamsFIPS{TLS: true}, + BodyScanning: &TeamsBodyScanning{InspectionMode: "deep"}, + + BlockPage: &TeamsBlockPage{ + Enabled: BoolPtr(true), + FooterText: "--footer--", + HeaderText: "--header--", + LogoPath: "https://logos.com/a.png", + BackgroundColor: "#ff0000", + Name: "Cloudflare", + MailtoAddress: "admin@example.com", + MailtoSubject: "Blocked User Inquiry", + SuppressFooter: BoolPtr(true), + }, + BrowserIsolation: &BrowserIsolation{ + UrlBrowserIsolationEnabled: BoolPtr(true), + NonIdentityEnabled: BoolPtr(true), + }, + ExtendedEmailMatching: &TeamsExtendedEmailMatching{ + Enabled: BoolPtr(true), + }, + }) + } +} + +func TestTeamsAccountUpdateConfiguration(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'put', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "settings": { + "antivirus": { + "enabled_download_phase": false + }, + "tls_decrypt": { + "enabled": true + }, + "activity_log": { + "enabled": true + }, + "protocol_detection": { + "enabled": true + }, + "extended_email_matching": { + "enabled": true + }, + "custom_certificate": { + "enabled": true + } + } + } + } + `) + } + + settings := TeamsAccountSettings{ + Antivirus: &TeamsAntivirus{EnabledDownloadPhase: false}, + ActivityLog: &TeamsActivityLog{Enabled: true}, + TLSDecrypt: &TeamsTLSDecrypt{Enabled: true}, + ProtocolDetection: &TeamsProtocolDetection{Enabled: true}, + ExtendedEmailMatching: &TeamsExtendedEmailMatching{ + Enabled: BoolPtr(true), + }, + CustomCertificate: &TeamsCustomCertificate{ + Enabled: BoolPtr(true), + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/configuration", handler) + + configuration := TeamsConfiguration{ + Settings: settings, + } + actual, err := client.TeamsAccountUpdateConfiguration(context.Background(), testAccountID, configuration) + + if assert.NoError(t, err) { + assert.Equal(t, actual, configuration) + } +} + +func TestTeamsAccountGetLoggingConfiguration(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {"settings_by_rule_type":{"dns":{"log_all":false,"log_blocks":true}},"redact_pii":true} + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/logging", handler) + + actual, err := client.TeamsAccountLoggingConfiguration(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, actual, TeamsLoggingSettings{ + RedactPii: true, + LoggingSettingsByRuleType: map[TeamsRuleType]TeamsAccountLoggingConfiguration{ + TeamsDnsRuleType: {LogAll: false, LogBlocks: true}, + }, + }) + } +} + +func TestTeamsAccountUpdateLoggingConfiguration(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {"settings_by_rule_type":{"dns":{"log_all":false,"log_blocks":true}, "http":{"log_all":true,"log_blocks":false}, "l4": {"log_all": false, "log_blocks": true}},"redact_pii":true} + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/logging", handler) + + actual, err := client.TeamsAccountUpdateLoggingConfiguration(context.Background(), testAccountID, TeamsLoggingSettings{ + RedactPii: true, + LoggingSettingsByRuleType: map[TeamsRuleType]TeamsAccountLoggingConfiguration{ + TeamsDnsRuleType: { + LogAll: false, + LogBlocks: true, + }, + TeamsHttpRuleType: { + LogAll: true, + }, + TeamsL4RuleType: { + LogBlocks: true, + }, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, actual, TeamsLoggingSettings{ + RedactPii: true, + LoggingSettingsByRuleType: map[TeamsRuleType]TeamsAccountLoggingConfiguration{ + TeamsDnsRuleType: {LogAll: false, LogBlocks: true}, + TeamsHttpRuleType: {LogAll: true, LogBlocks: false}, + TeamsL4RuleType: {LogAll: false, LogBlocks: true}, + }, + }) + } +} + +func TestTeamsAccountGetDeviceConfiguration(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {"gateway_proxy_enabled": true,"gateway_udp_proxy_enabled":false, "root_certificate_installation_enabled":true, "use_zt_virtual_ip":false} + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/settings", handler) + + actual, err := client.TeamsAccountDeviceConfiguration(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, actual, TeamsDeviceSettings{ + GatewayProxyEnabled: true, + GatewayProxyUDPEnabled: false, + RootCertificateInstallationEnabled: true, + UseZTVirtualIP: BoolPtr(false), + }) + } +} + +func TestTeamsAccountUpdateDeviceConfiguration(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {"gateway_proxy_enabled": true,"gateway_udp_proxy_enabled":true, "root_certificate_installation_enabled":true, "use_zt_virtual_ip":true} + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/settings", handler) + + actual, err := client.TeamsAccountDeviceUpdateConfiguration(context.Background(), testAccountID, TeamsDeviceSettings{ + GatewayProxyUDPEnabled: true, + GatewayProxyEnabled: true, + RootCertificateInstallationEnabled: true, + UseZTVirtualIP: BoolPtr(true), + }) + + if assert.NoError(t, err) { + assert.Equal(t, actual, TeamsDeviceSettings{ + GatewayProxyEnabled: true, + GatewayProxyUDPEnabled: true, + RootCertificateInstallationEnabled: true, + UseZTVirtualIP: BoolPtr(true), + }) + } +} + +func TestTeamsAccountGetConnectivityConfiguration(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {"icmp_proxy_enabled": false,"offramp_warp_enabled":false} + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/zerotrust/connectivity_settings", handler) + + actual, err := client.TeamsAccountConnectivityConfiguration(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, actual, TeamsConnectivitySettings{ + ICMPProxyEnabled: BoolPtr(false), + OfframpWARPEnabled: BoolPtr(false), + }) + } +} + +func TestTeamsAccountUpdateConnectivityConfiguration(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": {"icmp_proxy_enabled": true,"offramp_warp_enabled":true} + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/zerotrust/connectivity_settings", handler) + + actual, err := client.TeamsAccountConnectivityUpdateConfiguration(context.Background(), testAccountID, TeamsConnectivitySettings{ + ICMPProxyEnabled: BoolPtr(true), + OfframpWARPEnabled: BoolPtr(true), + }) + + if assert.NoError(t, err) { + assert.Equal(t, actual, TeamsConnectivitySettings{ + ICMPProxyEnabled: BoolPtr(true), + OfframpWARPEnabled: BoolPtr(true), + }) + } +} diff --git a/pkg/cloudflare-go/teams_audit_ssh_settings.go b/pkg/cloudflare-go/teams_audit_ssh_settings.go new file mode 100644 index 000000000..a4ec1e320 --- /dev/null +++ b/pkg/cloudflare-go/teams_audit_ssh_settings.go @@ -0,0 +1,86 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// TeamsList represents a Teams List. +type AuditSSHSettings struct { + PublicKey string `json:"public_key"` + SeedUUID string `json:"seed_id"` + CreatedAt *time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at"` +} + +type AuditSSHSettingsResponse struct { + Result AuditSSHSettings `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type GetAuditSSHSettingsParams struct{} + +type UpdateAuditSSHSettingsParams struct { + PublicKey string `json:"public_key"` +} + +// GetAuditSSHSettings returns the accounts zt audit ssh settings. +// +// API reference: https://api.cloudflare.com/#zero-trust-get-audit-ssh-settings +func (api *API) GetAuditSSHSettings(ctx context.Context, rc *ResourceContainer, params GetAuditSSHSettingsParams) (AuditSSHSettings, ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return AuditSSHSettings{}, ResultInfo{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/%s/%s/gateway/audit_ssh_settings", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return AuditSSHSettings{}, ResultInfo{}, err + } + + var auditSSHSettingsResponse AuditSSHSettingsResponse + err = json.Unmarshal(res, &auditSSHSettingsResponse) + if err != nil { + return AuditSSHSettings{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return auditSSHSettingsResponse.Result, auditSSHSettingsResponse.ResultInfo, nil +} + +// UpdateAuditSSHSettings updates an existing zt audit ssh setting. +// +// API reference: https://api.cloudflare.com/#zero-trust-update-audit-ssh-settings +func (api *API) UpdateAuditSSHSettings(ctx context.Context, rc *ResourceContainer, params UpdateAuditSSHSettingsParams) (AuditSSHSettings, error) { + if rc.Level != AccountRouteLevel { + return AuditSSHSettings{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return AuditSSHSettings{}, ErrMissingAccountID + } + + uri := fmt.Sprintf( + "/%s/%s/gateway/audit_ssh_settings", + rc.Level, + rc.Identifier, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return AuditSSHSettings{}, err + } + + var auditSSHSettingsResponse AuditSSHSettingsResponse + err = json.Unmarshal(res, &auditSSHSettingsResponse) + if err != nil { + return AuditSSHSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return auditSSHSettingsResponse.Result, nil +} diff --git a/pkg/cloudflare-go/teams_audit_ssh_settings_test.go b/pkg/cloudflare-go/teams_audit_ssh_settings_test.go new file mode 100644 index 000000000..934f98099 --- /dev/null +++ b/pkg/cloudflare-go/teams_audit_ssh_settings_test.go @@ -0,0 +1,91 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetAuditSSHSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "public_key": "1pyl6I1tL7xfJuFYVzXlUW8uXXlpxegHXBzGCBKaSFA=", + "seed_id": "f1f968a9-83e7-401a-8abc-e0efe128425c", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AuditSSHSettings{ + PublicKey: "1pyl6I1tL7xfJuFYVzXlUW8uXXlpxegHXBzGCBKaSFA=", + SeedUUID: "f1f968a9-83e7-401a-8abc-e0efe128425c", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/audit_ssh_settings", handler) + + actual, _, err := client.GetAuditSSHSettings(context.Background(), AccountIdentifier(testAccountID), GetAuditSSHSettingsParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateAuditSSHSettings(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "public_key": "updated1tL7xfJuFYVzXlUW8uXXlpxegHXBzGCBKaSFA=", + "seed_id": "f1f968a9-83e7-401a-8abc-e0efe128425c", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := AuditSSHSettings{ + PublicKey: "updated1tL7xfJuFYVzXlUW8uXXlpxegHXBzGCBKaSFA=", + SeedUUID: "f1f968a9-83e7-401a-8abc-e0efe128425c", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/audit_ssh_settings", handler) + + actual, err := client.UpdateAuditSSHSettings(context.Background(), AccountIdentifier(testAccountID), UpdateAuditSSHSettingsParams{PublicKey: "updated1tL7xfJuFYVzXlUW8uXXlpxegHXBzGCBKaSFA="}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/teams_devices.go b/pkg/cloudflare-go/teams_devices.go new file mode 100644 index 000000000..96ec4d08b --- /dev/null +++ b/pkg/cloudflare-go/teams_devices.go @@ -0,0 +1,107 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type TeamsDevicesList struct { + Response + Result []TeamsDeviceListItem `json:"result"` +} + +type TeamsDeviceDetail struct { + Response + Result TeamsDeviceListItem `json:"result"` +} + +type TeamsDeviceListItem struct { + User UserItem `json:"user,omitempty"` + ID string `json:"id,omitempty"` + Key string `json:"key,omitempty"` + DeviceType string `json:"device_type,omitempty"` + Name string `json:"name,omitempty"` + Model string `json:"model,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + Deleted bool `json:"deleted,omitempty"` + Version string `json:"version,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + OSVersion string `json:"os_version,omitempty"` + OSDistroName string `json:"os_distro_name,omitempty"` + OsDistroRevision string `json:"os_distro_revision,omitempty"` + OSVersionExtra string `json:"os_version_extra,omitempty"` + MacAddress string `json:"mac_address,omitempty"` + IP string `json:"ip,omitempty"` + Created string `json:"created,omitempty"` + Updated string `json:"updated,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + RevokedAt string `json:"revoked_at,omitempty"` +} + +type UserItem struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +// ListTeamsDevice returns all devices for a given account. +// +// API reference : https://api.cloudflare.com/#devices-list-devices +func (api *API) ListTeamsDevices(ctx context.Context, accountID string) ([]TeamsDeviceListItem, error) { + uri := fmt.Sprintf("/%s/%s/devices", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TeamsDeviceListItem{}, err + } + + var response TeamsDevicesList + err = json.Unmarshal(res, &response) + if err != nil { + return []TeamsDeviceListItem{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// RevokeTeamsDevice revokes device with given identifiers. +// +// API reference : https://api.cloudflare.com/#devices-revoke-devices +func (api *API) RevokeTeamsDevices(ctx context.Context, accountID string, deviceIds []string) (Response, error) { + uri := fmt.Sprintf("/%s/%s/devices/revoke", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, deviceIds) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// GetTeamsDeviceDetails gets device details. +// +// API reference : https://api.cloudflare.com/#devices-device-details +func (api *API) GetTeamsDeviceDetails(ctx context.Context, accountID string, deviceID string) (TeamsDeviceListItem, error) { + uri := fmt.Sprintf("/%s/%s/devices/%s", AccountRouteRoot, accountID, deviceID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsDeviceListItem{}, err + } + + var response TeamsDeviceDetail + err = json.Unmarshal(res, &response) + if err != nil { + return TeamsDeviceListItem{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} diff --git a/pkg/cloudflare-go/teams_devices_test.go b/pkg/cloudflare-go/teams_devices_test.go new file mode 100644 index 000000000..3c78bb38d --- /dev/null +++ b/pkg/cloudflare-go/teams_devices_test.go @@ -0,0 +1,190 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeamsDevicesList(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "user": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "John Appleseed", + "email": "user@example.com" + }, + "key": "yek0SUYoOQ10vMGsIYAevozXUQpQtNFJFfFGqER/BGc=", + "device_type": "windows", + "name": "My mobile device", + "model": "MyPhone(pro-X)", + "manufacturer": "My phone corp", + "deleted": true, + "version": "1.0.0", + "serial_number": "EXAMPLEHMD6R", + "os_version": "10.0.0", + "os_distro_name": "ubuntu", + "os_distro_revision": "1.0.0", + "os_version_extra": "(a)", + "mac_address": "00-00-5E-00-53-00", + "ip": "192.0.2.1", + "created": "2017-06-14T00:00:00Z", + "updated": "2017-06-14T00:00:00Z", + "last_seen": "2017-06-14T00:00:00Z", + "revoked_at": "2017-06-14T00:00:00Z" + } + ] + } + `) + } + + want := []TeamsDeviceListItem{{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + User: UserItem{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "John Appleseed", + Email: "user@example.com", + }, + Key: "yek0SUYoOQ10vMGsIYAevozXUQpQtNFJFfFGqER/BGc=", + DeviceType: "windows", + Name: "My mobile device", + Model: "MyPhone(pro-X)", + Manufacturer: "My phone corp", + Deleted: true, + Version: "1.0.0", + SerialNumber: "EXAMPLEHMD6R", + OSVersion: "10.0.0", + OSDistroName: "ubuntu", + OsDistroRevision: "1.0.0", + OSVersionExtra: "(a)", + MacAddress: "00-00-5E-00-53-00", + IP: "192.0.2.1", + Created: "2017-06-14T00:00:00Z", + Updated: "2017-06-14T00:00:00Z", + LastSeen: "2017-06-14T00:00:00Z", + RevokedAt: "2017-06-14T00:00:00Z", + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/devices", handler) + + actual, err := client.ListTeamsDevices(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestRevokeTeamsDevices(t *testing.T) { + setup() + defer teardown() + + deviceIds := []string{"f174e90a-fafe-4643-bbbc-4a0ed4fc8415", "g174e90a-fafe-4643-bbbc-4a0ed4fc8415"} + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": null, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/revoke", handler) + + want := Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}} + + actual, err := client.RevokeTeamsDevices(context.Background(), testAccountID, deviceIds) + require.NoError(t, err) + assert.Equal(t, want, actual) +} + +func TestGetTeamsDeviceDetails(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "user": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "John Appleseed", + "email": "user@example.com" + }, + "key": "yek0SUYoOQ10vMGsIYAevozXUQpQtNFJFfFGqER/BGc=", + "device_type": "windows", + "name": "My mobile device", + "model": "MyPhone(pro-X)", + "manufacturer": "My phone corp", + "deleted": true, + "version": "1.0.0", + "serial_number": "EXAMPLEHMD6R", + "os_version": "10.0.0", + "mac_address": "00-00-5E-00-53-00", + "ip": "192.0.2.1", + "created": "2017-06-14T00:00:00Z", + "updated": "2017-06-14T00:00:00Z", + "last_seen": "2017-06-14T00:00:00Z", + "revoked_at": "2017-06-14T00:00:00Z" + } + } + `) + } + + want := TeamsDeviceListItem{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + User: UserItem{ + ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + Name: "John Appleseed", + Email: "user@example.com", + }, + Key: "yek0SUYoOQ10vMGsIYAevozXUQpQtNFJFfFGqER/BGc=", + DeviceType: "windows", + Name: "My mobile device", + Model: "MyPhone(pro-X)", + Manufacturer: "My phone corp", + Deleted: true, + Version: "1.0.0", + SerialNumber: "EXAMPLEHMD6R", + OSVersion: "10.0.0", + MacAddress: "00-00-5E-00-53-00", + IP: "192.0.2.1", + Created: "2017-06-14T00:00:00Z", + Updated: "2017-06-14T00:00:00Z", + LastSeen: "2017-06-14T00:00:00Z", + RevokedAt: "2017-06-14T00:00:00Z", + } + + deviceID := "f174e90a-fafe-4643-bbbc-4a0ed4fc8415" + + mux.HandleFunc("/accounts/"+testAccountID+"/devices/"+deviceID, handler) + + actual, err := client.GetTeamsDeviceDetails(context.Background(), testAccountID, deviceID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/teams_list.go b/pkg/cloudflare-go/teams_list.go new file mode 100644 index 000000000..066d7afff --- /dev/null +++ b/pkg/cloudflare-go/teams_list.go @@ -0,0 +1,330 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ErrMissingListID = errors.New("required missing list ID") + +// TeamsList represents a Teams List. +type TeamsList struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Items []TeamsListItem `json:"items,omitempty"` + Count uint64 `json:"count,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// TeamsListItem represents a single list item. +type TeamsListItem struct { + Value string `json:"value"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +// PatchTeamsList represents a patch request for appending/removing list items. +type PatchTeamsList struct { + ID string `json:"id"` + Append []TeamsListItem `json:"append"` + Remove []string `json:"remove"` +} + +// TeamsListListResponse represents the response from the list +// teams lists endpoint. +type TeamsListListResponse struct { + Result []TeamsList `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// TeamsListItemsListResponse represents the response from the list +// teams list items endpoint. +type TeamsListItemsListResponse struct { + Result []TeamsListItem `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// TeamsListDetailResponse is the API response, containing a single +// teams list. +type TeamsListDetailResponse struct { + Response + Result TeamsList `json:"result"` +} + +type ListTeamsListItemsParams struct { + ListID string `url:"-"` + + ResultInfo +} + +type ListTeamListsParams struct{} + +type CreateTeamsListParams struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Items []TeamsListItem `json:"items,omitempty"` + Count uint64 `json:"count,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type UpdateTeamsListParams struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Items []TeamsListItem `json:"items,omitempty"` + Count uint64 `json:"count,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type PatchTeamsListParams struct { + ID string `json:"id"` + Append []TeamsListItem `json:"append"` + Remove []string `json:"remove"` +} + +// ListTeamsLists returns all lists within an account. +// +// API reference: https://api.cloudflare.com/#teams-lists-list-teams-lists +func (api *API) ListTeamsLists(ctx context.Context, rc *ResourceContainer, params ListTeamListsParams) ([]TeamsList, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/gateway/lists", AccountRouteRoot, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TeamsList{}, ResultInfo{}, err + } + + var teamsListListResponse TeamsListListResponse + err = json.Unmarshal(res, &teamsListListResponse) + if err != nil { + return []TeamsList{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsListListResponse.Result, teamsListListResponse.ResultInfo, nil +} + +// GetTeamsList returns a single list based on the list ID. +// +// API reference: https://api.cloudflare.com/#teams-lists-teams-list-details +func (api *API) GetTeamsList(ctx context.Context, rc *ResourceContainer, listID string) (TeamsList, error) { + uri := fmt.Sprintf( + "/%s/%s/gateway/lists/%s", + rc.Level, + rc.Identifier, + listID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsList{}, err + } + + var teamsListDetailResponse TeamsListDetailResponse + err = json.Unmarshal(res, &teamsListDetailResponse) + if err != nil { + return TeamsList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsListDetailResponse.Result, nil +} + +// ListTeamsListItems returns all list items for a list. +// +// API reference: https://api.cloudflare.com/#teams-lists-teams-list-items +func (api *API) ListTeamsListItems(ctx context.Context, rc *ResourceContainer, params ListTeamsListItemsParams) ([]TeamsListItem, ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return []TeamsListItem{}, ResultInfo{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return []TeamsListItem{}, ResultInfo{}, ErrMissingAccountID + } + + if params.ListID == "" { + return []TeamsListItem{}, ResultInfo{}, ErrMissingListID + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 50 + } + + if params.Page < 1 { + params.Page = 1 + } + + var teamListItems []TeamsListItem + var lResponse TeamsListItemsListResponse + for { + lResponse = TeamsListItemsListResponse{} + uri := buildURI( + fmt.Sprintf("/%s/%s/gateway/lists/%s/items", rc.Level, rc.Identifier, params.ListID), + params, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TeamsListItem{}, ResultInfo{}, err + } + + err = json.Unmarshal(res, &lResponse) + if err != nil { + return []TeamsListItem{}, ResultInfo{}, fmt.Errorf("failed to unmarshal teams list JSON data: %w", err) + } + + teamListItems = append(teamListItems, lResponse.Result...) + params.ResultInfo = lResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return teamListItems, lResponse.ResultInfo, nil +} + +// CreateTeamsList creates a new teams list. +// +// API reference: https://api.cloudflare.com/#teams-lists-create-teams-list +func (api *API) CreateTeamsList(ctx context.Context, rc *ResourceContainer, params CreateTeamsListParams) (TeamsList, error) { + if rc.Level != AccountRouteLevel { + return TeamsList{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return TeamsList{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/%s/%s/gateway/lists", rc.Level, rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return TeamsList{}, err + } + + var teamsListDetailResponse TeamsListDetailResponse + err = json.Unmarshal(res, &teamsListDetailResponse) + if err != nil { + return TeamsList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsListDetailResponse.Result, nil +} + +// UpdateTeamsList updates an existing teams list. +// +// API reference: https://api.cloudflare.com/#teams-lists-update-teams-list +func (api *API) UpdateTeamsList(ctx context.Context, rc *ResourceContainer, params UpdateTeamsListParams) (TeamsList, error) { + if rc.Level != AccountRouteLevel { + return TeamsList{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return TeamsList{}, ErrMissingAccountID + } + + if params.ID == "" { + return TeamsList{}, fmt.Errorf("teams list ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/gateway/lists/%s", + rc.Level, + rc.Identifier, + params.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return TeamsList{}, err + } + + var teamsListDetailResponse TeamsListDetailResponse + err = json.Unmarshal(res, &teamsListDetailResponse) + if err != nil { + return TeamsList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsListDetailResponse.Result, nil +} + +// PatchTeamsList updates the items in an existing teams list. +// +// API reference: https://api.cloudflare.com/#teams-lists-patch-teams-list +func (api *API) PatchTeamsList(ctx context.Context, rc *ResourceContainer, listPatch PatchTeamsListParams) (TeamsList, error) { + if rc.Level != AccountRouteLevel { + return TeamsList{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return TeamsList{}, ErrMissingAccountID + } + + if listPatch.ID == "" { + return TeamsList{}, fmt.Errorf("teams list ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/gateway/lists/%s", + AccountRouteRoot, + rc.Identifier, + listPatch.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, listPatch) + if err != nil { + return TeamsList{}, err + } + + var teamsListDetailResponse TeamsListDetailResponse + err = json.Unmarshal(res, &teamsListDetailResponse) + if err != nil { + return TeamsList{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsListDetailResponse.Result, nil +} + +// DeleteTeamsList deletes a teams list. +// +// API reference: https://api.cloudflare.com/#teams-lists-delete-teams-list +func (api *API) DeleteTeamsList(ctx context.Context, rc *ResourceContainer, teamsListID string) error { + if rc.Level != AccountRouteLevel { + return fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + if rc.Identifier == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf( + "/%s/%s/gateway/lists/%s", + AccountRouteRoot, + rc.Identifier, + teamsListID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/teams_list_test.go b/pkg/cloudflare-go/teams_list_test.go new file mode 100644 index 000000000..6dbf384f1 --- /dev/null +++ b/pkg/cloudflare-go/teams_list_test.go @@ -0,0 +1,356 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTeamsLists(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "My Serial List", + "description": "My Description", + "type": "SERIAL", + "count": 1, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []TeamsList{{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My Serial List", + Description: "My Description", + Type: "SERIAL", + Count: 1, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists", handler) + + actual, _, err := client.ListTeamsLists(context.Background(), AccountIdentifier(testAccountID), ListTeamListsParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsList(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "My Serial List", + "description": "My Description", + "type": "SERIAL", + "count": 1, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + }, + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := TeamsList{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My Serial List", + Description: "My Description", + Type: "SERIAL", + Count: 1, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.GetTeamsList(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsListItems(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Method, http.MethodGet, "Expected method 'GET', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "value": "val1", + "created_at": "2014-01-01T05:20:00.12345Z" + }, + { + "value": "val2", + "created_at": "2014-01-01T05:20:00.12345Z" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []TeamsListItem{ + { + Value: "val1", + CreatedAt: &createdAt, + }, + { + Value: "val2", + CreatedAt: &createdAt, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists/480f4f69-1a28-4fdd-9240-1ed29f0ac1db/items", handler) + + actual, _, err := client.ListTeamsListItems(context.Background(), AccountIdentifier(testAccountID), ListTeamsListItemsParams{ListID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateTeamsList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "My Serial List", + "description": "My Description", + "type": "SERIAL", + "count": 1, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + teamsList := TeamsList{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My Serial List", + Description: "My Description", + Type: "SERIAL", + Count: 1, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists", handler) + + actual, err := client.CreateTeamsList(context.Background(), AccountIdentifier(testAccountID), CreateTeamsListParams{ + Name: "My Serial List", + Description: "My Description", + Type: "SERIAL", + Count: 1, + }) + + if assert.NoError(t, err) { + assert.Equal(t, teamsList, actual) + } +} + +func TestUpdateTeamsList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "My Serial List", + "description": "My Updated Description", + "type": "SERIAL", + "count": 1, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + teamsList := TeamsList{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My Serial List", + Description: "My Updated Description", + Type: "SERIAL", + Count: 1, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.UpdateTeamsList(context.Background(), AccountIdentifier(testAccountID), UpdateTeamsListParams{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My Serial List", + Description: "My Updated Description", + Type: "SERIAL", + Count: 1, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }) + + if assert.NoError(t, err) { + assert.Equal(t, teamsList, actual) + } +} + +func TestUpdateTeamsListWithMissingID(t *testing.T) { + setup() + defer teardown() + + _, err := client.UpdateTeamsList(context.Background(), AccountIdentifier(testAccountID), UpdateTeamsListParams{}) + assert.EqualError(t, err, "teams list ID cannot be empty") +} + +func TestPatchTeamsList(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + "name": "My Serial List", + "description": "My Updated Description", + "type": "SERIAL", + "count": 1, + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z" + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + teamsList := TeamsList{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Name: "My Serial List", + Description: "My Updated Description", + Type: "SERIAL", + Count: 1, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + + actual, err := client.PatchTeamsList(context.Background(), AccountIdentifier(testAccountID), PatchTeamsListParams{ + ID: "480f4f69-1a28-4fdd-9240-1ed29f0ac1db", + Append: []TeamsListItem{{Value: "abcd-1234"}}, + Remove: []string{"def-5678"}, + }) + + if assert.NoError(t, err) { + assert.Equal(t, teamsList, actual) + } +} + +func TestDeleteTeamsList(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db" + } + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/lists/480f4f69-1a28-4fdd-9240-1ed29f0ac1db", handler) + err := client.DeleteTeamsList(context.Background(), AccountIdentifier(testAccountID), "480f4f69-1a28-4fdd-9240-1ed29f0ac1db") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/teams_locations.go b/pkg/cloudflare-go/teams_locations.go new file mode 100644 index 000000000..b6c58304e --- /dev/null +++ b/pkg/cloudflare-go/teams_locations.go @@ -0,0 +1,155 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type TeamsLocationsListResponse struct { + Response + ResultInfo `json:"result_info"` + Result []TeamsLocation `json:"result"` +} + +type TeamsLocationDetailResponse struct { + Response + Result TeamsLocation `json:"result"` +} + +type TeamsLocationNetwork struct { + ID string `json:"id"` + Network string `json:"network"` +} + +type TeamsLocation struct { + ID string `json:"id"` + Name string `json:"name"` + Networks []TeamsLocationNetwork `json:"networks"` + PolicyIDs []string `json:"policy_ids"` + Ip string `json:"ip,omitempty"` + Subdomain string `json:"doh_subdomain"` + AnonymizedLogsEnabled bool `json:"anonymized_logs_enabled"` + IPv4Destination string `json:"ipv4_destination"` + ClientDefault bool `json:"client_default"` + ECSSupport *bool `json:"ecs_support,omitempty"` + + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// TeamsLocations returns all locations within an account. +// +// API reference: https://api.cloudflare.com/#teams-locations-list-teams-locations +func (api *API) TeamsLocations(ctx context.Context, accountID string) ([]TeamsLocation, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/gateway/locations", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TeamsLocation{}, ResultInfo{}, err + } + + var teamsLocationsListResponse TeamsLocationsListResponse + err = json.Unmarshal(res, &teamsLocationsListResponse) + if err != nil { + return []TeamsLocation{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsLocationsListResponse.Result, teamsLocationsListResponse.ResultInfo, nil +} + +// TeamsLocation returns a single location based on the ID. +// +// API reference: https://api.cloudflare.com/#teams-locations-teams-location-details +func (api *API) TeamsLocation(ctx context.Context, accountID, locationID string) (TeamsLocation, error) { + uri := fmt.Sprintf( + "/%s/%s/gateway/locations/%s", + AccountRouteRoot, + accountID, + locationID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsLocation{}, err + } + + var teamsLocationDetailResponse TeamsLocationDetailResponse + err = json.Unmarshal(res, &teamsLocationDetailResponse) + if err != nil { + return TeamsLocation{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsLocationDetailResponse.Result, nil +} + +// CreateTeamsLocation creates a new teams location. +// +// API reference: https://api.cloudflare.com/#teams-locations-create-teams-location +func (api *API) CreateTeamsLocation(ctx context.Context, accountID string, teamsLocation TeamsLocation) (TeamsLocation, error) { + uri := fmt.Sprintf("/%s/%s/gateway/locations", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, teamsLocation) + if err != nil { + return TeamsLocation{}, err + } + + var teamsLocationDetailResponse TeamsLocationDetailResponse + err = json.Unmarshal(res, &teamsLocationDetailResponse) + if err != nil { + return TeamsLocation{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsLocationDetailResponse.Result, nil +} + +// UpdateTeamsLocation updates an existing teams location. +// +// API reference: https://api.cloudflare.com/#teams-locations-update-teams-location +func (api *API) UpdateTeamsLocation(ctx context.Context, accountID string, teamsLocation TeamsLocation) (TeamsLocation, error) { + if teamsLocation.ID == "" { + return TeamsLocation{}, fmt.Errorf("teams location ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/gateway/locations/%s", + AccountRouteRoot, + accountID, + teamsLocation.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, teamsLocation) + if err != nil { + return TeamsLocation{}, err + } + + var teamsLocationDetailResponse TeamsLocationDetailResponse + err = json.Unmarshal(res, &teamsLocationDetailResponse) + if err != nil { + return TeamsLocation{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsLocationDetailResponse.Result, nil +} + +// DeleteTeamsLocation deletes a teams location. +// +// API reference: https://api.cloudflare.com/#teams-locations-delete-teams-location +func (api *API) DeleteTeamsLocation(ctx context.Context, accountID, teamsLocationID string) error { + uri := fmt.Sprintf( + "/%s/%s/gateway/locations/%s", + AccountRouteRoot, + accountID, + teamsLocationID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/teams_locations_test.go b/pkg/cloudflare-go/teams_locations_test.go new file mode 100644 index 000000000..4eb615b1b --- /dev/null +++ b/pkg/cloudflare-go/teams_locations_test.go @@ -0,0 +1,278 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTeamsLocations(t *testing.T) { + setup() + defer teardown() + + 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") + _, err := fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "0f8185414dec4a5e9034f3d917c17890", + "name": "home", + "networks": [ + { + "network": "198.51.100.1/32", + "id": "8e4c7835436345f0ab395429b187a076" + } + ], + "policy_ids": [], + "ip": "2a06:98c1:54::2419", + "doh_subdomain": "q15l7x2lbw", + "anonymized_logs_enabled": false, + "ipv4_destination": null, + "client_default": false, + "ecs_support": false, + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + ] + } + `) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := []TeamsLocation{{ + ID: "0f8185414dec4a5e9034f3d917c17890", + Name: "home", + Networks: []TeamsLocationNetwork{{ID: "8e4c7835436345f0ab395429b187a076", Network: "198.51.100.1/32"}}, + PolicyIDs: []string{}, + Ip: "2a06:98c1:54::2419", + Subdomain: "q15l7x2lbw", + AnonymizedLogsEnabled: false, + IPv4Destination: "", + ClientDefault: false, + ECSSupport: BoolPtr(false), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }} + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/locations", testAccountID), handler) + + actual, _, err := client.TeamsLocations(context.Background(), testAccountID) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestTeamsLocation(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + 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") + _, err := fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "name": "home", + "networks": [ + { + "network": "198.51.100.1/32", + "id": "8e4c7835436345f0ab395429b187a076" + } + ], + "policy_ids": [], + "ip": "2a06:98c1:54::2419", + "doh_subdomain": "q15l7x2lbw", + "anonymized_logs_enabled": false, + "ipv4_destination": null, + "client_default": false, + "ecs_support": false, + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + }`, id) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := TeamsLocation{ + ID: id, + Name: "home", + Networks: []TeamsLocationNetwork{{ID: "8e4c7835436345f0ab395429b187a076", Network: "198.51.100.1/32"}}, + PolicyIDs: []string{}, + Ip: "2a06:98c1:54::2419", + Subdomain: "q15l7x2lbw", + AnonymizedLogsEnabled: false, + IPv4Destination: "", + ClientDefault: false, + ECSSupport: BoolPtr(false), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/locations/%s", testAccountID, id), handler) + + actual, err := client.TeamsLocation(context.Background(), testAccountID, id) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestCreateTeamsLocation(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err := fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "name": "test", + "networks": [ + { + "network": "198.51.100.1/32", + "id": "8e4c7835436345f0ab395429b187a076" + } + ], + "policy_ids": [], + "ip": "2a06:98c1:54::2419", + "doh_subdomain": "q15l7x2lbw", + "anonymized_logs_enabled": false, + "ipv4_destination": null, + "client_default": false, + "ecs_support": false, + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + }`, id) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := TeamsLocation{ + ID: id, + Name: "test", + Networks: []TeamsLocationNetwork{{ID: "8e4c7835436345f0ab395429b187a076", Network: "198.51.100.1/32"}}, + PolicyIDs: []string{}, + Ip: "2a06:98c1:54::2419", + Subdomain: "q15l7x2lbw", + AnonymizedLogsEnabled: false, + IPv4Destination: "", + ClientDefault: false, + ECSSupport: BoolPtr(false), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/locations", testAccountID), handler) + + actual, err := client.CreateTeamsLocation(context.Background(), testAccountID, TeamsLocation{ + Name: "test", + ClientDefault: true, + Networks: []TeamsLocationNetwork{}, + }) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestUpdateTeamsLocation(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err := fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "name": "new", + "networks": [ + { + "network": "198.51.100.1/32", + "id": "8e4c7835436345f0ab395429b187a076" + } + ], + "policy_ids": [], + "ip": "2a06:98c1:54::2419", + "doh_subdomain": "q15l7x2lbw", + "anonymized_logs_enabled": false, + "ipv4_destination": null, + "client_default": false, + "ecs_support": false, + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + }`, id) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := TeamsLocation{ + ID: id, + Name: "new", + Networks: []TeamsLocationNetwork{{ID: "8e4c7835436345f0ab395429b187a076", Network: "198.51.100.1/32"}}, + PolicyIDs: []string{}, + Ip: "2a06:98c1:54::2419", + Subdomain: "q15l7x2lbw", + AnonymizedLogsEnabled: false, + IPv4Destination: "", + ClientDefault: false, + ECSSupport: BoolPtr(false), + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/locations/%s", testAccountID, id), handler) + + actual, err := client.UpdateTeamsLocation(context.Background(), testAccountID, TeamsLocation{ + ID: id, + Name: "new", + ClientDefault: false, + Networks: []TeamsLocationNetwork{{ID: "", Network: "1.2.3.4"}}, + }) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestDeleteTeamsLocation(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + 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") + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/locations/%s", testAccountID, id), handler) + err := client.DeleteTeamsLocation(context.Background(), testAccountID, id) + require.Nil(t, err) +} diff --git a/pkg/cloudflare-go/teams_proxy_endpoints.go b/pkg/cloudflare-go/teams_proxy_endpoints.go new file mode 100644 index 000000000..8a658e20e --- /dev/null +++ b/pkg/cloudflare-go/teams_proxy_endpoints.go @@ -0,0 +1,137 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type TeamsProxyEndpointListResponse struct { + Response + ResultInfo `json:"result_info"` + Result []TeamsProxyEndpoint `json:"result"` +} +type TeamsProxyEndpointDetailResponse struct { + Response + Result TeamsProxyEndpoint `json:"result"` +} + +type TeamsProxyEndpoint struct { + ID string `json:"id"` + Name string `json:"name"` + IPs []string `json:"ips"` + Subdomain string `json:"subdomain"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +// TeamsProxyEndpoint returns a single proxy endpoints within an account. +// +// API reference: https://api.cloudflare.com/#zero-trust-gateway-proxy-endpoints-proxy-endpoint-details +func (api *API) TeamsProxyEndpoint(ctx context.Context, accountID, proxyEndpointID string) (TeamsProxyEndpoint, error) { + uri := fmt.Sprintf("/%s/%s/gateway/proxy_endpoints/%s", AccountRouteRoot, accountID, proxyEndpointID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsProxyEndpoint{}, err + } + + var teamsProxyEndpointDetailResponse TeamsProxyEndpointDetailResponse + err = json.Unmarshal(res, &teamsProxyEndpointDetailResponse) + if err != nil { + return TeamsProxyEndpoint{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsProxyEndpointDetailResponse.Result, nil +} + +// TeamsProxyEndpoints returns all proxy endpoints within an account. +// +// API reference: https://api.cloudflare.com/#zero-trust-gateway-proxy-endpoints-list-proxy-endpoints +func (api *API) TeamsProxyEndpoints(ctx context.Context, accountID string) ([]TeamsProxyEndpoint, ResultInfo, error) { + uri := fmt.Sprintf("/%s/%s/gateway/proxy_endpoints", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TeamsProxyEndpoint{}, ResultInfo{}, err + } + + var teamsProxyEndpointListResponse TeamsProxyEndpointListResponse + err = json.Unmarshal(res, &teamsProxyEndpointListResponse) + if err != nil { + return []TeamsProxyEndpoint{}, ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsProxyEndpointListResponse.Result, teamsProxyEndpointListResponse.ResultInfo, nil +} + +// CreateTeamsProxyEndpoint creates a new proxy endpoint. +// +// API reference: https://api.cloudflare.com/#zero-trust-gateway-proxy-endpoints-create-proxy-endpoint +func (api *API) CreateTeamsProxyEndpoint(ctx context.Context, accountID string, proxyEndpoint TeamsProxyEndpoint) (TeamsProxyEndpoint, error) { + uri := fmt.Sprintf("/%s/%s/gateway/proxy_endpoints", AccountRouteRoot, accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, proxyEndpoint) + if err != nil { + return TeamsProxyEndpoint{}, err + } + + var teamsProxyEndpointDetailResponse TeamsProxyEndpointDetailResponse + err = json.Unmarshal(res, &teamsProxyEndpointDetailResponse) + if err != nil { + return TeamsProxyEndpoint{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsProxyEndpointDetailResponse.Result, nil +} + +// UpdateTeamsProxyEndpoint updates an existing teams Proxy Endpoint. +// +// API reference: https://api.cloudflare.com/#zero-trust-gateway-proxy-endpoints-update-proxy-endpoint +func (api *API) UpdateTeamsProxyEndpoint(ctx context.Context, accountID string, proxyEndpoint TeamsProxyEndpoint) (TeamsProxyEndpoint, error) { + if proxyEndpoint.ID == "" { + return TeamsProxyEndpoint{}, fmt.Errorf("Proxy Endpoint ID cannot be empty") + } + + uri := fmt.Sprintf( + "/%s/%s/gateway/proxy_endpoints/%s", + AccountRouteRoot, + accountID, + proxyEndpoint.ID, + ) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, proxyEndpoint) + if err != nil { + return TeamsProxyEndpoint{}, err + } + + var teamsProxyEndpointDetailResponse TeamsProxyEndpointDetailResponse + err = json.Unmarshal(res, &teamsProxyEndpointDetailResponse) + if err != nil { + return TeamsProxyEndpoint{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsProxyEndpointDetailResponse.Result, nil +} + +// DeleteTeamsProxyEndpoint deletes a teams Proxy Endpoint. +// +// API reference: https://api.cloudflare.com/#zero-trust-gateway-proxy-endpoints-delete-proxy-endpoint +func (api *API) DeleteTeamsProxyEndpoint(ctx context.Context, accountID, proxyEndpointID string) error { + uri := fmt.Sprintf( + "/%s/%s/gateway/proxy_endpoints/%s", + AccountRouteRoot, + accountID, + proxyEndpointID, + ) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/teams_proxy_endpoints_test.go b/pkg/cloudflare-go/teams_proxy_endpoints_test.go new file mode 100644 index 000000000..e4bc158a1 --- /dev/null +++ b/pkg/cloudflare-go/teams_proxy_endpoints_test.go @@ -0,0 +1,203 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProxyEndpoint(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + 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") + _, err := fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0f8185414dec4a5e9034f3d917c17890", + "name": "home", + "ips": ["192.0.2.1/32"], + "subdomain": "q15l7x2lbw", + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + }`) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := TeamsProxyEndpoint{ + ID: "0f8185414dec4a5e9034f3d917c17890", + Name: "home", + IPs: []string{"192.0.2.1/32"}, + Subdomain: "q15l7x2lbw", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/proxy_endpoints/%s", testAccountID, id), handler) + + actual, err := client.TeamsProxyEndpoint(context.Background(), testAccountID, id) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestProxyEndpoints(t *testing.T) { + setup() + defer teardown() + + 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") + _, err := fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "0f8185414dec4a5e9034f3d917c17890", + "name": "home", + "ips": ["192.0.2.1/32"], + "subdomain": "q15l7x2lbw", + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + ] + } + `) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := []TeamsProxyEndpoint{{ + ID: "0f8185414dec4a5e9034f3d917c17890", + Name: "home", + IPs: []string{"192.0.2.1/32"}, + Subdomain: "q15l7x2lbw", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + }} + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/proxy_endpoints", testAccountID), handler) + + actual, _, err := client.TeamsProxyEndpoints(context.Background(), testAccountID) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestCreateProxyEndpoint(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err := fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "name": "test", + "ips": ["192.0.2.1/32"], + "subdomain": "q15l7x2lbw", + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + }`, id) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := TeamsProxyEndpoint{ + ID: id, + Name: "test", + IPs: []string{"192.0.2.1/32"}, + Subdomain: "q15l7x2lbw", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/proxy_endpoints", testAccountID), handler) + + actual, err := client.CreateTeamsProxyEndpoint(context.Background(), testAccountID, TeamsProxyEndpoint{}) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestUpdateProxyEndpoint(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, err := fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s", + "name": "new", + "ips": ["192.0.2.1/32"], + "subdomain": "q15l7x2lbw", + "created_at": "2020-05-18T22:07:03Z", + "updated_at": "2020-05-18T22:07:05Z" + } + }`, id) + require.Nil(t, err) + } + + createdAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:03Z") + updatedAt, _ := time.Parse(time.RFC3339, "2020-05-18T22:07:05Z") + + want := TeamsProxyEndpoint{ + ID: id, + Name: "new", + IPs: []string{"192.0.2.1/32"}, + Subdomain: "q15l7x2lbw", + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/proxy_endpoints/%s", testAccountID, id), handler) + + actual, err := client.UpdateTeamsProxyEndpoint(context.Background(), testAccountID, TeamsProxyEndpoint{ + ID: id, + }) + require.Nil(t, err) + assert.Equal(t, want, actual) +} + +func TestDeleteProxyEndpoint(t *testing.T) { + setup() + defer teardown() + + id := "0f8185414dec4a5e9034f3d917c17890" + 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") + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/gateway/proxy_endpoints/%s", testAccountID, id), handler) + err := client.DeleteTeamsProxyEndpoint(context.Background(), testAccountID, id) + require.Nil(t, err) +} diff --git a/pkg/cloudflare-go/teams_rules.go b/pkg/cloudflare-go/teams_rules.go new file mode 100644 index 000000000..b03c01215 --- /dev/null +++ b/pkg/cloudflare-go/teams_rules.go @@ -0,0 +1,356 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type TeamsRuleSettings struct { + // list of ipv4 or ipv6 ips to override with, when action is set to dns override + OverrideIPs []string `json:"override_ips"` + + // show this string at block page caused by this rule + BlockReason string `json:"block_reason"` + + // host name to override with when action is set to dns override. Can not be used with OverrideIPs + OverrideHost string `json:"override_host"` + + // settings for browser isolation actions + BISOAdminControls *TeamsBISOAdminControlSettings `json:"biso_admin_controls"` + + // settings for l4(network) level overrides + L4Override *TeamsL4OverrideSettings `json:"l4override"` + + // settings for adding headers to http requests + AddHeaders http.Header `json:"add_headers"` + + // settings for session check in allow action + CheckSession *TeamsCheckSessionSettings `json:"check_session"` + + // Enable block page on rules with action block + BlockPageEnabled bool `json:"block_page_enabled"` + + // whether to disable dnssec validation for allow action + InsecureDisableDNSSECValidation bool `json:"insecure_disable_dnssec_validation"` + + // settings for rules with egress action + EgressSettings *EgressSettings `json:"egress"` + + // DLP payload logging configuration + PayloadLog *TeamsDlpPayloadLogSettings `json:"payload_log"` + + //AuditSsh Settings + AuditSSH *AuditSSHRuleSettings `json:"audit_ssh"` + + // Turns on ip category based filter on dns if the rule contains dns category checks + IPCategories bool `json:"ip_categories"` + + // Allow parent MSP accounts to enable bypass their children's rules. Do not set them for non MSP accounts. + AllowChildBypass *bool `json:"allow_child_bypass,omitempty"` + + // Allow child MSP accounts to bypass their parent's rules. Do not set them for non MSP accounts. + BypassParentRule *bool `json:"bypass_parent_rule,omitempty"` + + // Action taken when an untrusted origin certificate error occurs in a http allow rule + UntrustedCertSettings *UntrustedCertSettings `json:"untrusted_cert"` + + // Specifies that a resolver policy should use Cloudflare's DNS Resolver. + ResolveDnsThroughCloudflare *bool `json:"resolve_dns_through_cloudflare,omitempty"` + + // Resolver policy settings. + DnsResolverSettings *TeamsDnsResolverSettings `json:"dns_resolvers,omitempty"` + + NotificationSettings *TeamsNotificationSettings `json:"notification_settings"` +} + +type TeamsGatewayUntrustedCertAction string + +const ( + UntrustedCertPassthrough TeamsGatewayUntrustedCertAction = "pass_through" + UntrustedCertBlock TeamsGatewayUntrustedCertAction = "block" + UntrustedCertError TeamsGatewayUntrustedCertAction = "error" +) + +type UntrustedCertSettings struct { + Action TeamsGatewayUntrustedCertAction `json:"action"` +} + +type TeamsNotificationSettings struct { + Enabled *bool `json:"enabled,omitempty"` + Message string `json:"msg"` + SupportURL string `json:"support_url"` +} + +type AuditSSHRuleSettings struct { + CommandLogging bool `json:"command_logging"` +} + +type EgressSettings struct { + Ipv6Range string `json:"ipv6"` + Ipv4 string `json:"ipv4"` + Ipv4Fallback string `json:"ipv4_fallback"` +} + +// TeamsL4OverrideSettings used in l4 filter type rule with action set to override. +type TeamsL4OverrideSettings struct { + IP string `json:"ip,omitempty"` + Port int `json:"port,omitempty"` +} + +type TeamsBISOAdminControlSettings struct { + DisablePrinting bool `json:"dp"` + DisableCopyPaste bool `json:"dcp"` + DisableDownload bool `json:"dd"` + DisableUpload bool `json:"du"` + DisableKeyboard bool `json:"dk"` + DisableClipboardRedirection bool `json:"dcr"` +} + +type TeamsCheckSessionSettings struct { + Enforce bool `json:"enforce"` + Duration Duration `json:"duration"` +} + +type ( + TeamsDnsResolverSettings struct { + V4Resolvers []TeamsDnsResolverAddressV4 `json:"ipv4,omitempty"` + V6Resolvers []TeamsDnsResolverAddressV6 `json:"ipv6,omitempty"` + } + + TeamsDnsResolverAddressV4 struct { + TeamsDnsResolverAddress + } + + TeamsDnsResolverAddressV6 struct { + TeamsDnsResolverAddress + } + + TeamsDnsResolverAddress struct { + IP string `json:"ip"` + Port *int `json:"port,omitempty"` + VnetID string `json:"vnet_id,omitempty"` + RouteThroughPrivateNetwork *bool `json:"route_through_private_network,omitempty"` + } +) + +type TeamsDlpPayloadLogSettings struct { + Enabled bool `json:"enabled"` +} + +type TeamsFilterType string + +type TeamsGatewayAction string + +const ( + HttpFilter TeamsFilterType = "http" + DnsFilter TeamsFilterType = "dns" + L4Filter TeamsFilterType = "l4" + EgressFilter TeamsFilterType = "egress" + DnsResolverFilter TeamsFilterType = "dns_resolver" +) + +const ( + Allow TeamsGatewayAction = "allow" // dns|http|l4 + Block TeamsGatewayAction = "block" // dns|http|l4 + SafeSearch TeamsGatewayAction = "safesearch" // dns + YTRestricted TeamsGatewayAction = "ytrestricted" // dns + On TeamsGatewayAction = "on" // http + Off TeamsGatewayAction = "off" // http + Scan TeamsGatewayAction = "scan" // http + NoScan TeamsGatewayAction = "noscan" // http + Isolate TeamsGatewayAction = "isolate" // http + NoIsolate TeamsGatewayAction = "noisolate" // http + Override TeamsGatewayAction = "override" // http + L4Override TeamsGatewayAction = "l4_override" // l4 + Egress TeamsGatewayAction = "egress" // egress + AuditSSH TeamsGatewayAction = "audit_ssh" // l4 + Resolve TeamsGatewayAction = "resolve" // resolve +) + +func TeamsRulesActionValues() []string { + return []string{ + string(Allow), + string(Block), + string(SafeSearch), + string(YTRestricted), + string(On), + string(Off), + string(Scan), + string(NoScan), + string(Isolate), + string(NoIsolate), + string(Override), + string(L4Override), + string(Egress), + string(AuditSSH), + string(Resolve), + } +} + +func TeamsRulesUntrustedCertActionValues() []string { + return []string{ + string(UntrustedCertPassthrough), + string(UntrustedCertBlock), + string(UntrustedCertError), + } +} + +// TeamsRule represents an Teams wirefilter rule. +type TeamsRule struct { + ID string `json:"id,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Precedence uint64 `json:"precedence"` + Enabled bool `json:"enabled"` + Action TeamsGatewayAction `json:"action"` + Filters []TeamsFilterType `json:"filters"` + Traffic string `json:"traffic"` + Identity string `json:"identity"` + DevicePosture string `json:"device_posture"` + Version uint64 `json:"version"` + RuleSettings TeamsRuleSettings `json:"rule_settings,omitempty"` +} + +// TeamsRuleResponse is the API response, containing a single rule. +type TeamsRuleResponse struct { + Response + Result TeamsRule `json:"result"` +} + +// TeamsRuleResponse is the API response, containing an array of rules. +type TeamsRulesResponse struct { + Response + Result []TeamsRule `json:"result"` +} + +// TeamsRulePatchRequest is used to patch an existing rule. +type TeamsRulePatchRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Precedence uint64 `json:"precedence"` + Enabled bool `json:"enabled"` + Action TeamsGatewayAction `json:"action"` + RuleSettings TeamsRuleSettings `json:"rule_settings,omitempty"` +} + +// TeamsRules returns all rules within an account. +// +// API reference: https://api.cloudflare.com/#teams-rules-properties +func (api *API) TeamsRules(ctx context.Context, accountID string) ([]TeamsRule, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/rules", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TeamsRule{}, err + } + + var teamsRulesResponse TeamsRulesResponse + err = json.Unmarshal(res, &teamsRulesResponse) + if err != nil { + return []TeamsRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsRulesResponse.Result, nil +} + +// TeamsRule returns the rule with rule ID in the URL. +// +// API reference: https://api.cloudflare.com/#teams-rules-properties +func (api *API) TeamsRule(ctx context.Context, accountID string, ruleId string) (TeamsRule, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/rules/%s", accountID, ruleId) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TeamsRule{}, err + } + + var teamsRuleResponse TeamsRuleResponse + err = json.Unmarshal(res, &teamsRuleResponse) + if err != nil { + return TeamsRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsRuleResponse.Result, nil +} + +// TeamsCreateRule creates a rule with wirefilter expression. +// +// API reference: https://api.cloudflare.com/#teams-rules-properties +func (api *API) TeamsCreateRule(ctx context.Context, accountID string, rule TeamsRule) (TeamsRule, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/rules", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, rule) + if err != nil { + return TeamsRule{}, err + } + + var teamsRuleResponse TeamsRuleResponse + err = json.Unmarshal(res, &teamsRuleResponse) + if err != nil { + return TeamsRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsRuleResponse.Result, nil +} + +// TeamsUpdateRule updates a rule with wirefilter expression. +// +// API reference: https://api.cloudflare.com/#teams-rules-properties +func (api *API) TeamsUpdateRule(ctx context.Context, accountID string, ruleId string, rule TeamsRule) (TeamsRule, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/rules/%s", accountID, ruleId) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, rule) + if err != nil { + return TeamsRule{}, err + } + + var teamsRuleResponse TeamsRuleResponse + err = json.Unmarshal(res, &teamsRuleResponse) + if err != nil { + return TeamsRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsRuleResponse.Result, nil +} + +// TeamsPatchRule patches a rule associated values. +// +// API reference: https://api.cloudflare.com/#teams-rules-properties +func (api *API) TeamsPatchRule(ctx context.Context, accountID string, ruleId string, rule TeamsRulePatchRequest) (TeamsRule, error) { + uri := fmt.Sprintf("/accounts/%s/gateway/rules/%s", accountID, ruleId) + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, rule) + if err != nil { + return TeamsRule{}, err + } + + var teamsRuleResponse TeamsRuleResponse + err = json.Unmarshal(res, &teamsRuleResponse) + if err != nil { + return TeamsRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return teamsRuleResponse.Result, nil +} + +// TeamsDeleteRule deletes a rule. +// +// API reference: https://api.cloudflare.com/#teams-rules-properties +func (api *API) TeamsDeleteRule(ctx context.Context, accountID string, ruleId string) error { + uri := fmt.Sprintf("/accounts/%s/gateway/rules/%s", accountID, ruleId) + + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/teams_rules_test.go b/pkg/cloudflare-go/teams_rules_test.go new file mode 100644 index 000000000..226ce50bb --- /dev/null +++ b/pkg/cloudflare-go/teams_rules_test.go @@ -0,0 +1,770 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTeamsRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "7559a944-3dd7-41bf-b183-360a814a8c36", + "name": "rule1", + "description": "rule description", + "precedence": 1000, + "enabled": false, + "action": "isolate", + "filters": [ + "http" + ], + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "deleted_at": null, + "traffic": "http.host == \"example.com\"", + "identity": "", + "version": 1, + "rule_settings": { + "block_page_enabled": false, + "block_reason": "", + "override_ips": null, + "override_host": "", + "l4override": null, + "biso_admin_controls": null, + "add_headers": null, + "check_session": { + "enforce": true, + "duration": "15m0s" + }, + "insecure_disable_dnssec_validation": false, + "untrusted_cert": { + "action": "error" + }, + "dns_resolvers": { + "ipv4": [ + {"ip": "10.0.0.2", "port": 5053}, + { + "ip": "192.168.0.2", + "vnet_id": "16fd7a32-11f0-4687-a0bb-7031d241e184", + "route_through_private_network": true + } + ], + "ipv6": [ + {"ip": "2460::1"} + ] + }, + "notification_settings": { + "enabled": true, + "msg": "message", + "support_url": "https://hello.com" + } + } + }, + { + "id": "9ae57318-f32e-46b3-b889-48dd6dcc49af", + "name": "rule2", + "description": "rule description 2", + "precedence": 2000, + "enabled": true, + "action": "block", + "filters": [ + "http" + ], + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "deleted_at": null, + "traffic": "http.host == \"abcd.com\"", + "identity": "", + "version": 1, + "rule_settings": { + "block_page_enabled": true, + "block_reason": "", + "override_ips": null, + "override_host": "", + "l4override": null, + "biso_admin_controls": null, + "add_headers": null, + "check_session": null, + "insecure_disable_dnssec_validation": true, + "untrusted_cert": { + "action": "pass_through" + }, + "resolve_dns_through_cloudflare": true + } + } + ] + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := []TeamsRule{{ + ID: "7559a944-3dd7-41bf-b183-360a814a8c36", + Name: "rule1", + Description: "rule description", + Precedence: 1000, + Enabled: false, + Action: Isolate, + Filters: []TeamsFilterType{HttpFilter}, + Traffic: `http.host == "example.com"`, + DevicePosture: "", + Identity: "", + Version: 1, + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: &TeamsCheckSessionSettings{ + Enforce: true, + Duration: Duration{900 * time.Second}, + }, + InsecureDisableDNSSECValidation: false, + UntrustedCertSettings: &UntrustedCertSettings{ + Action: UntrustedCertError, + }, + DnsResolverSettings: &TeamsDnsResolverSettings{ + V4Resolvers: []TeamsDnsResolverAddressV4{ + { + TeamsDnsResolverAddress{ + IP: "10.0.0.2", + Port: IntPtr(5053), + }, + }, + { + TeamsDnsResolverAddress{ + IP: "192.168.0.2", + VnetID: "16fd7a32-11f0-4687-a0bb-7031d241e184", + RouteThroughPrivateNetwork: BoolPtr(true), + }, + }, + }, + V6Resolvers: []TeamsDnsResolverAddressV6{ + { + TeamsDnsResolverAddress{ + IP: "2460::1", + }, + }, + }, + }, + NotificationSettings: &TeamsNotificationSettings{ + Enabled: BoolPtr(true), + Message: "message", + SupportURL: "https://hello.com", + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + DeletedAt: nil, + }, + { + ID: "9ae57318-f32e-46b3-b889-48dd6dcc49af", + Name: "rule2", + Description: "rule description 2", + Precedence: 2000, + Enabled: true, + Action: Block, + Filters: []TeamsFilterType{HttpFilter}, + Traffic: `http.host == "abcd.com"`, + Identity: "", + DevicePosture: "", + Version: 1, + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: true, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: nil, + // setting is invalid for block rules, just testing serialization here + InsecureDisableDNSSECValidation: true, + UntrustedCertSettings: &UntrustedCertSettings{ + Action: UntrustedCertPassthrough, + }, + ResolveDnsThroughCloudflare: BoolPtr(true), + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + DeletedAt: nil, + }} + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules", handler) + + actual, err := client.TeamsRules(context.Background(), testAccountID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "7559a944-3dd7-41bf-b183-360a814a8c36", + "name": "rule1", + "description": "rule description", + "precedence": 1000, + "enabled": false, + "action": "isolate", + "filters": [ + "http" + ], + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-01-01T05:20:00.12345Z", + "deleted_at": null, + "traffic": "http.host == \"abcd.com\"", + "identity": "", + "version": 1, + "rule_settings": { + "block_page_enabled": false, + "block_reason": "", + "override_ips": null, + "override_host": "", + "l4override": null, + "biso_admin_controls": null, + "add_headers": null, + "check_session": { + "enforce": true, + "duration": "15m0s" + }, + "insecure_disable_dnssec_validation": false, + "untrusted_cert": { + "action": "block" + } + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := TeamsRule{ + ID: "7559a944-3dd7-41bf-b183-360a814a8c36", + Name: "rule1", + Description: "rule description", + Precedence: 1000, + Enabled: false, + Action: Isolate, + Filters: []TeamsFilterType{HttpFilter}, + Traffic: `http.host == "abcd.com"`, + Identity: "", + DevicePosture: "", + Version: 1, + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: &TeamsCheckSessionSettings{ + Enforce: true, + Duration: Duration{900 * time.Second}, + }, + InsecureDisableDNSSECValidation: false, + UntrustedCertSettings: &UntrustedCertSettings{ + Action: UntrustedCertBlock, + }, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + DeletedAt: nil, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules/7559a9443dd741bfb183360a814a8c36", handler) + + actual, err := client.TeamsRule(context.Background(), testAccountID, "7559a9443dd741bfb183360a814a8c36") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsCreateHTTPRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "rule1", + "description": "rule description", + "precedence": 1000, + "enabled": false, + "action": "isolate", + "filters": [ + "http" + ], + "traffic": "http.host == \"abcd.com\"", + "identity": "", + "rule_settings": { + "block_page_enabled": false, + "biso_admin_controls": {"dp": true, "du": true, "dk": true}, + "add_headers": { + "X-Test": ["abcd"] + }, + "check_session": { + "enforce": true, + "duration": "5m0s" + }, + "insecure_disable_dnssec_validation": false + } + } + } + `) + } + + want := TeamsRule{ + Name: "rule1", + Description: "rule description", + Precedence: 1000, + Enabled: false, + Action: Isolate, + Filters: []TeamsFilterType{HttpFilter}, + Traffic: `http.host == "abcd.com"`, + Identity: "", + DevicePosture: "", + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: http.Header{"X-Test": []string{"abcd"}}, + BISOAdminControls: &TeamsBISOAdminControlSettings{ + DisablePrinting: true, + DisableKeyboard: true, + DisableUpload: true, + }, + CheckSession: &TeamsCheckSessionSettings{ + Enforce: true, + Duration: Duration{300 * time.Second}, + }, + InsecureDisableDNSSECValidation: false, + EgressSettings: nil, + }, + DeletedAt: nil, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules", handler) + + actual, err := client.TeamsCreateRule(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsCreateEgressRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "egress via chicago", + "description": "rule description", + "precedence": 1000, + "enabled": false, + "action": "egress", + "filters": [ + "egress" + ], + "traffic": "net.src.geo.country == \"US\"", + "identity": "", + "rule_settings": { + "egress": { + "ipv6": "2a06:98c1:54::c61/64", + "ipv4": "2.2.2.2", + "ipv4_fallback": "1.1.1.1" + } + } + } + } + `) + } + + want := TeamsRule{ + Name: "egress via chicago", + Description: "rule description", + Precedence: 1000, + Enabled: false, + Action: Egress, + Filters: []TeamsFilterType{EgressFilter}, + Traffic: `net.src.geo.country == "US"`, + Identity: "", + DevicePosture: "", + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: nil, + InsecureDisableDNSSECValidation: false, + EgressSettings: &EgressSettings{ + Ipv6Range: "2a06:98c1:54::c61/64", + Ipv4: "2.2.2.2", + Ipv4Fallback: "1.1.1.1", + }, + }, + DeletedAt: nil, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules", handler) + + actual, err := client.TeamsCreateRule(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsCreateL4Rule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "block 4.4.4.4", + "description": "rule description", + "precedence": 1000, + "enabled": true, + "action": "audit_ssh", + "filters": [ + "l4" + ], + "traffic": "net.src.geo.country == \"US\"", + "identity": "", + "rule_settings": { + "audit_ssh": { "command_logging": true } + } + } + } + `) + } + + want := TeamsRule{ + Name: "block 4.4.4.4", + Description: "rule description", + Precedence: 1000, + Enabled: true, + Action: AuditSSH, + Filters: []TeamsFilterType{L4Filter}, + Traffic: `net.src.geo.country == "US"`, + Identity: "", + DevicePosture: "", + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: nil, + InsecureDisableDNSSECValidation: false, + EgressSettings: nil, + AuditSSH: &AuditSSHRuleSettings{ + CommandLogging: true, + }, + }, + DeletedAt: nil, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules", handler) + + actual, err := client.TeamsCreateRule(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsCreateResolverPolicy(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "resolve 4.4.4.4", + "description": "rule description", + "precedence": 1000, + "enabled": true, + "action": "resolve", + "filters": [ + "dns_resolver" + ], + "traffic": "any(dns.domains[*] == \"scottstots.com\")", + "identity": "", + "rule_settings": { + "audit_ssh": { "command_logging": true }, + "resolve_dns_through_cloudflare": true + } + } + } + `) + } + + want := TeamsRule{ + Name: "resolve 4.4.4.4", + Description: "rule description", + Precedence: 1000, + Enabled: true, + Action: Resolve, + Filters: []TeamsFilterType{DnsResolverFilter}, + Traffic: `any(dns.domains[*] == "scottstots.com")`, + Identity: "", + DevicePosture: "", + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: nil, + InsecureDisableDNSSECValidation: false, + EgressSettings: nil, + AuditSSH: &AuditSSHRuleSettings{ + CommandLogging: true, + }, + ResolveDnsThroughCloudflare: BoolPtr(true), + }, + DeletedAt: nil, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules", handler) + + actual, err := client.TeamsCreateRule(context.Background(), testAccountID, want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsUpdateRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "7559a944-3dd7-41bf-b183-360a814a8c36", + "name": "rule_name_change", + "description": "rule new description", + "precedence": 3000, + "enabled": true, + "action": "block", + "filters": [ + "http" + ], + "traffic": "", + "identity": "", + "created_at": "2014-01-01T05:20:00.12345Z", + "updated_at": "2014-02-01T05:20:00.12345Z", + "rule_settings": { + "block_page_enabled": false, + "block_reason": "", + "override_ips": null, + "override_host": "", + "l4override": null, + "biso_admin_controls": null, + "add_headers": null, + "check_session": null, + "insecure_disable_dnssec_validation": false + } + } + } + `) + } + + createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + updatedAt, _ := time.Parse(time.RFC3339, "2014-02-01T05:20:00.12345Z") + + want := TeamsRule{ + ID: "7559a944-3dd7-41bf-b183-360a814a8c36", + Name: "rule_name_change", + Description: "rule new description", + Precedence: 3000, + Enabled: true, + Action: Block, + Filters: []TeamsFilterType{HttpFilter}, + Traffic: "", + Identity: "", + DevicePosture: "", + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: nil, + InsecureDisableDNSSECValidation: false, + }, + CreatedAt: &createdAt, + UpdatedAt: &updatedAt, + DeletedAt: nil, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules/7559a9443dd741bfb183360a814a8c36", handler) + + actual, err := client.TeamsUpdateRule(context.Background(), testAccountID, "7559a9443dd741bfb183360a814a8c36", want) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTeamsPatchRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'Patch', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "rule_name_change", + "description": "rule new description", + "precedence": 3000, + "enabled": true, + "action": "block", + "rule_settings": { + "block_page_enabled": false, + "block_reason": "", + "override_ips": null, + "override_host": "", + "l4override": null, + "biso_admin_controls": null, + "add_headers": null, + "check_session": null, + "insecure_disable_dnssec_validation": false + } + } + } + `) + } + + reqBody := TeamsRulePatchRequest{ + Name: "rule_name_change", + Description: "rule new description", + Precedence: 3000, + Enabled: true, + Action: Block, + RuleSettings: TeamsRuleSettings{ + BlockPageEnabled: false, + BlockReason: "", + OverrideIPs: nil, + OverrideHost: "", + L4Override: nil, + AddHeaders: nil, + BISOAdminControls: nil, + CheckSession: nil, + InsecureDisableDNSSECValidation: false, + }, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules/7559a9443dd741bfb183360a814a8c36", handler) + + actual, err := client.TeamsPatchRule(context.Background(), testAccountID, "7559a9443dd741bfb183360a814a8c36", reqBody) + + if assert.NoError(t, err) { + assert.Equal(t, actual.Name, "rule_name_change") + assert.Equal(t, actual.Description, "rule new description") + assert.Equal(t, actual.Precedence, uint64(3000)) + assert.Equal(t, actual.Action, Block) + assert.Equal(t, actual.Enabled, true) + } +} + +func TestTeamsDeleteRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": nil + } + `) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/gateway/rules/7559a9443dd741bfb183360a814a8c36", handler) + + err := client.TeamsDeleteRule(context.Background(), testAccountID, "7559a9443dd741bfb183360a814a8c36") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/testdata/fixtures/dns/list_page_1.json b/pkg/cloudflare-go/testdata/fixtures/dns/list_page_1.json new file mode 100644 index 000000000..5ff33aac6 --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/dns/list_page_1.json @@ -0,0 +1,80 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "372e67954025e0ba6aaa6d586b9e0b59", + "type": "A", + "name": "example.com", + "content": "198.51.100.4", + "proxiable": true, + "proxied": true, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "tags": [ + "tag1", + "tag2extended" + ] + }, + { + "id": "7eb0a9821aec4b1395bd8cc03d88c17d", + "type": "A", + "name": "sub1.example.com", + "content": "198.51.100.5", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "tags": [ + "tag1", + "tag2extended" + ] + }, + { + "id": "4c2c40857e334a2d903dd28f65a99682", + "type": "A", + "name": "sub2.example.com", + "content": "198.51.100.6", + "proxiable": true, + "proxied": true, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "tags": [ + "tag1", + "tag2extended" + ] + } + ], + "result_info": { + "count": 3, + "page": 1, + "per_page": 3, + "total_count": 5, + "total_pages": 2 + } +} diff --git a/pkg/cloudflare-go/testdata/fixtures/dns/list_page_2.json b/pkg/cloudflare-go/testdata/fixtures/dns/list_page_2.json new file mode 100644 index 000000000..f783aa449 --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/dns/list_page_2.json @@ -0,0 +1,58 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "97e1dc2d19204b448b6ee04724f005ba", + "type": "A", + "name": "sub3.example.com", + "content": "198.51.100.7", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "tags": [ + "tag1", + "tag2extended" + ] + }, + { + "id": "5bafaa7059d3480da9f6e2ecd8468c33", + "type": "A", + "name": "sub4.example.com", + "content": "198.51.100.8", + "proxiable": true, + "proxied": false, + "ttl": 120, + "zone_id": "d56084adb405e0b7e32c52321bf07be6", + "zone_name": "example.com", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "data": {}, + "meta": { + "auto_added": true, + "source": "primary" + }, + "tags": [ + "tag1", + "tag2extended" + ] + } + ], + "result_info": { + "count": 2, + "page": 2, + "per_page": 3, + "total_count": 5, + "total_pages": 2 + } +} diff --git a/pkg/cloudflare-go/testdata/fixtures/images_variants/single_full.json b/pkg/cloudflare-go/testdata/fixtures/images_variants/single_full.json new file mode 100644 index 000000000..f8bc039b5 --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/images_variants/single_full.json @@ -0,0 +1,17 @@ +{ + "errors": [], + "messages": [], + "result": { + "variant": { + "id": "hero", + "neverRequireSignedURLs": true, + "options": { + "fit": "scale-down", + "height": 768, + "metadata": "none", + "width": 1366 + } + } + }, + "success": true +} diff --git a/pkg/cloudflare-go/testdata/fixtures/images_variants/single_list.json b/pkg/cloudflare-go/testdata/fixtures/images_variants/single_list.json new file mode 100644 index 000000000..1838aa6ce --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/images_variants/single_list.json @@ -0,0 +1,19 @@ +{ + "errors": [], + "messages": [], + "result": { + "variants": { + "hero": { + "id": "hero", + "neverRequireSignedURLs": true, + "options": { + "fit": "scale-down", + "height": 768, + "metadata": "none", + "width": 1366 + } + } + } + }, + "success": true +} diff --git a/pkg/cloudflare-go/testdata/fixtures/tunnel/configuration.json b/pkg/cloudflare-go/testdata/fixtures/tunnel/configuration.json new file mode 100644 index 000000000..41e642cb4 --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/tunnel/configuration.json @@ -0,0 +1,18 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "config": { + "warp-routing": { "enabled": true }, + "originRequest": { "connectTimeout": 10 }, + "ingress": [ + { "hostname": "test.example.com", "service": "https://localhost:8000", "originRequest": { "noTLSVerify": true } }, + { "service": "http_status:404" } + ] + }, + "tunnel_id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "version": 5, + "created_at": "2021-01-25T18:22:34.317854Z" + } +} diff --git a/pkg/cloudflare-go/testdata/fixtures/tunnel/empty.json b/pkg/cloudflare-go/testdata/fixtures/tunnel/empty.json new file mode 100644 index 000000000..ca0f4176f --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/tunnel/empty.json @@ -0,0 +1,5 @@ +{ + "success": true, + "errors": [], + "messages": [] +} diff --git a/pkg/cloudflare-go/testdata/fixtures/tunnel/multiple_full.json b/pkg/cloudflare-go/testdata/fixtures/tunnel/multiple_full.json new file mode 100644 index 000000000..586dd9003 --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/tunnel/multiple_full.json @@ -0,0 +1,30 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "blog", + "created_at": "2009-11-10T23:00:00Z", + "deleted_at": "2009-11-10T23:00:00Z", + "connections": [ + { + "colo_name": "DFW", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect": false, + "client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + "client_version": "2022.2.0", + "opened_at": "2021-01-25T18:22:34.317854Z", + "origin_ip": "198.51.100.1" + } + ] + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1 + } +} diff --git a/pkg/cloudflare-go/testdata/fixtures/tunnel/single_full.json b/pkg/cloudflare-go/testdata/fixtures/tunnel/single_full.json new file mode 100644 index 000000000..6eb38f30c --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/tunnel/single_full.json @@ -0,0 +1,25 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "name": "blog", + "created_at": "2009-11-10T23:00:00Z", + "deleted_at": "2009-11-10T23:00:00Z", + "connections": [ + { + "colo_name": "DFW", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect": false, + "client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + "client_version": "2022.2.0", + "opened_at": "2021-01-25T18:22:34.317854Z", + "origin_ip": "198.51.100.1" + } + ], + "status": "healthy", + "tun_type": "cfd_tunnel", + "remote_config": true + } +} diff --git a/pkg/cloudflare-go/testdata/fixtures/tunnel/token.json b/pkg/cloudflare-go/testdata/fixtures/tunnel/token.json new file mode 100644 index 000000000..9e1a53407 --- /dev/null +++ b/pkg/cloudflare-go/testdata/fixtures/tunnel/token.json @@ -0,0 +1,6 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": "ZHNraGdhc2RraGFza2hqZGFza2poZGFza2poYXNrZGpoYWtzamRoa2FzZGpoa2FzamRoa2Rhc2po\na2FzamRoa2FqCg==" +} diff --git a/pkg/cloudflare-go/tiered_cache.go b/pkg/cloudflare-go/tiered_cache.go new file mode 100644 index 000000000..a6e845084 --- /dev/null +++ b/pkg/cloudflare-go/tiered_cache.go @@ -0,0 +1,317 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type TieredCacheType int + +const ( + TieredCacheOff TieredCacheType = 0 + TieredCacheGeneric TieredCacheType = 1 + TieredCacheSmart TieredCacheType = 2 +) + +func (e TieredCacheType) String() string { + switch e { + case TieredCacheGeneric: + return "generic" + case TieredCacheSmart: + return "smart" + case TieredCacheOff: + return "off" + default: + return fmt.Sprintf("%d", int(e)) + } +} + +type TieredCache struct { + Type TieredCacheType + LastModified time.Time +} + +// GetTieredCache allows you to retrieve the current Tiered Cache Settings for a Zone. +// This function does not support custom topologies, only Generic and Smart Tiered Caching. +// +// API Reference: https://api.cloudflare.com/#smart-tiered-cache-get-smart-tiered-cache-setting +// API Reference: https://api.cloudflare.com/#tiered-cache-get-tiered-cache-setting +func (api *API) GetTieredCache(ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + var lastModified time.Time + + generic, err := getGenericTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + lastModified = generic.LastModified + + smart, err := getSmartTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + + if smart.LastModified.After(lastModified) { + lastModified = smart.LastModified + } + + if generic.Type == TieredCacheOff { + return TieredCache{Type: TieredCacheOff, LastModified: lastModified}, nil + } + + if smart.Type == TieredCacheOff { + return TieredCache{Type: TieredCacheGeneric, LastModified: lastModified}, nil + } + + return TieredCache{Type: TieredCacheSmart, LastModified: lastModified}, nil +} + +// SetTieredCache allows you to set a zone's tiered cache topology between the available types. +// Using the value of TieredCacheOff will disable Tiered Cache entirely. +// +// API Reference: https://api.cloudflare.com/#smart-tiered-cache-patch-smart-tiered-cache-setting +// API Reference: https://api.cloudflare.com/#tiered-cache-patch-tiered-cache-setting +func (api *API) SetTieredCache(ctx context.Context, rc *ResourceContainer, value TieredCacheType) (TieredCache, error) { + if value == TieredCacheOff { + return api.DeleteTieredCache(ctx, rc) + } + + var lastModified time.Time + + if value == TieredCacheGeneric { + result, err := deleteSmartTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + lastModified = result.LastModified + + result, err = enableGenericTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + + if result.LastModified.After(lastModified) { + lastModified = result.LastModified + } + return TieredCache{Type: TieredCacheGeneric, LastModified: lastModified}, nil + } + + result, err := enableGenericTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + lastModified = result.LastModified + + result, err = enableSmartTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + + if result.LastModified.After(lastModified) { + lastModified = result.LastModified + } + return TieredCache{Type: TieredCacheSmart, LastModified: lastModified}, nil +} + +// DeleteTieredCache allows you to delete the tiered cache settings for a zone. +// This is equivalent to using SetTieredCache with the value of TieredCacheOff. +// +// API Reference: https://api.cloudflare.com/#smart-tiered-cache-delete-smart-tiered-cache-setting +// API Reference: https://api.cloudflare.com/#tiered-cache-patch-tiered-cache-setting +func (api *API) DeleteTieredCache(ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + var lastModified time.Time + + result, err := deleteSmartTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + lastModified = result.LastModified + + result, err = disableGenericTieredCache(api, ctx, rc) + if err != nil { + return TieredCache{}, err + } + + if result.LastModified.After(lastModified) { + lastModified = result.LastModified + } + return TieredCache{Type: TieredCacheOff, LastModified: lastModified}, nil +} + +type tieredCacheResult struct { + ID string `json:"id"` + Value string `json:"value,omitempty"` + LastModified time.Time `json:"modified_on"` +} + +type tieredCacheResponse struct { + Result tieredCacheResult `json:"result"` + Response +} + +type tieredCacheSetting struct { + Value string `json:"value"` +} + +func getGenericTieredCache(api *API, ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + uri := fmt.Sprintf("/zones/%s/argo/tiered_caching", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + var response tieredCacheResponse + err = json.Unmarshal(res, &response) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + if !response.Success { + return TieredCache{Type: TieredCacheOff}, errors.New("request to retrieve generic tiered cache failed") + } + + if response.Result.Value == "off" { + return TieredCache{Type: TieredCacheOff, LastModified: response.Result.LastModified}, nil + } + + return TieredCache{Type: TieredCacheGeneric, LastModified: response.Result.LastModified}, nil +} + +func getSmartTieredCache(api *API, ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + uri := fmt.Sprintf("/zones/%s/cache/tiered_cache_smart_topology_enable", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + var notFoundError *NotFoundError + if errors.As(err, ¬FoundError) { + return TieredCache{Type: TieredCacheOff}, nil + } + return TieredCache{Type: TieredCacheOff}, err + } + + var response tieredCacheResponse + err = json.Unmarshal(res, &response) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + if !response.Success { + return TieredCache{Type: TieredCacheOff}, errors.New("request to retrieve smart tiered cache failed") + } + + if response.Result.Value == "off" { + return TieredCache{Type: TieredCacheOff, LastModified: response.Result.LastModified}, nil + } + return TieredCache{Type: TieredCacheSmart, LastModified: response.Result.LastModified}, nil +} + +func enableGenericTieredCache(api *API, ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + uri := fmt.Sprintf("/zones/%s/argo/tiered_caching", rc.Identifier) + setting := tieredCacheSetting{ + Value: "on", + } + body, err := json.Marshal(setting) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, body) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + var response tieredCacheResponse + err = json.Unmarshal(res, &response) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + if !response.Success { + return TieredCache{Type: TieredCacheOff}, errors.New("request to enable generic tiered cache failed") + } + + return TieredCache{Type: TieredCacheGeneric, LastModified: response.Result.LastModified}, nil +} + +func enableSmartTieredCache(api *API, ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + uri := fmt.Sprintf("/zones/%s/cache/tiered_cache_smart_topology_enable", rc.Identifier) + setting := tieredCacheSetting{ + Value: "on", + } + body, err := json.Marshal(setting) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, body) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + var response tieredCacheResponse + err = json.Unmarshal(res, &response) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + if !response.Success { + return TieredCache{Type: TieredCacheOff}, errors.New("request to enable smart tiered cache failed") + } + + return TieredCache{Type: TieredCacheSmart, LastModified: response.Result.LastModified}, nil +} + +func disableGenericTieredCache(api *API, ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + uri := fmt.Sprintf("/zones/%s/argo/tiered_caching", rc.Identifier) + setting := tieredCacheSetting{ + Value: "off", + } + body, err := json.Marshal(setting) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, body) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + var response tieredCacheResponse + err = json.Unmarshal(res, &response) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + if !response.Success { + return TieredCache{Type: TieredCacheOff}, errors.New("request to disable generic tiered cache failed") + } + + return TieredCache{Type: TieredCacheOff, LastModified: response.Result.LastModified}, nil +} + +func deleteSmartTieredCache(api *API, ctx context.Context, rc *ResourceContainer) (TieredCache, error) { + uri := fmt.Sprintf("/zones/%s/cache/tiered_cache_smart_topology_enable", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + var notFoundError *NotFoundError + if errors.As(err, ¬FoundError) { + return TieredCache{Type: TieredCacheOff}, nil + } + return TieredCache{Type: TieredCacheOff}, err + } + + var response tieredCacheResponse + err = json.Unmarshal(res, &response) + if err != nil { + return TieredCache{Type: TieredCacheOff}, err + } + + if !response.Success { + return TieredCache{Type: TieredCacheOff}, errors.New("request to disable smart tiered cache failed") + } + + return TieredCache{Type: TieredCacheOff, LastModified: response.Result.LastModified}, nil +} diff --git a/pkg/cloudflare-go/tiered_cache_test.go b/pkg/cloudflare-go/tiered_cache_test.go new file mode 100644 index 000000000..c30ada640 --- /dev/null +++ b/pkg/cloudflare-go/tiered_cache_test.go @@ -0,0 +1,321 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func createSmartTieredCacheHandler(val string, lastModified string) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "editable": true, + "id": "tiered_cache_smart_topology_enable", + "modified_on": "%s", + "value": "%s" + } + }`, lastModified, val) + } +} + +func nonexistentSmartTieredCacheHandler() func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(404) + fmt.Fprintf(w, `{ + "result": null, + "success": false, + "errors": [ + { + "code": 1142, + "message": "Unable to retrieve tiered_cache_smart_topology_enable setting value. The zone setting does not exist." + } + ], + "messages": [] + }`) + } +} + +func createGenericTieredCacheHandler(val string, lastModified string) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "tiered_caching", + "value": "%s", + "modified_on": "%s", + "editable": false + } + }`, val, lastModified) + } +} + +func TestGetTieredCache(t *testing.T) { + t.Run("can identify when Smart Tiered Cache", func(t *testing.T) { + t.Run("is disabled", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", createSmartTieredCacheHandler("off", lastModified)) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheGeneric, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + + t.Run("is enabled", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", createSmartTieredCacheHandler("on", lastModified)) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheSmart, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + + t.Run("zone setting does not exist", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", nonexistentSmartTieredCacheHandler()) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheGeneric, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + }) + + t.Run("can identify when generic tiered cache", func(t *testing.T) { + t.Run("is disabled", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("off", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", createSmartTieredCacheHandler("on", lastModified)) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheOff, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + + t.Run("is enabled", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", nonexistentSmartTieredCacheHandler()) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheGeneric, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + }) + + t.Run("determines the latest last modified when", func(t *testing.T) { + t.Run("smart tiered cache zone setting does not exist", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", nonexistentSmartTieredCacheHandler()) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheGeneric, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + + t.Run("generic tiered cache was modified more recently", func(t *testing.T) { + setup() + defer teardown() + + earlier := time.Now().Add(time.Minute * -5).Format(time.RFC3339) + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", earlier)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", createSmartTieredCacheHandler("on", lastModified)) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheSmart, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + + t.Run("smart tiered cache was modified more recently", func(t *testing.T) { + setup() + defer teardown() + + earlier := time.Now().Add(time.Minute * -5).Format(time.RFC3339) + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", createSmartTieredCacheHandler("on", earlier)) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheSmart, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + }) +} + +func TestSetTieredCache(t *testing.T) { + t.Run("can enable tiered caching", func(t *testing.T) { + t.Run("using smart caching", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", createSmartTieredCacheHandler("on", lastModified)) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheSmart, + LastModified: wanted, + } + + got, err := client.SetTieredCache(context.Background(), ZoneIdentifier(testZoneID), TieredCacheSmart) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + + t.Run("use generic caching", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("on", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", nonexistentSmartTieredCacheHandler()) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheGeneric, + LastModified: wanted, + } + + got, err := client.SetTieredCache(context.Background(), ZoneIdentifier(testZoneID), TieredCacheGeneric) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) + }) +} + +func TestDeleteTieredCache(t *testing.T) { + t.Run("can disable tiered caching", func(t *testing.T) { + setup() + defer teardown() + + lastModified := time.Now().Format(time.RFC3339) + + mux.HandleFunc("/zones/"+testZoneID+"/argo/tiered_caching", createGenericTieredCacheHandler("off", lastModified)) + mux.HandleFunc("/zones/"+testZoneID+"/cache/tiered_cache_smart_topology_enable", nonexistentSmartTieredCacheHandler()) + + wanted, _ := time.Parse(time.RFC3339, lastModified) + want := TieredCache{ + Type: TieredCacheOff, + LastModified: wanted, + } + + got, err := client.GetTieredCache(context.Background(), ZoneIdentifier(testZoneID)) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } + }) +} diff --git a/pkg/cloudflare-go/total_tls.go b/pkg/cloudflare-go/total_tls.go new file mode 100644 index 000000000..5ea0bdb3b --- /dev/null +++ b/pkg/cloudflare-go/total_tls.go @@ -0,0 +1,64 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type TotalTLS struct { + Enabled *bool `json:"enabled,omitempty"` + CertificateAuthority string `json:"certificate_authority,omitempty"` + ValidityDays int `json:"validity_days,omitempty"` +} + +type TotalTLSResponse struct { + Response + Result TotalTLS `json:"result"` +} + +// GetTotalTLS Get Total TLS Settings for a Zone. +// +// API Reference: https://api.cloudflare.com/#total-tls-total-tls-settings-details +func (api *API) GetTotalTLS(ctx context.Context, rc *ResourceContainer) (TotalTLS, error) { + if rc.Identifier == "" { + return TotalTLS{}, ErrMissingZoneID + } + uri := fmt.Sprintf("/zones/%s/acm/total_tls", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TotalTLS{}, err + } + + var r TotalTLSResponse + err = json.Unmarshal(res, &r) + if err != nil { + return TotalTLS{}, err + } + + return r.Result, nil +} + +// SetTotalTLS Set Total TLS Settings or disable the feature for a Zone. +// +// API Reference: https://api.cloudflare.com/#total-tls-enable-or-disable-total-tls +func (api *API) SetTotalTLS(ctx context.Context, rc *ResourceContainer, params TotalTLS) (TotalTLS, error) { + if rc.Identifier == "" { + return TotalTLS{}, ErrMissingZoneID + } + uri := fmt.Sprintf("/zones/%s/acm/total_tls", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return TotalTLS{}, err + } + + var r TotalTLSResponse + err = json.Unmarshal(res, &r) + if err != nil { + return TotalTLS{}, err + } + + return r.Result, nil +} diff --git a/pkg/cloudflare-go/total_tls_test.go b/pkg/cloudflare-go/total_tls_test.go new file mode 100644 index 000000000..3b5e23963 --- /dev/null +++ b/pkg/cloudflare-go/total_tls_test.go @@ -0,0 +1,74 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTotalTLS_GetSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/acm/total_tls", testZoneID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "enabled": true, + "certificate_authority": "google", + "validity_days": 90 + } + }`) + }) + + _, err := client.GetTotalTLS(context.Background(), ZoneIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + result, err := client.GetTotalTLS(context.Background(), ZoneIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, BoolPtr(true), result.Enabled) + assert.Equal(t, "google", result.CertificateAuthority) + assert.Equal(t, 90, result.ValidityDays) + } +} + +func TestTotalTLS_SetSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/acm/total_tls", testZoneID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "enabled": true, + "certificate_authority": "google", + "validity_days": 90 + } +}`) + }) + + _, err := client.SetTotalTLS(context.Background(), ZoneIdentifier(""), TotalTLS{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + result, err := client.SetTotalTLS(context.Background(), ZoneIdentifier(testZoneID), TotalTLS{CertificateAuthority: "google", Enabled: BoolPtr(true)}) + if assert.NoError(t, err) { + assert.Equal(t, BoolPtr(true), result.Enabled) + assert.Equal(t, "google", result.CertificateAuthority) + assert.Equal(t, 90, result.ValidityDays) + } +} diff --git a/pkg/cloudflare-go/tunnel.go b/pkg/cloudflare-go/tunnel.go new file mode 100644 index 000000000..2f3f6d7c9 --- /dev/null +++ b/pkg/cloudflare-go/tunnel.go @@ -0,0 +1,536 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/goccy/go-json" +) + +// A TunnelDuration is a Duration that has custom serialization for JSON. +// JSON in Javascript assumes that int fields are 32 bits and Duration fields +// are deserialized assuming that numbers are in nanoseconds, which in 32bit +// integers limits to just 2 seconds. This type assumes that when +// serializing/deserializing from JSON, that the number is in seconds, while it +// maintains the YAML serde assumptions. +type TunnelDuration struct { + time.Duration +} + +func (s TunnelDuration) MarshalJSON() ([]byte, error) { + return json.Marshal(s.Duration.Seconds()) +} + +func (s *TunnelDuration) UnmarshalJSON(data []byte) error { + seconds, err := strconv.ParseInt(string(data), 10, 64) + if err != nil { + return err + } + + s.Duration = time.Duration(seconds * int64(time.Second)) + return nil +} + +// ErrMissingTunnelID is for when a required tunnel ID is missing from the +// parameters. +var ErrMissingTunnelID = errors.New("required missing tunnel ID") + +// Tunnel is the struct definition of a tunnel. +type Tunnel struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Secret string `json:"tunnel_secret,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + Connections []TunnelConnection `json:"connections,omitempty"` + ConnsActiveAt *time.Time `json:"conns_active_at,omitempty"` + ConnInactiveAt *time.Time `json:"conns_inactive_at,omitempty"` + TunnelType string `json:"tun_type,omitempty"` + Status string `json:"status,omitempty"` + RemoteConfig bool `json:"remote_config,omitempty"` +} + +// Connection is the struct definition of a connection. +type Connection struct { + ID string `json:"id,omitempty"` + Features []string `json:"features,omitempty"` + Version string `json:"version,omitempty"` + Arch string `json:"arch,omitempty"` + Connections []TunnelConnection `json:"conns,omitempty"` + RunAt *time.Time `json:"run_at,omitempty"` + ConfigVersion int `json:"config_version,omitempty"` +} + +// TunnelConnection represents the connections associated with a tunnel. +type TunnelConnection struct { + ColoName string `json:"colo_name"` + ID string `json:"id"` + IsPendingReconnect bool `json:"is_pending_reconnect"` + ClientID string `json:"client_id"` + ClientVersion string `json:"client_version"` + OpenedAt string `json:"opened_at"` + OriginIP string `json:"origin_ip"` +} + +// TunnelsDetailResponse is used for representing the API response payload for +// multiple tunnels. +type TunnelsDetailResponse struct { + Result []Tunnel `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// listTunnelsDefaultPageSize represents the default per_page size of the API. +var listTunnelsDefaultPageSize int = 100 + +// TunnelDetailResponse is used for representing the API response payload for +// a single tunnel. +type TunnelDetailResponse struct { + Result Tunnel `json:"result"` + Response +} + +// TunnelConnectionResponse is used for representing the API response payload for +// connections of a single tunnel. +type TunnelConnectionResponse struct { + Result []Connection `json:"result"` + Response +} + +type TunnelConfigurationResult struct { + TunnelID string `json:"tunnel_id,omitempty"` + Config TunnelConfiguration `json:"config,omitempty"` + Version int `json:"version,omitempty"` +} + +// TunnelConfigurationResponse is used for representing the API response payload +// for a single tunnel. +type TunnelConfigurationResponse struct { + Result TunnelConfigurationResult `json:"result"` + Response +} + +// TunnelTokenResponse is the API response for a tunnel token. +type TunnelTokenResponse struct { + Result string `json:"result"` + Response +} + +type TunnelCreateParams struct { + Name string `json:"name,omitempty"` + Secret string `json:"tunnel_secret,omitempty"` + ConfigSrc string `json:"config_src,omitempty"` +} + +type TunnelUpdateParams struct { + Name string `json:"name,omitempty"` + Secret string `json:"tunnel_secret,omitempty"` +} + +type UnvalidatedIngressRule struct { + Hostname string `json:"hostname,omitempty"` + Path string `json:"path,omitempty"` + Service string `json:"service,omitempty"` + OriginRequest *OriginRequestConfig `json:"originRequest,omitempty"` +} + +// OriginRequestConfig is a set of optional fields that users may set to +// customize how cloudflared sends requests to origin services. It is used to set +// up general config that apply to all rules, and also, specific per-rule +// config. +type OriginRequestConfig struct { + // HTTP proxy timeout for establishing a new connection + ConnectTimeout *TunnelDuration `json:"connectTimeout,omitempty"` + // HTTP proxy timeout for completing a TLS handshake + TLSTimeout *TunnelDuration `json:"tlsTimeout,omitempty"` + // HTTP proxy TCP keepalive duration + TCPKeepAlive *TunnelDuration `json:"tcpKeepAlive,omitempty"` + // HTTP proxy should disable "happy eyeballs" for IPv4/v6 fallback + NoHappyEyeballs *bool `json:"noHappyEyeballs,omitempty"` + // HTTP proxy maximum keepalive connection pool size + KeepAliveConnections *int `json:"keepAliveConnections,omitempty"` + // HTTP proxy timeout for closing an idle connection + KeepAliveTimeout *TunnelDuration `json:"keepAliveTimeout,omitempty"` + // Sets the HTTP Host header for the local webserver. + HTTPHostHeader *string `json:"httpHostHeader,omitempty"` + // Hostname on the origin server certificate. + OriginServerName *string `json:"originServerName,omitempty"` + // Path to the CA for the certificate of your origin. + // This option should be used only if your certificate is not signed by Cloudflare. + CAPool *string `json:"caPool,omitempty"` + // Disables TLS verification of the certificate presented by your origin. + // Will allow any certificate from the origin to be accepted. + // Note: The connection from your machine to Cloudflare's Edge is still encrypted. + NoTLSVerify *bool `json:"noTLSVerify,omitempty"` + // Disables chunked transfer encoding. + // Useful if you are running a WSGI server. + DisableChunkedEncoding *bool `json:"disableChunkedEncoding,omitempty"` + // Runs as jump host + BastionMode *bool `json:"bastionMode,omitempty"` + // Listen address for the proxy. + ProxyAddress *string `json:"proxyAddress,omitempty"` + // Listen port for the proxy. + ProxyPort *uint `json:"proxyPort,omitempty"` + // Valid options are 'socks' or empty. + ProxyType *string `json:"proxyType,omitempty"` + // IP rules for the proxy service + IPRules []IngressIPRule `json:"ipRules,omitempty"` + // Attempt to connect to origin with HTTP/2 + Http2Origin *bool `json:"http2Origin,omitempty"` + // Access holds all access related configs + Access *AccessConfig `json:"access,omitempty"` +} + +type AccessConfig struct { + // Required when set to true will fail every request that does not arrive + // through an access authenticated endpoint. + Required bool `yaml:"required" json:"required,omitempty"` + // TeamName is the organization team name to get the public key certificates for. + TeamName string `yaml:"teamName" json:"teamName"` + // AudTag is the AudTag to verify access JWT against. + AudTag []string `yaml:"audTag" json:"audTag"` +} + +type IngressIPRule struct { + Prefix *string `json:"prefix,omitempty"` + Ports []int `json:"ports,omitempty"` + Allow bool `json:"allow,omitempty"` +} + +type TunnelConfiguration struct { + Ingress []UnvalidatedIngressRule `json:"ingress,omitempty"` + WarpRouting *WarpRoutingConfig `json:"warp-routing,omitempty"` + OriginRequest OriginRequestConfig `json:"originRequest,omitempty"` +} + +type WarpRoutingConfig struct { + Enabled bool `json:"enabled,omitempty"` +} + +type TunnelConfigurationParams struct { + TunnelID string `json:"-"` + Config TunnelConfiguration `json:"config,omitempty"` +} + +type TunnelListParams struct { + Name string `url:"name,omitempty"` + UUID string `url:"uuid,omitempty"` // the tunnel ID + IsDeleted *bool `url:"is_deleted,omitempty"` + ExistedAt *time.Time `url:"existed_at,omitempty"` + IncludePrefix string `url:"include_prefix,omitempty"` + ExcludePrefix string `url:"exclude_prefix,omitempty"` + + ResultInfo +} + +// ListTunnels lists all tunnels. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-list-cloudflare-tunnels +func (api *API) ListTunnels(ctx context.Context, rc *ResourceContainer, params TunnelListParams) ([]Tunnel, *ResultInfo, error) { + if rc.Identifier == "" { + return []Tunnel{}, &ResultInfo{}, ErrMissingAccountID + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = listTunnelsDefaultPageSize + } + + if params.Page < 1 { + params.Page = 1 + } + + var records []Tunnel + var listResponse TunnelsDetailResponse + + for { + uri := buildURI(fmt.Sprintf("/accounts/%s/cfd_tunnel", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Tunnel{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &listResponse) + if err != nil { + return []Tunnel{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + records = append(records, listResponse.Result...) + params.ResultInfo = listResponse.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return records, &listResponse.ResultInfo, nil +} + +// GetTunnel returns a single Argo tunnel. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-get-cloudflare-tunnel +func (api *API) GetTunnel(ctx context.Context, rc *ResourceContainer, tunnelID string) (Tunnel, error) { + if rc.Identifier == "" { + return Tunnel{}, ErrMissingAccountID + } + + if tunnelID == "" { + return Tunnel{}, errors.New("missing tunnel ID") + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", rc.Identifier, tunnelID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Tunnel{}, err + } + + var argoDetailsResponse TunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return Tunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// CreateTunnel creates a new tunnel for the account. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-create-cloudflare-tunnel +func (api *API) CreateTunnel(ctx context.Context, rc *ResourceContainer, params TunnelCreateParams) (Tunnel, error) { + if rc.Identifier == "" { + return Tunnel{}, ErrMissingAccountID + } + + if params.Name == "" { + return Tunnel{}, errors.New("missing tunnel name") + } + + if params.Secret == "" { + return Tunnel{}, errors.New("missing tunnel secret") + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return Tunnel{}, err + } + + var argoDetailsResponse TunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return Tunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return argoDetailsResponse.Result, nil +} + +// UpdateTunnel updates an existing tunnel for the account. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-update-cloudflare-tunnel +func (api *API) UpdateTunnel(ctx context.Context, rc *ResourceContainer, params TunnelUpdateParams) (Tunnel, error) { + if rc.Identifier == "" { + return Tunnel{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", rc.Identifier) + + var tunnel Tunnel + + if params.Name != "" { + tunnel.Name = params.Name + } + + if params.Secret != "" { + tunnel.Secret = params.Secret + } + + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, tunnel) + if err != nil { + return Tunnel{}, err + } + + var argoDetailsResponse TunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return Tunnel{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return argoDetailsResponse.Result, nil +} + +// UpdateTunnelConfiguration updates an existing tunnel for the account. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-configuration-properties +func (api *API) UpdateTunnelConfiguration(ctx context.Context, rc *ResourceContainer, params TunnelConfigurationParams) (TunnelConfigurationResult, error) { + if rc.Identifier == "" { + return TunnelConfigurationResult{}, ErrMissingAccountID + } + + if params.TunnelID == "" { + return TunnelConfigurationResult{}, ErrMissingTunnelID + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/configurations", rc.Identifier, params.TunnelID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return TunnelConfigurationResult{}, err + } + + var tunnelDetailsResponse TunnelConfigurationResponse + err = json.Unmarshal(res, &tunnelDetailsResponse) + if err != nil { + return TunnelConfigurationResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + var tunnelDetails TunnelConfigurationResult + + tunnelDetails.Config = tunnelDetailsResponse.Result.Config + tunnelDetails.TunnelID = tunnelDetailsResponse.Result.TunnelID + tunnelDetails.Version = tunnelDetailsResponse.Result.Version + + return tunnelDetails, nil +} + +// GetTunnelConfiguration updates an existing tunnel for the account. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-configuration-properties +func (api *API) GetTunnelConfiguration(ctx context.Context, rc *ResourceContainer, tunnelID string) (TunnelConfigurationResult, error) { + if rc.Identifier == "" { + return TunnelConfigurationResult{}, ErrMissingAccountID + } + + if tunnelID == "" { + return TunnelConfigurationResult{}, ErrMissingTunnelID + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/configurations", rc.Identifier, tunnelID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TunnelConfigurationResult{}, err + } + + var tunnelDetailsResponse TunnelConfigurationResponse + err = json.Unmarshal(res, &tunnelDetailsResponse) + if err != nil { + return TunnelConfigurationResult{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + var tunnelDetails TunnelConfigurationResult + + tunnelDetails.Config = tunnelDetailsResponse.Result.Config + tunnelDetails.TunnelID = tunnelDetailsResponse.Result.TunnelID + tunnelDetails.Version = tunnelDetailsResponse.Result.Version + + return tunnelDetails, nil +} + +// ListTunnelConnections gets all connections on a tunnel. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-list-cloudflare-tunnel-connections +func (api *API) ListTunnelConnections(ctx context.Context, rc *ResourceContainer, tunnelID string) ([]Connection, error) { + if rc.Identifier == "" { + return []Connection{}, ErrMissingAccountID + } + + if tunnelID == "" { + return []Connection{}, ErrMissingTunnelID + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", rc.Identifier, tunnelID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Connection{}, err + } + + var argoDetailsResponse TunnelConnectionResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return []Connection{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return argoDetailsResponse.Result, nil +} + +// DeleteTunnel removes a single Argo tunnel. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-delete-cloudflare-tunnel +func (api *API) DeleteTunnel(ctx context.Context, rc *ResourceContainer, tunnelID string) error { + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", rc.Identifier, tunnelID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var argoDetailsResponse TunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// CleanupTunnelConnections deletes any inactive connections on a tunnel. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-clean-up-cloudflare-tunnel-connections +func (api *API) CleanupTunnelConnections(ctx context.Context, rc *ResourceContainer, tunnelID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if tunnelID == "" { + return errors.New("missing tunnel ID") + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", rc.Identifier, tunnelID) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var argoDetailsResponse TunnelDetailResponse + err = json.Unmarshal(res, &argoDetailsResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// GetTunnelToken that allows to run a tunnel. +// +// API reference: https://api.cloudflare.com/#cloudflare-tunnel-get-cloudflare-tunnel-token +func (api *API) GetTunnelToken(ctx context.Context, rc *ResourceContainer, tunnelID string) (string, error) { + if rc.Identifier == "" { + return "", ErrMissingAccountID + } + + if tunnelID == "" { + return "", errors.New("missing tunnel ID") + } + + uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/token", rc.Identifier, tunnelID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return "", err + } + + var tunnelTokenResponse TunnelTokenResponse + err = json.Unmarshal(res, &tunnelTokenResponse) + if err != nil { + return "", fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return tunnelTokenResponse.Result, nil +} diff --git a/pkg/cloudflare-go/tunnel_routes.go b/pkg/cloudflare-go/tunnel_routes.go new file mode 100644 index 000000000..c67925eba --- /dev/null +++ b/pkg/cloudflare-go/tunnel_routes.go @@ -0,0 +1,214 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingNetwork = errors.New("missing required network parameter") + ErrInvalidNetworkValue = errors.New("invalid IP parameter. Cannot use CIDR ranges for this endpoint.") +) + +// TunnelRoute is the full record for a route. +type TunnelRoute struct { + Network string `json:"network"` + TunnelID string `json:"tunnel_id"` + TunnelName string `json:"tunnel_name"` + Comment string `json:"comment"` + CreatedAt *time.Time `json:"created_at"` + DeletedAt *time.Time `json:"deleted_at"` + VirtualNetworkID string `json:"virtual_network_id"` +} + +type TunnelRoutesListParams struct { + TunnelID string `url:"tunnel_id,omitempty"` + Comment string `url:"comment,omitempty"` + IsDeleted *bool `url:"is_deleted,omitempty"` + NetworkSubset string `url:"network_subset,omitempty"` + NetworkSuperset string `url:"network_superset,omitempty"` + ExistedAt *time.Time `url:"existed_at,omitempty"` + VirtualNetworkID string `url:"virtual_network_id,omitempty"` + PaginationOptions +} + +type TunnelRoutesCreateParams struct { + Network string `json:"-"` + TunnelID string `json:"tunnel_id"` + Comment string `json:"comment,omitempty"` + VirtualNetworkID string `json:"virtual_network_id,omitempty"` +} + +type TunnelRoutesUpdateParams struct { + Network string `json:"network"` + TunnelID string `json:"tunnel_id"` + Comment string `json:"comment,omitempty"` + VirtualNetworkID string `json:"virtual_network_id,omitempty"` +} + +type TunnelRoutesForIPParams struct { + Network string `url:"-"` + VirtualNetworkID string `url:"virtual_network_id,omitempty"` +} + +type TunnelRoutesDeleteParams struct { + Network string `url:"-"` + VirtualNetworkID string `url:"virtual_network_id,omitempty"` +} + +// tunnelRouteListResponse is the API response for listing tunnel routes. +type tunnelRouteListResponse struct { + Response + Result []TunnelRoute `json:"result"` +} + +type tunnelRouteResponse struct { + Response + Result TunnelRoute `json:"result"` +} + +// ListTunnelRoutes lists all defined routes for tunnels in the account. +// +// See: https://api.cloudflare.com/#tunnel-route-list-tunnel-routes +func (api *API) ListTunnelRoutes(ctx context.Context, rc *ResourceContainer, params TunnelRoutesListParams) ([]TunnelRoute, error) { + if rc.Identifier == "" { + return []TunnelRoute{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/teamnet/routes", AccountRouteRoot, rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []TunnelRoute{}, err + } + + var resp tunnelRouteListResponse + err = json.Unmarshal(res, &resp) + if err != nil { + return []TunnelRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return resp.Result, nil +} + +// GetTunnelRouteForIP finds the Tunnel Route that encompasses the given IP. +// +// See: https://api.cloudflare.com/#tunnel-route-get-tunnel-route-by-ip +func (api *API) GetTunnelRouteForIP(ctx context.Context, rc *ResourceContainer, params TunnelRoutesForIPParams) (TunnelRoute, error) { + if rc.Identifier == "" { + return TunnelRoute{}, ErrMissingAccountID + } + + if params.Network == "" { + return TunnelRoute{}, ErrMissingNetwork + } + + if strings.Contains(params.Network, "/") { + return TunnelRoute{}, ErrInvalidNetworkValue + } + + uri := buildURI(fmt.Sprintf("/%s/%s/teamnet/routes/ip/%s", AccountRouteRoot, rc.Identifier, params.Network), params) + + responseBody, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TunnelRoute{}, err + } + + var routeResponse tunnelRouteResponse + err = json.Unmarshal(responseBody, &routeResponse) + if err != nil { + return TunnelRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return routeResponse.Result, nil +} + +// CreateTunnelRoute add a new route to the account routing table for the given +// tunnel. +// +// See: https://api.cloudflare.com/#tunnel-route-create-route +func (api *API) CreateTunnelRoute(ctx context.Context, rc *ResourceContainer, params TunnelRoutesCreateParams) (TunnelRoute, error) { + if rc.Identifier == "" { + return TunnelRoute{}, ErrMissingAccountID + } + + if params.Network == "" { + return TunnelRoute{}, ErrMissingNetwork + } + + uri := fmt.Sprintf("/%s/%s/teamnet/routes/network/%s", AccountRouteRoot, rc.Identifier, url.PathEscape(params.Network)) + + responseBody, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return TunnelRoute{}, err + } + + var routeResponse tunnelRouteResponse + err = json.Unmarshal(responseBody, &routeResponse) + if err != nil { + return TunnelRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return routeResponse.Result, nil +} + +// DeleteTunnelRoute delete an existing route from the account routing table. +// +// See: https://api.cloudflare.com/#tunnel-route-delete-route +func (api *API) DeleteTunnelRoute(ctx context.Context, rc *ResourceContainer, params TunnelRoutesDeleteParams) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if params.Network == "" { + return ErrMissingNetwork + } + + // Cannot fully utilize buildURI here because it tries to escape "%" sign + // from the already escaped "/" sign from Network field. + uri := fmt.Sprintf("/%s/%s/teamnet/routes/network/%s%s", AccountRouteRoot, rc.Identifier, url.PathEscape(params.Network), buildURI("", params)) + + responseBody, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var routeResponse tunnelRouteResponse + err = json.Unmarshal(responseBody, &routeResponse) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// UpdateTunnelRoute updates an existing route in the account routing table for +// the given tunnel. +// +// See: https://api.cloudflare.com/#tunnel-route-update-route +func (api *API) UpdateTunnelRoute(ctx context.Context, rc *ResourceContainer, params TunnelRoutesUpdateParams) (TunnelRoute, error) { + if rc.Identifier == "" { + return TunnelRoute{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/%s/%s/teamnet/routes/network/%s", AccountRouteRoot, rc.Identifier, url.PathEscape(params.Network)) + + responseBody, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return TunnelRoute{}, err + } + + var routeResponse tunnelRouteResponse + err = json.Unmarshal(responseBody, &routeResponse) + if err != nil { + return TunnelRoute{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return routeResponse.Result, nil +} diff --git a/pkg/cloudflare-go/tunnel_routes_test.go b/pkg/cloudflare-go/tunnel_routes_test.go new file mode 100644 index 000000000..b9583bd11 --- /dev/null +++ b/pkg/cloudflare-go/tunnel_routes_test.go @@ -0,0 +1,222 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListTunnelRoutes(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "network": "ff01::/32", + "tunnel_id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "tunnel_name": "blog", + "comment": "Example comment for this route", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z", + "virtual_network_id": "9f322de4-5988-4945-b770-f1d6ac200f86" + } + ] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/routes", handler) + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + want := []TunnelRoute{ + { + "ff01::/32", + "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "blog", + "Example comment for this route", + &ts, + &ts, + "9f322de4-5988-4945-b770-f1d6ac200f86", + }, + } + + params := TunnelRoutesListParams{} + got, err := client.ListTunnelRoutes(context.Background(), AccountIdentifier(testAccountID), params) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestTunnelRouteForIP(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "network": "ff01::/32", + "tunnel_id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "tunnel_name": "blog", + "comment": "Example comment for this route", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z", + "virtual_network_id": "9f322de4-5988-4945-b770-f1d6ac200f86" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/routes/ip/10.1.0.137", handler) + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + want := TunnelRoute{ + Network: "ff01::/32", + TunnelID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + TunnelName: "blog", + Comment: "Example comment for this route", + CreatedAt: &ts, + DeletedAt: &ts, + VirtualNetworkID: "9f322de4-5988-4945-b770-f1d6ac200f86", + } + + got, err := client.GetTunnelRouteForIP(context.Background(), AccountIdentifier(testAccountID), TunnelRoutesForIPParams{Network: "10.1.0.137"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestCreateTunnelRoute(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "network": "10.0.0.0/16", + "tunnel_id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "tunnel_name": "blog", + "comment": "Example comment for this route", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z", + "virtual_network_id": "9f322de4-5988-4945-b770-f1d6ac200f86" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/routes/network/10.0.0.0/16", handler) + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + want := TunnelRoute{ + Network: "10.0.0.0/16", + TunnelID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + TunnelName: "blog", + Comment: "Example comment for this route", + CreatedAt: &ts, + DeletedAt: &ts, + VirtualNetworkID: "9f322de4-5988-4945-b770-f1d6ac200f86", + } + + tunnel, err := client.CreateTunnelRoute(context.Background(), AccountIdentifier(testAccountID), TunnelRoutesCreateParams{TunnelID: testTunnelID, Network: "10.0.0.0/16", Comment: "foo", VirtualNetworkID: "9f322de4-5988-4945-b770-f1d6ac200f86"}) + if assert.NoError(t, err) { + assert.Equal(t, want, tunnel) + } +} + +func TestUpdateTunnelRoute(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + _, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "network": "10.0.0.0/16", + "tunnel_id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "tunnel_name": "blog", + "comment": "Example comment for this route", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z", + "virtual_network_id": "9f322de4-5988-4945-b770-f1d6ac200f86" + } + }`) + } + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + want := TunnelRoute{ + Network: "10.0.0.0/16", + TunnelID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + TunnelName: "blog", + Comment: "Example comment for this route", + CreatedAt: &ts, + DeletedAt: &ts, + VirtualNetworkID: "9f322de4-5988-4945-b770-f1d6ac200f86", + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/routes/network/10.0.0.0/16", handler) + tunnel, err := client.UpdateTunnelRoute(context.Background(), AccountIdentifier(testAccountID), TunnelRoutesUpdateParams{TunnelID: testTunnelID, Network: "10.0.0.0/16", Comment: "foo", VirtualNetworkID: "9f322de4-5988-4945-b770-f1d6ac200f86"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, tunnel) + } +} + +func TestDeleteTunnelRoute(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "network": "ff01::/32", + "tunnel_id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "tunnel_name": "blog", + "comment": "Example comment for this route", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z", + "virtual_network_id": "9f322de4-5988-4945-b770-f1d6ac200f86" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/routes/network/10.0.0.0/16", handler) + err := client.DeleteTunnelRoute(context.Background(), AccountIdentifier(testAccountID), TunnelRoutesDeleteParams{Network: "10.0.0.0/16", VirtualNetworkID: "9f322de4-5988-4945-b770-f1d6ac200f86"}) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/tunnel_test.go b/pkg/cloudflare-go/tunnel_test.go new file mode 100644 index 000000000..da912ccf4 --- /dev/null +++ b/pkg/cloudflare-go/tunnel_test.go @@ -0,0 +1,410 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListTunnels(t *testing.T) { + setup() + defer teardown() + + 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, loadFixture("tunnel", "multiple_full")) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := []Tunnel{{ + ID: testTunnelID, + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []TunnelConnection{{ + ColoName: "DFW", + ID: testTunnelID, + IsPendingReconnect: false, + ClientID: "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + ClientVersion: "2022.2.0", + OpenedAt: "2021-01-25T18:22:34.317854Z", + OriginIP: "198.51.100.1", + }}, + }} + + actual, _, err := client.ListTunnels(context.Background(), AccountIdentifier(testAccountID), TunnelListParams{UUID: testTunnelID}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListTunnelsPagination(t *testing.T) { + setup() + defer teardown() + + 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") + qry, _ := url.Parse(r.RequestURI) + assert.Equal(t, "blog", qry.Query().Get("name")) + assert.Equal(t, "2", qry.Query().Get("page")) + assert.Equal(t, "1", qry.Query().Get("per_page")) + fmt.Fprint(w, loadFixture("tunnel", "multiple_full")) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := []Tunnel{ + { + ID: testTunnelID, + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []TunnelConnection{{ + ColoName: "DFW", + ID: testTunnelID, + IsPendingReconnect: false, + ClientID: "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + ClientVersion: "2022.2.0", + OpenedAt: "2021-01-25T18:22:34.317854Z", + OriginIP: "198.51.100.1", + }}, + }, + } + + actual, _, err := client.ListTunnels(context.Background(), AccountIdentifier(testAccountID), + TunnelListParams{ + Name: "blog", + ResultInfo: ResultInfo{ + Page: 2, + PerPage: 1, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetTunnel(t *testing.T) { + setup() + defer teardown() + + 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, loadFixture("tunnel", "single_full")) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel/"+testTunnelID, handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := Tunnel{ + ID: testTunnelID, + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []TunnelConnection{{ + ColoName: "DFW", + ID: testTunnelID, + IsPendingReconnect: false, + ClientID: "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + ClientVersion: "2022.2.0", + OpenedAt: "2021-01-25T18:22:34.317854Z", + OriginIP: "198.51.100.1", + }}, + TunnelType: "cfd_tunnel", + Status: "healthy", + RemoteConfig: true, + } + + actual, err := client.GetTunnel(context.Background(), AccountIdentifier(testAccountID), testTunnelID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateTunnel(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, loadFixture("tunnel", "single_full")) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel", handler) + + createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := Tunnel{ + ID: testTunnelID, + Name: "blog", + CreatedAt: &createdAt, + DeletedAt: &deletedAt, + Connections: []TunnelConnection{{ + ColoName: "DFW", + ID: testTunnelID, + IsPendingReconnect: false, + ClientID: "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + ClientVersion: "2022.2.0", + OpenedAt: "2021-01-25T18:22:34.317854Z", + OriginIP: "198.51.100.1", + }}, + TunnelType: "cfd_tunnel", + Status: "healthy", + RemoteConfig: true, + } + + actual, err := client.CreateTunnel(context.Background(), AccountIdentifier(testAccountID), TunnelCreateParams{Name: "blog", Secret: "notarealsecret", ConfigSrc: "cloudflare"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateTunnelConfiguration(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method '%s', got %s", http.MethodPut, r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, loadFixture("tunnel", "configuration")) + } + + timeout, _ := time.ParseDuration("10s") + mux.HandleFunc(fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/configurations", testAccountID, testTunnelID), handler) + want := TunnelConfigurationResult{ + TunnelID: testTunnelID, + Version: 5, + Config: TunnelConfiguration{ + Ingress: []UnvalidatedIngressRule{ + { + Hostname: "test.example.com", + Service: "https://localhost:8000", + OriginRequest: &OriginRequestConfig{ + NoTLSVerify: BoolPtr(true), + }, + }, + { + Service: "http_status:404", + }, + }, + WarpRouting: &WarpRoutingConfig{ + Enabled: true, + }, + OriginRequest: OriginRequestConfig{ + ConnectTimeout: &TunnelDuration{timeout}, + }, + }} + + actual, err := client.UpdateTunnelConfiguration(context.Background(), AccountIdentifier(testAccountID), TunnelConfigurationParams{ + TunnelID: testTunnelID, + Config: TunnelConfiguration{ + Ingress: []UnvalidatedIngressRule{ + { + Hostname: "test.example.com", + Service: "https://localhost:8000", + OriginRequest: &OriginRequestConfig{ + NoTLSVerify: BoolPtr(true), + }, + }, + { + Service: "http_status:404", + }, + }, + WarpRouting: &WarpRoutingConfig{ + Enabled: true, + }, + OriginRequest: OriginRequestConfig{ + ConnectTimeout: &TunnelDuration{10}, + }, + }, + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestGetTunnelConfiguration(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method '%s', got %s", http.MethodGet, r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, loadFixture("tunnel", "configuration")) + } + + timeout, _ := time.ParseDuration("10s") + mux.HandleFunc(fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/configurations", testAccountID, testTunnelID), handler) + want := TunnelConfigurationResult{ + TunnelID: testTunnelID, + Version: 5, + Config: TunnelConfiguration{ + Ingress: []UnvalidatedIngressRule{ + { + Hostname: "test.example.com", + Service: "https://localhost:8000", + OriginRequest: &OriginRequestConfig{ + NoTLSVerify: BoolPtr(true), + }, + }, + { + Service: "http_status:404", + }, + }, + WarpRouting: &WarpRoutingConfig{ + Enabled: true, + }, + OriginRequest: OriginRequestConfig{ + ConnectTimeout: &TunnelDuration{timeout}, + }, + }} + + actual, err := client.GetTunnelConfiguration(context.Background(), AccountIdentifier(testAccountID), testTunnelID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestTunnelConnections(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success":true, + "errors":[], + "messages":[], + "result":[{ + "id":"dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + "features": [ + "allow_remote_config", + "serialized_headers", + "ha-origin" + ], + "version": "2022.2.0", + "arch": "linux_amd64", + "config_version": 15, + "run_at":"2009-11-10T23:00:00Z", + "conns": [ + { + "colo_name": "DFW", + "id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415", + "is_pending_reconnect": false, + "client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + "client_version": "2022.2.0", + "opened_at": "2021-01-25T18:22:34.317854Z", + "origin_ip": "198.51.100.1" + } + ] + }] + } + `) + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", testAccountID, testTunnelID), handler) + + runAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z") + want := []Connection{ + { + ID: "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + Features: []string{ + "allow_remote_config", + "serialized_headers", + "ha-origin", + }, + Version: "2022.2.0", + Arch: "linux_amd64", + RunAt: &runAt, + Connections: []TunnelConnection{{ + ColoName: "DFW", + ID: testTunnelID, + IsPendingReconnect: false, + ClientID: "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90", + ClientVersion: "2022.2.0", + OpenedAt: "2021-01-25T18:22:34.317854Z", + OriginIP: "198.51.100.1", + }}, + ConfigVersion: 15, + }, + } + + actual, err := client.ListTunnelConnections(context.Background(), AccountIdentifier(testAccountID), testTunnelID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteTunnel(t *testing.T) { + setup() + defer teardown() + + 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, loadFixture("tunnel", "single_full")) + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", testAccountID, testTunnelID), handler) + + err := client.DeleteTunnel(context.Background(), AccountIdentifier(testAccountID), testTunnelID) + assert.NoError(t, err) +} + +func TestCleanupTunnelConnections(t *testing.T) { + setup() + defer teardown() + + 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, loadFixture("tunnel", "empty")) + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", testAccountID, testTunnelID), handler) + + err := client.CleanupTunnelConnections(context.Background(), AccountIdentifier(testAccountID), testTunnelID) + assert.NoError(t, err) +} + +func TestTunnelToken(t *testing.T) { + setup() + defer teardown() + + 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, loadFixture("tunnel", "token")) + } + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/token", testAccountID, testTunnelID), handler) + + token, err := client.GetTunnelToken(context.Background(), AccountIdentifier(testAccountID), testTunnelID) + assert.NoError(t, err) + assert.Equal(t, "ZHNraGdhc2RraGFza2hqZGFza2poZGFza2poYXNrZGpoYWtzamRoa2FzZGpoa2FzamRoa2Rhc2po\na2FzamRoa2FqCg==", token) +} diff --git a/pkg/cloudflare-go/tunnel_virtual_networks.go b/pkg/cloudflare-go/tunnel_virtual_networks.go new file mode 100644 index 000000000..26a6875ac --- /dev/null +++ b/pkg/cloudflare-go/tunnel_virtual_networks.go @@ -0,0 +1,159 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ErrMissingVnetName = errors.New("required missing virtual network name") + +// TunnelVirtualNetwork is segregation of Tunnel IP Routes via Virtualized +// Networks to handle overlapping private IPs in your origins. +type TunnelVirtualNetwork struct { + ID string `json:"id"` + Name string `json:"name"` + IsDefaultNetwork bool `json:"is_default_network"` + Comment string `json:"comment"` + CreatedAt *time.Time `json:"created_at"` + DeletedAt *time.Time `json:"deleted_at"` +} + +type TunnelVirtualNetworksListParams struct { + ID string `url:"id,omitempty"` + Name string `url:"name,omitempty"` + IsDefault *bool `url:"is_default,omitempty"` + IsDeleted *bool `url:"is_deleted,omitempty"` + + PaginationOptions +} + +type TunnelVirtualNetworkCreateParams struct { + Name string `json:"name"` + Comment string `json:"comment"` + IsDefault bool `json:"is_default"` +} + +type TunnelVirtualNetworkUpdateParams struct { + VnetID string `json:"-"` + Name string `json:"name,omitempty"` + Comment string `json:"comment,omitempty"` + IsDefaultNetwork *bool `json:"is_default_network,omitempty"` +} + +// tunnelRouteListResponse is the API response for listing tunnel virtual +// networks. +type tunnelVirtualNetworkListResponse struct { + Response + Result []TunnelVirtualNetwork `json:"result"` +} + +type tunnelVirtualNetworkResponse struct { + Response + Result TunnelVirtualNetwork `json:"result"` +} + +// ListTunnelVirtualNetworks lists all defined virtual networks for tunnels in +// the account. +// +// API reference: https://api.cloudflare.com/#tunnel-virtual-network-list-virtual-networks +func (api *API) ListTunnelVirtualNetworks(ctx context.Context, rc *ResourceContainer, params TunnelVirtualNetworksListParams) ([]TunnelVirtualNetwork, error) { + if rc.Identifier == "" { + return []TunnelVirtualNetwork{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/%s/%s/teamnet/virtual_networks", AccountRouteRoot, rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, params) + if err != nil { + return []TunnelVirtualNetwork{}, err + } + + var resp tunnelVirtualNetworkListResponse + err = json.Unmarshal(res, &resp) + if err != nil { + return []TunnelVirtualNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return resp.Result, nil +} + +// CreateTunnelVirtualNetwork adds a new virtual network to the account. +// +// API reference: https://api.cloudflare.com/#tunnel-virtual-network-create-virtual-network +func (api *API) CreateTunnelVirtualNetwork(ctx context.Context, rc *ResourceContainer, params TunnelVirtualNetworkCreateParams) (TunnelVirtualNetwork, error) { + if rc.Identifier == "" { + return TunnelVirtualNetwork{}, ErrMissingAccountID + } + + if params.Name == "" { + return TunnelVirtualNetwork{}, ErrMissingVnetName + } + + uri := fmt.Sprintf("/%s/%s/teamnet/virtual_networks", AccountRouteRoot, rc.Identifier) + + responseBody, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return TunnelVirtualNetwork{}, err + } + + var resp tunnelVirtualNetworkResponse + err = json.Unmarshal(responseBody, &resp) + if err != nil { + return TunnelVirtualNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return resp.Result, nil +} + +// DeleteTunnelVirtualNetwork deletes an existing virtual network from the +// account. +// +// API reference: https://api.cloudflare.com/#tunnel-virtual-network-delete-virtual-network +func (api *API) DeleteTunnelVirtualNetwork(ctx context.Context, rc *ResourceContainer, vnetID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf("/%s/%s/teamnet/virtual_networks/%s", AccountRouteRoot, rc.Identifier, vnetID) + + responseBody, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + var resp tunnelVirtualNetworkResponse + err = json.Unmarshal(responseBody, &resp) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// UpdateTunnelRoute updates an existing virtual network in the account. +// +// API reference: https://api.cloudflare.com/#tunnel-virtual-network-update-virtual-network +func (api *API) UpdateTunnelVirtualNetwork(ctx context.Context, rc *ResourceContainer, params TunnelVirtualNetworkUpdateParams) (TunnelVirtualNetwork, error) { + if rc.Identifier == "" { + return TunnelVirtualNetwork{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/%s/%s/teamnet/virtual_networks/%s", AccountRouteRoot, rc.Identifier, params.VnetID) + + responseBody, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return TunnelVirtualNetwork{}, err + } + + var resp tunnelVirtualNetworkResponse + err = json.Unmarshal(responseBody, &resp) + if err != nil { + return TunnelVirtualNetwork{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return resp.Result, nil +} diff --git a/pkg/cloudflare-go/tunnel_virtual_networks_test.go b/pkg/cloudflare-go/tunnel_virtual_networks_test.go new file mode 100644 index 000000000..2e6affdd2 --- /dev/null +++ b/pkg/cloudflare-go/tunnel_virtual_networks_test.go @@ -0,0 +1,185 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTunnelVirtualNetworks(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "name": "us-east-1-vpc", + "is_default_network": true, + "comment": "Staging VPC for data science", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z" + } + ] + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/virtual_networks", handler) + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + + want := []TunnelVirtualNetwork{ + { + ID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + Name: "us-east-1-vpc", + IsDefaultNetwork: true, + Comment: "Staging VPC for data science", + CreatedAt: &ts, + DeletedAt: &ts, + }, + } + + got, err := client.ListTunnelVirtualNetworks(context.Background(), AccountIdentifier(testAccountID), TunnelVirtualNetworksListParams{}) + + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestCreateTunnelVirtualNetwork(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "name": "us-east-1-vpc", + "is_default_network": true, + "comment": "Staging VPC for data science", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/virtual_networks", handler) + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + want := TunnelVirtualNetwork{ + ID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + Name: "us-east-1-vpc", + IsDefaultNetwork: true, + Comment: "Staging VPC for data science", + CreatedAt: &ts, + DeletedAt: &ts, + } + + tunnel, err := client.CreateTunnelVirtualNetwork(context.Background(), AccountIdentifier(testAccountID), TunnelVirtualNetworkCreateParams{ + Name: "us-east-1-vpc", + IsDefault: true, + Comment: "Staging VPC for data science", + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, tunnel) + } +} + +func TestUpdateTunnelVirtualNetwork(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + _, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "name": "us-east-1-vpc", + "is_default_network": true, + "comment": "Staging VPC for data science", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z" + } + }`) + } + + ts, _ := time.Parse(time.RFC3339Nano, "2021-01-25T18:22:34.317854Z") + want := TunnelVirtualNetwork{ + ID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + Name: "us-east-1-vpc", + IsDefaultNetwork: true, + Comment: "Staging VPC for data science", + CreatedAt: &ts, + DeletedAt: &ts, + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/virtual_networks/f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", handler) + + tunnel, err := client.UpdateTunnelVirtualNetwork(context.Background(), AccountIdentifier(testAccountID), TunnelVirtualNetworkUpdateParams{ + VnetID: "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + Name: "us-east-1-vpc", + IsDefaultNetwork: BoolPtr(true), + Comment: "Staging VPC for data science", + }) + + if assert.NoError(t, err) { + assert.Equal(t, want, tunnel) + } +} + +func TestDeleteTunnelVirtualNetwork(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", + "name": "us-east-1-vpc", + "is_default_network": true, + "comment": "Staging VPC for data science", + "created_at": "2021-01-25T18:22:34.317854Z", + "deleted_at": "2021-01-25T18:22:34.317854Z" + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/teamnet/virtual_networks/f70ff985-a4ef-4643-bbbc-4a0ed4fc8415", handler) + + err := client.DeleteTunnelVirtualNetwork(context.Background(), AccountIdentifier(testAccountID), "f70ff985-a4ef-4643-bbbc-4a0ed4fc8415") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/turnstile.go b/pkg/cloudflare-go/turnstile.go new file mode 100644 index 000000000..5bc9273cd --- /dev/null +++ b/pkg/cloudflare-go/turnstile.go @@ -0,0 +1,245 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ErrMissingSiteKey = errors.New("required site key missing") + +type TurnstileWidget struct { + SiteKey string `json:"sitekey,omitempty"` + Secret string `json:"secret,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + Name string `json:"name,omitempty"` + Domains []string `json:"domains,omitempty"` + Mode string `json:"mode,omitempty"` + BotFightMode bool `json:"bot_fight_mode,omitempty"` + Region string `json:"region,omitempty"` + OffLabel bool `json:"offlabel,omitempty"` +} + +type CreateTurnstileWidgetParams struct { + Name string `json:"name,omitempty"` + Domains []string `json:"domains,omitempty"` + Mode string `json:"mode,omitempty"` + BotFightMode bool `json:"bot_fight_mode,omitempty"` + Region string `json:"region,omitempty"` + OffLabel bool `json:"offlabel,omitempty"` +} + +type UpdateTurnstileWidgetParams struct { + SiteKey string `json:"-"` + Name string `json:"name,omitempty"` + Domains []string `json:"domains,omitempty"` + Mode string `json:"mode,omitempty"` + BotFightMode bool `json:"bot_fight_mode,omitempty"` + Region string `json:"region,omitempty"` + OffLabel bool `json:"offlabel,omitempty"` +} + +type TurnstileWidgetResponse struct { + Response + Result TurnstileWidget `json:"result"` +} + +type ListTurnstileWidgetParams struct { + ResultInfo + Direction string `url:"direction,omitempty"` + Order OrderDirection `url:"order,omitempty"` +} + +type ListTurnstileWidgetResponse struct { + Response + ResultInfo `json:"result_info"` + Result []TurnstileWidget `json:"result"` +} + +type RotateTurnstileWidgetParams struct { + SiteKey string `json:"-"` + InvalidateImmediately bool `json:"invalidate_immediately,omitempty"` +} + +// CreateTurnstileWidget creates a new challenge widgets. +// +// API reference: https://api.cloudflare.com/#challenge-widgets-properties +func (api *API) CreateTurnstileWidget(ctx context.Context, rc *ResourceContainer, params CreateTurnstileWidgetParams) (TurnstileWidget, error) { + if rc.Identifier == "" { + return TurnstileWidget{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/challenges/widgets", rc.Identifier) + res, err := api.makeRequestContext(ctx, "POST", uri, params) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r TurnstileWidgetResponse + err = json.Unmarshal(res, &r) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// ListTurnstileWidgets lists challenge widgets. +// +// API reference: https://api.cloudflare.com/#challenge-widgets-list-challenge-widgets +func (api *API) ListTurnstileWidgets(ctx context.Context, rc *ResourceContainer, params ListTurnstileWidgetParams) ([]TurnstileWidget, *ResultInfo, error) { + if rc.Identifier == "" { + return []TurnstileWidget{}, &ResultInfo{}, ErrMissingAccountID + } + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = 25 + } + + if params.Page < 1 { + params.Page = 1 + } + + var widgets []TurnstileWidget + var r ListTurnstileWidgetResponse + for { + uri := buildURI(fmt.Sprintf("/accounts/%s/challenges/widgets", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + if err != nil { + return []TurnstileWidget{}, &ResultInfo{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + err = json.Unmarshal(res, &r) + if err != nil { + return []TurnstileWidget{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + widgets = append(widgets, r.Result...) + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return widgets, &r.ResultInfo, nil +} + +// GetTurnstileWidget shows a single challenge widget configuration. +// +// API reference: https://api.cloudflare.com/#challenge-widgets-challenge-widget-details +func (api *API) GetTurnstileWidget(ctx context.Context, rc *ResourceContainer, siteKey string) (TurnstileWidget, error) { + if rc.Identifier == "" { + return TurnstileWidget{}, ErrMissingAccountID + } + + if siteKey == "" { + return TurnstileWidget{}, ErrMissingSiteKey + } + + uri := fmt.Sprintf("/accounts/%s/challenges/widgets/%s", rc.Identifier, siteKey) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r TurnstileWidgetResponse + err = json.Unmarshal(res, &r) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateTurnstileWidget update the configuration of a widget. +// +// API reference: https://api.cloudflare.com/#challenge-widgets-update-a-challenge-widget +func (api *API) UpdateTurnstileWidget(ctx context.Context, rc *ResourceContainer, params UpdateTurnstileWidgetParams) (TurnstileWidget, error) { + if rc.Identifier == "" { + return TurnstileWidget{}, ErrMissingAccountID + } + + if params.SiteKey == "" { + return TurnstileWidget{}, ErrMissingSiteKey + } + + uri := fmt.Sprintf("/accounts/%s/challenges/widgets/%s", rc.Identifier, params.SiteKey) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r TurnstileWidgetResponse + err = json.Unmarshal(res, &r) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// RotateTurnstileWidget generates a new secret key for this widget. If +// invalidate_immediately is set to false, the previous secret remains valid for +// 2 hours. +// +// Note that secrets cannot be rotated again during the grace period. +// +// API reference: https://api.cloudflare.com/#challenge-widgets-rotate-secret-for-a-challenge-widget +func (api *API) RotateTurnstileWidget(ctx context.Context, rc *ResourceContainer, param RotateTurnstileWidgetParams) (TurnstileWidget, error) { + if rc.Identifier == "" { + return TurnstileWidget{}, ErrMissingAccountID + } + if param.SiteKey == "" { + return TurnstileWidget{}, ErrMissingSiteKey + } + + uri := fmt.Sprintf("/accounts/%s/challenges/widgets/%s/rotate_secret", rc.Identifier, param.SiteKey) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, param) + + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r TurnstileWidgetResponse + err = json.Unmarshal(res, &r) + if err != nil { + return TurnstileWidget{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteTurnstileWidget delete a challenge widget. +// +// API reference: https://api.cloudflare.com/#challenge-widgets-delete-a-challenge-widget +func (api *API) DeleteTurnstileWidget(ctx context.Context, rc *ResourceContainer, siteKey string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if siteKey == "" { + return ErrMissingSiteKey + } + uri := fmt.Sprintf("/accounts/%s/challenges/widgets/%s", rc.Identifier, siteKey) + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r TurnstileWidgetResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/turnstile_test.go b/pkg/cloudflare-go/turnstile_test.go new file mode 100644 index 000000000..38e835514 --- /dev/null +++ b/pkg/cloudflare-go/turnstile_test.go @@ -0,0 +1,334 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testTurnstileWidgetSiteKey = "0x4AAF00AAAABn0R22HWm-YUc" + +var ( + turnstileWidgetCreatedOn, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.123123Z") + turnstileWidgetModifiedOn, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.123123Z") + expectedTurnstileWidget = TurnstileWidget{ + SiteKey: "0x4AAF00AAAABn0R22HWm-YUc", + Secret: "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + CreatedOn: &turnstileWidgetCreatedOn, + ModifiedOn: &turnstileWidgetModifiedOn, + Name: "blog.cloudflare.com login form", + Domains: []string{ + "203.0.113.1", + "cloudflare.com", + "blog.example.com", + }, + Mode: "invisible", + BotFightMode: true, + Region: "world", + OffLabel: false, + } +) + +func TestTurnstileWidget_Create(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/challenges/widgets", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "sitekey": "0x4AAF00AAAABn0R22HWm-YUc", + "secret": "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + "created_on": "2014-01-01T05:20:00.123123Z", + "modified_on": "2014-01-01T05:20:00.123123Z", + "name": "blog.cloudflare.com login form", + "domains": [ + "203.0.113.1", + "cloudflare.com", + "blog.example.com" + ], + "mode": "invisible", + "bot_fight_mode": true, + "region": "world", + "offlabel": false + } + }`) + }) + + // Make sure missing account ID is thrown + _, err := client.CreateTurnstileWidget(context.Background(), AccountIdentifier(""), CreateTurnstileWidgetParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + out, err := client.CreateTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), CreateTurnstileWidgetParams{ + Name: "blog.cloudflare.com login form", + Mode: "invisible", + BotFightMode: true, + Domains: []string{ + "203.0.113.1", + "cloudflare.com", + "blog.example.com", + }, + Region: "world", + OffLabel: false, + }) + if assert.NoError(t, err) { + assert.Equal(t, expectedTurnstileWidget, out, "create challenge_widgets structs not equal") + } +} + +func TestTurnstileWidget_List(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/challenges/widgets", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + assert.Equal(t, "asc", r.URL.Query().Get("order")) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "sitekey": "0x4AAF00AAAABn0R22HWm-YUc", + "secret": "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + "created_on": "2014-01-01T05:20:00.123123Z", + "modified_on": "2014-01-01T05:20:00.123123Z", + "name": "blog.cloudflare.com login form", + "domains": [ + "203.0.113.1", + "cloudflare.com", + "blog.example.com" + ], + "mode": "invisible", + "bot_fight_mode": true, + "region": "world", + "offlabel": false + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } +}`) + }) + + _, _, err := client.ListTurnstileWidgets(context.Background(), AccountIdentifier(""), ListTurnstileWidgetParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + out, results, err := client.ListTurnstileWidgets(context.Background(), AccountIdentifier(testAccountID), ListTurnstileWidgetParams{ + Order: OrderDirectionAsc, + }) + if assert.NoError(t, err) { + assert.Equal(t, 1, len(out), "expected 1 challenge_widgets") + assert.Equal(t, 20, results.PerPage, "expected 20 per page") + assert.Equal(t, expectedTurnstileWidget, out[0], "list challenge_widgets structs not equal") + } +} + +func TestTurnstileWidget_Get(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/challenges/widgets/"+testTurnstileWidgetSiteKey, 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, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "sitekey": "0x4AAF00AAAABn0R22HWm-YUc", + "secret": "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + "created_on": "2014-01-01T05:20:00.123123Z", + "modified_on": "2014-01-01T05:20:00.123123Z", + "name": "blog.cloudflare.com login form", + "domains": [ + "203.0.113.1", + "cloudflare.com", + "blog.example.com" + ], + "mode": "invisible", + "bot_fight_mode": true, + "region": "world", + "offlabel": false + } +}`) + }) + + _, err := client.GetTurnstileWidget(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.GetTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingSiteKey, err) + } + + out, err := client.GetTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), testTurnstileWidgetSiteKey) + + if assert.NoError(t, err) { + assert.Equal(t, expectedTurnstileWidget, out, "get challenge_widgets structs not equal") + } +} + +func TestTurnstileWidgets_Update(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/challenges/widgets/"+testTurnstileWidgetSiteKey, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "sitekey": "0x4AAF00AAAABn0R22HWm-YUc", + "secret": "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + "created_on": "2014-01-01T05:20:00.123123Z", + "modified_on": "2014-01-01T05:20:00.123123Z", + "name": "blog.cloudflare.com login form", + "domains": [ + "203.0.113.1", + "cloudflare.com", + "blog.example.com" + ], + "mode": "invisible", + "bot_fight_mode": true, + "region": "world", + "offlabel": false + } +}`) + }) + + _, err := client.UpdateTurnstileWidget(context.Background(), AccountIdentifier(""), UpdateTurnstileWidgetParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.UpdateTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), UpdateTurnstileWidgetParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingSiteKey, err) + } + + out, err := client.UpdateTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), UpdateTurnstileWidgetParams{ + SiteKey: testTurnstileWidgetSiteKey, + }) + if assert.NoError(t, err) { + assert.Equal(t, expectedTurnstileWidget, out, "update challenge_widgets structs not equal") + } +} + +func TestTurnstileWidgets_RotateSecret(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/challenges/widgets/"+testTurnstileWidgetSiteKey+"/rotate_secret", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "sitekey": "0x4AAF00AAAABn0R22HWm-YUc", + "secret": "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + "created_on": "2014-01-01T05:20:00.123123Z", + "modified_on": "2014-01-01T05:20:00.123123Z", + "name": "blog.cloudflare.com login form", + "domains": [ + "203.0.113.1", + "cloudflare.com", + "blog.example.com" + ], + "mode": "invisible", + "bot_fight_mode": true, + "region": "world", + "offlabel": false + } +}`) + }) + + // Make sure missing account ID is thrown + _, err := client.RotateTurnstileWidget(context.Background(), AccountIdentifier(""), RotateTurnstileWidgetParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.RotateTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), RotateTurnstileWidgetParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingSiteKey, err) + } + + out, err := client.RotateTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), RotateTurnstileWidgetParams{SiteKey: testTurnstileWidgetSiteKey}) + if assert.NoError(t, err) { + assert.Equal(t, expectedTurnstileWidget, out, "rotate challenge_widgets structs not equal") + } +} + +func TestTurnstileWidgets_Delete(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/challenges/widgets/"+testTurnstileWidgetSiteKey, 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, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "sitekey": "0x4AAF00AAAABn0R22HWm-YUc", + "secret": "0x4AAF00AAAABn0R22HWm098HVBjhdsYUc", + "created_on": "2014-01-01T05:20:00.123123Z", + "modified_on": "2014-01-01T05:20:00.123123Z", + "name": "blog.cloudflare.com login form", + "domains": [ + "203.0.113.1", + "cloudflare.com", + "blog.example.com" + ], + "mode": "invisible", + "bot_fight_mode": true, + "region": "world", + "offlabel": false + } +}`) + }) + + // Make sure missing account ID is thrown + err := client.DeleteTurnstileWidget(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + err = client.DeleteTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingSiteKey, err) + } + + err = client.DeleteTurnstileWidget(context.Background(), AccountIdentifier(testAccountID), testTurnstileWidgetSiteKey) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/universal_ssl.go b/pkg/cloudflare-go/universal_ssl.go new file mode 100644 index 000000000..a0b7a1bb9 --- /dev/null +++ b/pkg/cloudflare-go/universal_ssl.go @@ -0,0 +1,108 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// UniversalSSLSetting represents a universal ssl setting's properties. +type UniversalSSLSetting struct { + Enabled bool `json:"enabled"` +} + +type universalSSLSettingResponse struct { + Response + Result UniversalSSLSetting `json:"result"` +} + +// UniversalSSLVerificationDetails represents a universal ssl verification's properties. +type UniversalSSLVerificationDetails struct { + CertificateStatus string `json:"certificate_status"` + VerificationType string `json:"verification_type"` + ValidationMethod string `json:"validation_method"` + CertPackUUID string `json:"cert_pack_uuid"` + VerificationStatus bool `json:"verification_status"` + BrandCheck bool `json:"brand_check"` + VerificationInfo []SSLValidationRecord `json:"verification_info"` +} + +type universalSSLVerificationResponse struct { + Response + Result []UniversalSSLVerificationDetails `json:"result"` +} + +type UniversalSSLCertificatePackValidationMethodSetting struct { + ValidationMethod string `json:"validation_method"` +} + +type universalSSLCertificatePackValidationMethodSettingResponse struct { + Response + Result UniversalSSLCertificatePackValidationMethodSetting `json:"result"` +} + +// UniversalSSLSettingDetails returns the details for a universal ssl setting +// +// API reference: https://api.cloudflare.com/#universal-ssl-settings-for-a-zone-universal-ssl-settings-details +func (api *API) UniversalSSLSettingDetails(ctx context.Context, zoneID string) (UniversalSSLSetting, error) { + uri := fmt.Sprintf("/zones/%s/ssl/universal/settings", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return UniversalSSLSetting{}, err + } + var r universalSSLSettingResponse + if err := json.Unmarshal(res, &r); err != nil { + return UniversalSSLSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// EditUniversalSSLSetting edits the universal ssl setting for a zone +// +// API reference: https://api.cloudflare.com/#universal-ssl-settings-for-a-zone-edit-universal-ssl-settings +func (api *API) EditUniversalSSLSetting(ctx context.Context, zoneID string, setting UniversalSSLSetting) (UniversalSSLSetting, error) { + uri := fmt.Sprintf("/zones/%s/ssl/universal/settings", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, setting) + if err != nil { + return UniversalSSLSetting{}, err + } + var r universalSSLSettingResponse + if err := json.Unmarshal(res, &r); err != nil { + return UniversalSSLSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UniversalSSLVerificationDetails returns the details for a universal ssl verification +// +// API reference: https://api.cloudflare.com/#ssl-verification-ssl-verification-details +func (api *API) UniversalSSLVerificationDetails(ctx context.Context, zoneID string) ([]UniversalSSLVerificationDetails, error) { + uri := fmt.Sprintf("/zones/%s/ssl/verification", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []UniversalSSLVerificationDetails{}, err + } + var r universalSSLVerificationResponse + if err := json.Unmarshal(res, &r); err != nil { + return []UniversalSSLVerificationDetails{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateUniversalSSLCertificatePackValidationMethod changes the validation method for a certificate pack +// +// API reference: https://api.cloudflare.com/#ssl-verification-ssl-verification-details +func (api *API) UpdateUniversalSSLCertificatePackValidationMethod(ctx context.Context, zoneID string, certPackUUID string, setting UniversalSSLCertificatePackValidationMethodSetting) (UniversalSSLCertificatePackValidationMethodSetting, error) { + uri := fmt.Sprintf("/zones/%s/ssl/verification/%s", zoneID, certPackUUID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, setting) + if err != nil { + return UniversalSSLCertificatePackValidationMethodSetting{}, err + } + var r universalSSLCertificatePackValidationMethodSettingResponse + if err := json.Unmarshal(res, &r); err != nil { + return UniversalSSLCertificatePackValidationMethodSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/universal_ssl_test.go b/pkg/cloudflare-go/universal_ssl_test.go new file mode 100644 index 000000000..3b2e16922 --- /dev/null +++ b/pkg/cloudflare-go/universal_ssl_test.go @@ -0,0 +1,164 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUniversalSSLSettingDetails(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "enabled": true + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/universal/settings", handler) + + want := UniversalSSLSetting{ + Enabled: true, + } + + got, err := client.UniversalSSLSettingDetails(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestEditUniversalSSLSetting(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + assert.Equal(t, `{"enabled":true}`, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "enabled": true + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/universal/settings", handler) + + want := UniversalSSLSetting{ + Enabled: true, + } + + got, err := client.EditUniversalSSLSetting(context.Background(), testZoneID, want) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestUniversalSSLVerificationDetails(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": [{ + "certificate_status": "active", + "verification_type": "cname", + "verification_status": true, + "verification_info": [ + { + "status": "pending", + "http_url": "http://example.com/.well-known/acme-challenge/Km-ycWoOVh10cLfL4pRPppGt6jU_mGz8xgvNOxudMiA", + "http_body": "Km-ycWoOVh10cLfL4pRPppGt6jU_mGz8xgvNOxudMiA.Jckzm7Z9uOFls_MXPYibNRz6koY5a8qpI_BeHtDtf-g" + } + ], + "brand_check": false, + "validation_method": "txt", + "cert_pack_uuid": "a77f8bd7-3b47-46b4-a6f1-75cf98109948" + }] + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/verification", handler) + + want := []UniversalSSLVerificationDetails{ + { + CertificateStatus: "active", + VerificationType: "cname", + ValidationMethod: "txt", + CertPackUUID: "a77f8bd7-3b47-46b4-a6f1-75cf98109948", + VerificationStatus: true, + BrandCheck: false, + VerificationInfo: []SSLValidationRecord{{ + HTTPUrl: "http://example.com/.well-known/acme-challenge/Km-ycWoOVh10cLfL4pRPppGt6jU_mGz8xgvNOxudMiA", + HTTPBody: "Km-ycWoOVh10cLfL4pRPppGt6jU_mGz8xgvNOxudMiA.Jckzm7Z9uOFls_MXPYibNRz6koY5a8qpI_BeHtDtf-g", + }}, + }, + } + + got, err := client.UniversalSSLVerificationDetails(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestUpdateSSLCertificatePackValidationMethod(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + assert.Equal(t, `{"validation_method":"txt"}`, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "validation_method": "txt", + "status": "pending_validation" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/ssl/verification/"+testCertPackUUID, handler) + + want := UniversalSSLCertificatePackValidationMethodSetting{ + ValidationMethod: "txt", + } + + got, err := client.UpdateUniversalSSLCertificatePackValidationMethod(context.Background(), testZoneID, testCertPackUUID, want) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} diff --git a/pkg/cloudflare-go/url_normalization_settings.go b/pkg/cloudflare-go/url_normalization_settings.go new file mode 100644 index 000000000..c919100fa --- /dev/null +++ b/pkg/cloudflare-go/url_normalization_settings.go @@ -0,0 +1,60 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type URLNormalizationSettings struct { + Type string `json:"type"` + Scope string `json:"scope"` +} + +type URLNormalizationSettingsResponse struct { + Result URLNormalizationSettings `json:"result"` + Response +} + +type URLNormalizationSettingsUpdateParams struct { + Type string `json:"type"` + Scope string `json:"scope"` +} + +// URLNormalizationSettings API reference: https://api.cloudflare.com/#url-normalization-get-url-normalization-settings +func (api *API) URLNormalizationSettings(ctx context.Context, rc *ResourceContainer) (URLNormalizationSettings, error) { + uri := fmt.Sprintf("/zones/%s/url_normalization", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return URLNormalizationSettings{}, err + } + + var urlNormalizationSettingsResponse URLNormalizationSettingsResponse + err = json.Unmarshal(res, &urlNormalizationSettingsResponse) + if err != nil { + return URLNormalizationSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return urlNormalizationSettingsResponse.Result, nil +} + +// UpdateURLNormalizationSettings https://api.cloudflare.com/#url-normalization-update-url-normalization-settings +func (api *API) UpdateURLNormalizationSettings(ctx context.Context, rc *ResourceContainer, params URLNormalizationSettingsUpdateParams) (URLNormalizationSettings, error) { + uri := fmt.Sprintf("/zones/%s/url_normalization", rc.Identifier) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return URLNormalizationSettings{}, err + } + + var urlNormalizationSettingsResponse URLNormalizationSettingsResponse + err = json.Unmarshal(res, &urlNormalizationSettingsResponse) + if err != nil { + return URLNormalizationSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return urlNormalizationSettingsResponse.Result, nil +} diff --git a/pkg/cloudflare-go/url_normalization_settings_test.go b/pkg/cloudflare-go/url_normalization_settings_test.go new file mode 100644 index 000000000..6a6ac1116 --- /dev/null +++ b/pkg/cloudflare-go/url_normalization_settings_test.go @@ -0,0 +1,86 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestURLNormalizationSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "type": "cloudflare", + "scope": "incoming" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/url_normalization", handler) + + want := URLNormalizationSettings{ + Type: "cloudflare", + Scope: "incoming", + } + + got, err := client.URLNormalizationSettings(context.Background(), ZoneIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} + +func TestUpdateURLNormalizationSettings(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + assert.Equal(t, `{"type":"cloudflare","scope":"incoming"}`, string(body)) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "type": "cloudflare", + "scope": "incoming" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/url_normalization", handler) + + params := URLNormalizationSettingsUpdateParams{ + Type: "cloudflare", + Scope: "incoming", + } + + want := URLNormalizationSettings{ + Type: "cloudflare", + Scope: "incoming", + } + + got, err := client.UpdateURLNormalizationSettings(context.Background(), ZoneIdentifier(testZoneID), params) + if assert.NoError(t, err) { + assert.Equal(t, want, got) + } +} diff --git a/pkg/cloudflare-go/user.go b/pkg/cloudflare-go/user.go new file mode 100644 index 000000000..90feb8990 --- /dev/null +++ b/pkg/cloudflare-go/user.go @@ -0,0 +1,161 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// User describes a user account. +type User struct { + ID string `json:"id,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` + Telephone string `json:"telephone,omitempty"` + Country string `json:"country,omitempty"` + Zipcode string `json:"zipcode,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + APIKey string `json:"api_key,omitempty"` + TwoFA bool `json:"two_factor_authentication_enabled,omitempty"` + Betas []string `json:"betas,omitempty"` + Accounts []Account `json:"organizations,omitempty"` +} + +// UserResponse wraps a response containing User accounts. +type UserResponse struct { + Response + Result User `json:"result"` +} + +// userBillingProfileResponse wraps a response containing Billing Profile information. +type userBillingProfileResponse struct { + Response + Result UserBillingProfile +} + +// UserBillingProfile contains Billing Profile information. +type UserBillingProfile struct { + ID string `json:"id,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Address string `json:"address,omitempty"` + Address2 string `json:"address2,omitempty"` + Company string `json:"company,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + ZipCode string `json:"zipcode,omitempty"` + Country string `json:"country,omitempty"` + Telephone string `json:"telephone,omitempty"` + CardNumber string `json:"card_number,omitempty"` + CardExpiryYear int `json:"card_expiry_year,omitempty"` + CardExpiryMonth int `json:"card_expiry_month,omitempty"` + VAT string `json:"vat,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + EditedOn *time.Time `json:"edited_on,omitempty"` +} + +type UserBillingHistoryResponse struct { + Response + Result []UserBillingHistory `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +type UserBillingHistory struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Action string `json:"action,omitempty"` + Description string `json:"description,omitempty"` + OccurredAt *time.Time `json:"occurred_at,omitempty"` + Amount float32 `json:"amount,omitempty"` + Currency string `json:"currency,omitempty"` + Zone userBillingHistoryZone `json:"zone"` +} + +type userBillingHistoryZone struct { + Name string `json:"name,omitempty"` +} + +type UserBillingOptions struct { + PaginationOptions + Order string `url:"order,omitempty"` + Type string `url:"type,omitempty"` + OccurredAt *time.Time `url:"occurred_at,omitempty"` + Action string `url:"action,omitempty"` +} + +// UserDetails provides information about the logged-in user. +// +// API reference: https://api.cloudflare.com/#user-user-details +func (api *API) UserDetails(ctx context.Context) (User, error) { + var r UserResponse + res, err := api.makeRequestContext(ctx, http.MethodGet, "/user", nil) + if err != nil { + return User{}, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return User{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateUser updates the properties of the given user. +// +// API reference: https://api.cloudflare.com/#user-update-user +func (api *API) UpdateUser(ctx context.Context, user *User) (User, error) { + var r UserResponse + res, err := api.makeRequestContext(ctx, http.MethodPatch, "/user", user) + if err != nil { + return User{}, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return User{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UserBillingProfile returns the billing profile of the user. +// +// API reference: https://api.cloudflare.com/#user-billing-profile +func (api *API) UserBillingProfile(ctx context.Context) (UserBillingProfile, error) { + var r userBillingProfileResponse + res, err := api.makeRequestContext(ctx, http.MethodGet, "/user/billing/profile", nil) + if err != nil { + return UserBillingProfile{}, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return UserBillingProfile{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UserBillingHistory return the billing history of the user +// +// API reference: https://api.cloudflare.com/#user-billing-history-billing-history-details +func (api *API) UserBillingHistory(ctx context.Context, pageOpts UserBillingOptions) ([]UserBillingHistory, error) { + uri := buildURI("/user/billing/history", pageOpts) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []UserBillingHistory{}, err + } + var r UserBillingHistoryResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []UserBillingHistory{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/user_agent.go b/pkg/cloudflare-go/user_agent.go new file mode 100644 index 000000000..10449bbc0 --- /dev/null +++ b/pkg/cloudflare-go/user_agent.go @@ -0,0 +1,151 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/goccy/go-json" +) + +// UserAgentRule represents a User-Agent Block. These rules can be used to +// challenge, block or whitelist specific User-Agents for a given zone. +type UserAgentRule struct { + ID string `json:"id"` + Description string `json:"description"` + Mode string `json:"mode"` + Configuration UserAgentRuleConfig `json:"configuration"` + Paused bool `json:"paused"` +} + +// UserAgentRuleConfig represents a Zone Lockdown config, which comprises +// a Target ("ip" or "ip_range") and a Value (an IP address or IP+mask, +// respectively.) +type UserAgentRuleConfig ZoneLockdownConfig + +// UserAgentRuleResponse represents a response from the Zone Lockdown endpoint. +type UserAgentRuleResponse struct { + Result UserAgentRule `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// UserAgentRuleListResponse represents a response from the List Zone Lockdown endpoint. +type UserAgentRuleListResponse struct { + Result []UserAgentRule `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// CreateUserAgentRule creates a User-Agent Block rule for the given zone ID. +// +// API reference: https://api.cloudflare.com/#user-agent-blocking-rules-create-a-useragent-rule +func (api *API) CreateUserAgentRule(ctx context.Context, zoneID string, ld UserAgentRule) (*UserAgentRuleResponse, error) { + switch ld.Mode { + case "block", "challenge", "js_challenge", "managed_challenge": + break + default: + return nil, errors.New(`the User-Agent Block rule mode must be one of "block", "challenge", "js_challenge", "managed_challenge"`) + } + + uri := fmt.Sprintf("/zones/%s/firewall/ua_rules", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, ld) + if err != nil { + return nil, err + } + + response := &UserAgentRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// UpdateUserAgentRule updates a User-Agent Block rule (based on the ID) for the given zone ID. +// +// API reference: https://api.cloudflare.com/#user-agent-blocking-rules-update-useragent-rule +func (api *API) UpdateUserAgentRule(ctx context.Context, zoneID string, id string, ld UserAgentRule) (*UserAgentRuleResponse, error) { + uri := fmt.Sprintf("/zones/%s/firewall/ua_rules/%s", zoneID, id) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, ld) + if err != nil { + return nil, err + } + + response := &UserAgentRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// DeleteUserAgentRule deletes a User-Agent Block rule (based on the ID) for the given zone ID. +// +// API reference: https://api.cloudflare.com/#user-agent-blocking-rules-delete-useragent-rule +func (api *API) DeleteUserAgentRule(ctx context.Context, zoneID string, id string) (*UserAgentRuleResponse, error) { + uri := fmt.Sprintf("/zones/%s/firewall/ua_rules/%s", zoneID, id) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + + response := &UserAgentRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// UserAgentRule retrieves a User-Agent Block rule (based on the ID) for the given zone ID. +// +// API reference: https://api.cloudflare.com/#user-agent-blocking-rules-useragent-rule-details +func (api *API) UserAgentRule(ctx context.Context, zoneID string, id string) (*UserAgentRuleResponse, error) { + uri := fmt.Sprintf("/zones/%s/firewall/ua_rules/%s", zoneID, id) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + response := &UserAgentRuleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// ListUserAgentRules retrieves a list of User-Agent Block rules for a given zone ID by page number. +// +// API reference: https://api.cloudflare.com/#user-agent-blocking-rules-list-useragent-rules +func (api *API) ListUserAgentRules(ctx context.Context, zoneID string, page int) (*UserAgentRuleListResponse, error) { + v := url.Values{} + if page <= 0 { + page = 1 + } + + v.Set("page", strconv.Itoa(page)) + v.Set("per_page", strconv.Itoa(100)) + + uri := fmt.Sprintf("/zones/%s/firewall/ua_rules?%s", zoneID, v.Encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + response := &UserAgentRuleListResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} diff --git a/pkg/cloudflare-go/user_agent_example_test.go b/pkg/cloudflare-go/user_agent_example_test.go new file mode 100644 index 000000000..26db115a4 --- /dev/null +++ b/pkg/cloudflare-go/user_agent_example_test.go @@ -0,0 +1,31 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ListUserAgentRules_all() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + zoneID, err := api.ZoneIDByName("example.com") + if err != nil { + log.Fatal(err) + } + + // Fetch all Zone Lockdown rules for a zone, by page. + rules, err := api.ListUserAgentRules(context.Background(), zoneID, 1) + if err != nil { + log.Fatal(err) + } + + for _, r := range rules.Result { + fmt.Printf("%s: %s\n", r.Configuration.Target, r.Configuration.Value) + } +} diff --git a/pkg/cloudflare-go/user_test.go b/pkg/cloudflare-go/user_test.go new file mode 100644 index 000000000..d3d70903d --- /dev/null +++ b/pkg/cloudflare-go/user_test.go @@ -0,0 +1,244 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestUser_UserDetails(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/user", 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.Fprintf(w, `{ +"success": true, +"errors": [], +"messages": [], +"result": { + "id": "1", + "email": "cloudflare@example.com", + "first_name": "Jane", + "last_name": "Smith", + "username": "cloudflare12345", + "telephone": "+1 (650) 319 8930", + "country": "US", + "zipcode": "94107", + "created_on": "2009-07-01T00:00:00Z", + "modified_on": "2016-05-06T20:32:00Z", + "two_factor_authentication_enabled": true, + "betas": ["mirage_forever"] + } +}`) + }) + + user, err := client.UserDetails(context.Background()) + + createdOn, _ := time.Parse(time.RFC3339, "2009-07-01T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2016-05-06T20:32:00Z") + + want := User{ + ID: "1", + Email: "cloudflare@example.com", + FirstName: "Jane", + LastName: "Smith", + Username: "cloudflare12345", + Telephone: "+1 (650) 319 8930", + Country: "US", + Zipcode: "94107", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + TwoFA: true, + Betas: []string{"mirage_forever"}, + } + + if assert.NoError(t, err) { + assert.Equal(t, user, want) + } +} + +func TestUser_UpdateUser(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + if assert.NoError(t, err) { + assert.JSONEq(t, `{"country":"US","first_name":"John","username":"cfuser12345","email":"user@example.com", + "last_name": "Appleseed","telephone": "+1 123-123-1234","zipcode": "12345"}`, string(b), "JSON not equal") + } + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "first_name": "John", + "last_name": "Appleseed", + "username": "cfuser12345", + "telephone": "+1 123-123-1234", + "country": "US", + "zipcode": "12345", + "created_on": "2014-01-01T05:20:00Z", + "modified_on": "2014-01-01T05:20:00Z", + "two_factor_authentication_enabled": false + } +}`) + }) + + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00Z") + + userIn := User{ + Email: "user@example.com", + FirstName: "John", + LastName: "Appleseed", + Username: "cfuser12345", + Telephone: "+1 123-123-1234", + Country: "US", + Zipcode: "12345", + TwoFA: false, + } + + userOut, err := client.UpdateUser(context.Background(), &userIn) + + want := User{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + FirstName: "John", + LastName: "Appleseed", + Username: "cfuser12345", + Telephone: "+1 123-123-1234", + Country: "US", + Zipcode: "12345", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + TwoFA: false, + } + + if assert.NoError(t, err) { + assert.Equal(t, userOut, want, "structs not equal") + } +} + +func TestUser_UserBillingProfile(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/user/billing/profile", 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "0020c268dbf54e975e7fe8563df49d52", + "first_name": "Bob", + "last_name": "Smith", + "address": "123 3rd St.", + "address2": "Apt 123", + "company": "Cloudflare", + "city": "San Francisco", + "state": "CA", + "zipcode": "12345", + "country": "US", + "telephone": "+1 111-867-5309", + "card_number": "xxxx-xxxx-xxxx-1234", + "card_expiry_year": 2015, + "card_expiry_month": 4, + "vat": "aaa-123-987", + "edited_on": "2014-04-01T12:21:02.0000Z", + "created_on": "2014-03-01T12:21:02.0000Z" + } +}`) + }) + + createdOn, _ := time.Parse(time.RFC3339, "2014-03-01T12:21:02.0000Z") + editedOn, _ := time.Parse(time.RFC3339, "2014-04-01T12:21:02.0000Z") + + userBillingProfile, err := client.UserBillingProfile(context.Background()) + + want := UserBillingProfile{ + ID: "0020c268dbf54e975e7fe8563df49d52", + FirstName: "Bob", + LastName: "Smith", + Address: "123 3rd St.", + Address2: "Apt 123", + Company: "Cloudflare", + City: "San Francisco", + State: "CA", + ZipCode: "12345", + Country: "US", + Telephone: "+1 111-867-5309", + CardNumber: "xxxx-xxxx-xxxx-1234", + CardExpiryYear: 2015, + CardExpiryMonth: 4, + VAT: "aaa-123-987", + CreatedOn: &createdOn, + EditedOn: &editedOn, + } + + if assert.NoError(t, err) { + assert.Equal(t, userBillingProfile, want, "structs not equal") + } +} + +func TestUser_UserBillingHistory(t *testing.T) { + setup() + defer teardown() + mux.HandleFunc("/user/billing/history", 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "b69a9f3492637782896352daae219e7d", + "type": "charge", + "action": "subscription", + "description": "The billing item description", + "occurred_at": "2014-03-01T12:21:59.3456Z", + "amount": 20.99, + "currency": "USD", + "zone": { + "name": "example.com" + } + } + ] +}`) + }) + + userBillingProfile, err := client.UserBillingHistory(context.Background(), UserBillingOptions{}) + + OccurredAt, _ := time.Parse(time.RFC3339, "2014-03-01T12:21:59.3456Z") + + want := []UserBillingHistory{{ + ID: "b69a9f3492637782896352daae219e7d", + Type: "charge", + Action: "subscription", + Description: "The billing item description", + OccurredAt: &OccurredAt, + Amount: 20.99, + Currency: "USD", + Zone: userBillingHistoryZone{Name: "example.com"}, + }} + + if assert.NoError(t, err) { + assert.Equal(t, userBillingProfile, want, "structs not equal") + } +} diff --git a/pkg/cloudflare-go/utils.go b/pkg/cloudflare-go/utils.go new file mode 100644 index 000000000..bc2dc5633 --- /dev/null +++ b/pkg/cloudflare-go/utils.go @@ -0,0 +1,28 @@ +package cloudflare + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/google/go-querystring/query" +) + +// buildURI assembles the base path and queries. +func buildURI(path string, options interface{}) string { + v, _ := query.Values(options) + return (&url.URL{Path: path, RawQuery: v.Encode()}).String() +} + +// loadFixture takes a series of path components and returns the JSON fixture at +// that location associated. +func loadFixture(parts ...string) string { + paths := []string{"testdata", "fixtures"} + paths = append(paths, parts...) + b, err := os.ReadFile(filepath.Join(paths...) + ".json") + if err != nil { + fmt.Print(err) + } + return string(b) +} diff --git a/pkg/cloudflare-go/utils_test.go b/pkg/cloudflare-go/utils_test.go new file mode 100644 index 000000000..5973d8c72 --- /dev/null +++ b/pkg/cloudflare-go/utils_test.go @@ -0,0 +1,38 @@ +package cloudflare + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type testExample struct { + A string `url:"a,omitempty"` + C string `url:"c,omitempty"` + + PaginationOptions +} + +func Test_buildURI(t *testing.T) { + tests := map[string]struct { + path string + params interface{} + want string + }{ + "multi level path without params": {path: "/accounts/foo", params: testExample{}, want: "/accounts/foo"}, + "multi level path with params": {path: "/zones/foo", params: testExample{A: "b"}, want: "/zones/foo?a=b"}, + "multi level path with multiple params": {path: "/zones/foo", params: testExample{A: "b", C: "d"}, want: "/zones/foo?a=b&c=d"}, + "multi level path with nested fields": {path: "/zones/foo", params: testExample{A: "b", C: "d", PaginationOptions: PaginationOptions{PerPage: 10}}, want: "/zones/foo?a=b&c=d&per_page=10"}, + "single level path without params": {path: "/foo", params: testExample{}, want: "/foo"}, + "single level path with params": {path: "/bar", params: testExample{C: "d"}, want: "/bar?c=d"}, + "single level path with multiple params": {path: "/foo", params: testExample{A: "b", C: "d"}, want: "/foo?a=b&c=d"}, + "single level path with nested fields": {path: "/foo", params: testExample{A: "b", C: "d", PaginationOptions: PaginationOptions{PerPage: 10}}, want: "/foo?a=b&c=d&per_page=10"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := buildURI(tc.path, tc.params) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/cloudflare-go/waf.go b/pkg/cloudflare-go/waf.go new file mode 100644 index 000000000..c7a458a0c --- /dev/null +++ b/pkg/cloudflare-go/waf.go @@ -0,0 +1,352 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/goccy/go-json" +) + +// WAFPackage represents a WAF package configuration. +type WAFPackage struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ZoneID string `json:"zone_id"` + DetectionMode string `json:"detection_mode"` + Sensitivity string `json:"sensitivity"` + ActionMode string `json:"action_mode"` +} + +// WAFPackagesResponse represents the response from the WAF packages endpoint. +type WAFPackagesResponse struct { + Response + Result []WAFPackage `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFPackageResponse represents the response from the WAF package endpoint. +type WAFPackageResponse struct { + Response + Result WAFPackage `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFPackageOptions represents options to edit a WAF package. +type WAFPackageOptions struct { + Sensitivity string `json:"sensitivity,omitempty"` + ActionMode string `json:"action_mode,omitempty"` +} + +// WAFGroup represents a WAF rule group. +type WAFGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + RulesCount int `json:"rules_count"` + ModifiedRulesCount int `json:"modified_rules_count"` + PackageID string `json:"package_id"` + Mode string `json:"mode"` + AllowedModes []string `json:"allowed_modes"` +} + +// WAFGroupsResponse represents the response from the WAF groups endpoint. +type WAFGroupsResponse struct { + Response + Result []WAFGroup `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFGroupResponse represents the response from the WAF group endpoint. +type WAFGroupResponse struct { + Response + Result WAFGroup `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFRule represents a WAF rule. +type WAFRule struct { + ID string `json:"id"` + Description string `json:"description"` + Priority string `json:"priority"` + PackageID string `json:"package_id"` + Group struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"group"` + Mode string `json:"mode"` + DefaultMode string `json:"default_mode"` + AllowedModes []string `json:"allowed_modes"` +} + +// WAFRulesResponse represents the response from the WAF rules endpoint. +type WAFRulesResponse struct { + Response + Result []WAFRule `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFRuleResponse represents the response from the WAF rule endpoint. +type WAFRuleResponse struct { + Response + Result WAFRule `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFRuleOptions is a subset of WAFRule, for editable options. +type WAFRuleOptions struct { + Mode string `json:"mode"` +} + +// ListWAFPackages returns a slice of the WAF packages for the given zone. +// +// API Reference: https://api.cloudflare.com/#waf-rule-packages-list-firewall-packages +func (api *API) ListWAFPackages(ctx context.Context, zoneID string) ([]WAFPackage, error) { + // Construct a query string + v := url.Values{} + // Request as many WAF packages as possible per page - API max is 100 + v.Set("per_page", "100") + + var packages []WAFPackage + var res []byte + var err error + page := 1 + + // Loop over makeRequest until what we've fetched all records + for { + v.Set("page", strconv.Itoa(page)) + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages?%s", zoneID, v.Encode()) + res, err = api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WAFPackage{}, err + } + + var p WAFPackagesResponse + err = json.Unmarshal(res, &p) + if err != nil { + return []WAFPackage{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !p.Success { + // TODO: Provide an actual error message instead of always returning nil + return []WAFPackage{}, err + } + + packages = append(packages, p.Result...) + if p.ResultInfo.Page >= p.ResultInfo.TotalPages { + break + } + + // Loop around and fetch the next page + page++ + } + + return packages, nil +} + +// WAFPackage returns a WAF package for the given zone. +// +// API Reference: https://api.cloudflare.com/#waf-rule-packages-firewall-package-details +func (api *API) WAFPackage(ctx context.Context, zoneID, packageID string) (WAFPackage, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s", zoneID, packageID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WAFPackage{}, err + } + + var r WAFPackageResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFPackage{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateWAFPackage lets you update the a WAF Package. +// +// API Reference: https://api.cloudflare.com/#waf-rule-packages-edit-firewall-package +func (api *API) UpdateWAFPackage(ctx context.Context, zoneID, packageID string, opts WAFPackageOptions) (WAFPackage, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s", zoneID, packageID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, opts) + if err != nil { + return WAFPackage{}, err + } + + var r WAFPackageResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFPackage{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListWAFGroups returns a slice of the WAF groups for the given WAF package. +// +// API Reference: https://api.cloudflare.com/#waf-rule-groups-list-rule-groups +func (api *API) ListWAFGroups(ctx context.Context, zoneID, packageID string) ([]WAFGroup, error) { + // Construct a query string + v := url.Values{} + // Request as many WAF groups as possible per page - API max is 100 + v.Set("per_page", "100") + + var groups []WAFGroup + var res []byte + var err error + page := 1 + + // Loop over makeRequest until what we've fetched all records + for { + v.Set("page", strconv.Itoa(page)) + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s/groups?%s", zoneID, packageID, v.Encode()) + res, err = api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WAFGroup{}, err + } + + var r WAFGroupsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []WAFGroup{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !r.Success { + // TODO: Provide an actual error message instead of always returning nil + return []WAFGroup{}, err + } + + groups = append(groups, r.Result...) + if r.ResultInfo.Page >= r.ResultInfo.TotalPages { + break + } + + // Loop around and fetch the next page + page++ + } + return groups, nil +} + +// WAFGroup returns a WAF rule group from the given WAF package. +// +// API Reference: https://api.cloudflare.com/#waf-rule-groups-rule-group-details +func (api *API) WAFGroup(ctx context.Context, zoneID, packageID, groupID string) (WAFGroup, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s/groups/%s", zoneID, packageID, groupID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WAFGroup{}, err + } + + var r WAFGroupResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFGroup{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateWAFGroup lets you update the mode of a WAF Group. +// +// API Reference: https://api.cloudflare.com/#waf-rule-groups-edit-rule-group +func (api *API) UpdateWAFGroup(ctx context.Context, zoneID, packageID, groupID, mode string) (WAFGroup, error) { + opts := WAFRuleOptions{Mode: mode} + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s/groups/%s", zoneID, packageID, groupID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, opts) + if err != nil { + return WAFGroup{}, err + } + + var r WAFGroupResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFGroup{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ListWAFRules returns a slice of the WAF rules for the given WAF package. +// +// API Reference: https://api.cloudflare.com/#waf-rules-list-rules +func (api *API) ListWAFRules(ctx context.Context, zoneID, packageID string) ([]WAFRule, error) { + // Construct a query string + v := url.Values{} + // Request as many WAF rules as possible per page - API max is 100 + v.Set("per_page", "100") + + var rules []WAFRule + var res []byte + var err error + page := 1 + + // Loop over makeRequest until what we've fetched all records + for { + v.Set("page", strconv.Itoa(page)) + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s/rules?%s", zoneID, packageID, v.Encode()) + res, err = api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WAFRule{}, err + } + + var r WAFRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []WAFRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !r.Success { + // TODO: Provide an actual error message instead of always returning nil + return []WAFRule{}, err + } + + rules = append(rules, r.Result...) + if r.ResultInfo.Page >= r.ResultInfo.TotalPages { + break + } + + // Loop around and fetch the next page + page++ + } + + return rules, nil +} + +// WAFRule returns a WAF rule from the given WAF package. +// +// API Reference: https://api.cloudflare.com/#waf-rules-rule-details +func (api *API) WAFRule(ctx context.Context, zoneID, packageID, ruleID string) (WAFRule, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s/rules/%s", zoneID, packageID, ruleID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WAFRule{}, err + } + + var r WAFRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateWAFRule lets you update the mode of a WAF Rule. +// +// API Reference: https://api.cloudflare.com/#waf-rules-edit-rule +func (api *API) UpdateWAFRule(ctx context.Context, zoneID, packageID, ruleID, mode string) (WAFRule, error) { + opts := WAFRuleOptions{Mode: mode} + uri := fmt.Sprintf("/zones/%s/firewall/waf/packages/%s/rules/%s", zoneID, packageID, ruleID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, opts) + if err != nil { + return WAFRule{}, err + } + + var r WAFRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFRule{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/waf_overrides.go b/pkg/cloudflare-go/waf_overrides.go new file mode 100644 index 000000000..665cb6168 --- /dev/null +++ b/pkg/cloudflare-go/waf_overrides.go @@ -0,0 +1,138 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// WAFOverridesResponse represents the response form the WAF overrides endpoint. +type WAFOverridesResponse struct { + Response + Result []WAFOverride `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFOverrideResponse represents the response form the WAF override endpoint. +type WAFOverrideResponse struct { + Response + Result WAFOverride `json:"result"` + ResultInfo ResultInfo `json:"result_info"` +} + +// WAFOverride represents a WAF override. +type WAFOverride struct { + ID string `json:"id,omitempty"` + Description string `json:"description"` + URLs []string `json:"urls"` + Priority int `json:"priority"` + Groups map[string]string `json:"groups"` + RewriteAction map[string]string `json:"rewrite_action"` + Rules map[string]string `json:"rules"` + Paused bool `json:"paused"` +} + +// ListWAFOverrides returns a slice of the WAF overrides. +// +// API Reference: https://api.cloudflare.com/#waf-overrides-list-uri-controlled-waf-configurations +func (api *API) ListWAFOverrides(ctx context.Context, zoneID string) ([]WAFOverride, error) { + var overrides []WAFOverride + var res []byte + var err error + + uri := fmt.Sprintf("/zones/%s/firewall/waf/overrides", zoneID) + res, err = api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WAFOverride{}, err + } + + var r WAFOverridesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []WAFOverride{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + if !r.Success { + // TODO: Provide an actual error message instead of always returning nil + return []WAFOverride{}, err + } + + for ri := range r.Result { + overrides = append(overrides, r.Result[ri]) + } + return overrides, nil +} + +// WAFOverride returns a WAF override from the given override ID. +// +// API Reference: https://api.cloudflare.com/#waf-overrides-uri-controlled-waf-configuration-details +func (api *API) WAFOverride(ctx context.Context, zoneID, overrideID string) (WAFOverride, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/overrides/%s", zoneID, overrideID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WAFOverride{}, err + } + + var r WAFOverrideResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFOverride{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// CreateWAFOverride creates a new WAF override. +// +// API reference: https://api.cloudflare.com/#waf-overrides-create-a-uri-controlled-waf-configuration +func (api *API) CreateWAFOverride(ctx context.Context, zoneID string, override WAFOverride) (WAFOverride, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/overrides", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, override) + if err != nil { + return WAFOverride{}, err + } + var r WAFOverrideResponse + if err := json.Unmarshal(res, &r); err != nil { + return WAFOverride{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateWAFOverride updates an existing WAF override. +// +// API reference: https://api.cloudflare.com/#waf-overrides-update-uri-controlled-waf-configuration +func (api *API) UpdateWAFOverride(ctx context.Context, zoneID, overrideID string, override WAFOverride) (WAFOverride, error) { + uri := fmt.Sprintf("/zones/%s/firewall/waf/overrides/%s", zoneID, overrideID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, override) + if err != nil { + return WAFOverride{}, err + } + + var r WAFOverrideResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WAFOverride{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteWAFOverride deletes a WAF override for a zone. +// +// API reference: https://api.cloudflare.com/#waf-overrides-delete-lockdown-rule +func (api *API) DeleteWAFOverride(ctx context.Context, zoneID, overrideID string) error { + uri := fmt.Sprintf("/zones/%s/firewall/waf/overrides/%s", zoneID, overrideID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r WAFOverrideResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} diff --git a/pkg/cloudflare-go/waf_overrides_test.go b/pkg/cloudflare-go/waf_overrides_test.go new file mode 100644 index 000000000..01d886490 --- /dev/null +++ b/pkg/cloudflare-go/waf_overrides_test.go @@ -0,0 +1,238 @@ +package cloudflare + +import ( + context "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWAFOverride(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "a27cece9ec0e4af39ae9c58e3326e2b6", + "paused": false, + "urls": [ + "foo.bar.com" + ], + "priority": 0, + "groups": { + "ea8687e59929c1fd05ba97574ad43f77": "default" + }, + "rules": { + "100015": "disable" + }, + "rewrite_action": { + "default": "simulate" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/firewall/waf/overrides/a27cece9ec0e4af39ae9c58e3326e2b6", handler) + want := WAFOverride{ + ID: "a27cece9ec0e4af39ae9c58e3326e2b6", + Paused: false, + URLs: []string{"foo.bar.com"}, + Priority: 0, + Groups: map[string]string{"ea8687e59929c1fd05ba97574ad43f77": "default"}, + Rules: map[string]string{"100015": "disable"}, + RewriteAction: map[string]string{"default": "simulate"}, + } + + actual, err := client.WAFOverride(context.Background(), "01a7362d577a6c3019a474fd6f485823", "a27cece9ec0e4af39ae9c58e3326e2b6") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListWAFOverrides(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#waf-overrides-list-uri-controlled-waf-configurations + fmt.Fprintf(w, `{ + "result": [ + { + "id": "a27cece9ec0e4af39ae9c58e3326e2b6", + "paused": false, + "urls": [ + "foo.bar.com" + ], + "priority": 0, + "groups": { + "ea8687e59929c1fd05ba97574ad43f77": "default" + }, + "rules": { + "100015": "disable" + }, + "rewrite_action": { + "default": "simulate" + } + } + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 25, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/overrides", handler) + + want := []WAFOverride{ + { + ID: "a27cece9ec0e4af39ae9c58e3326e2b6", + Paused: false, + URLs: []string{"foo.bar.com"}, + Priority: 0, + Groups: map[string]string{"ea8687e59929c1fd05ba97574ad43f77": "default"}, + Rules: map[string]string{"100015": "disable"}, + RewriteAction: map[string]string{"default": "simulate"}, + }, + } + + d, err := client.ListWAFOverrides(context.Background(), testZoneID) + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } +} + +func TestCreateWAFOverride(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "cf5b1db4ac454d7bad98ebec4533db57", + "paused": false, + "urls": [ + "foo.bar.com" + ], + "priority": 0, + "groups": { + "ea8687e59929c1fd05ba97574ad43f77": "default" + }, + "rules": { + "100015": "disable" + }, + "rewrite_action": { + "default": "simulate" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/firewall/waf/overrides", handler) + + want := WAFOverride{ + ID: "cf5b1db4ac454d7bad98ebec4533db57", + URLs: []string{"foo.bar.com"}, + Rules: map[string]string{"100015": "disable"}, + Groups: map[string]string{"ea8687e59929c1fd05ba97574ad43f77": "default"}, + RewriteAction: map[string]string{"default": "simulate"}, + } + + actual, err := client.CreateWAFOverride(context.Background(), "01a7362d577a6c3019a474fd6f485823", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteWAFOverride(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": { + "id": "18a9b91a93364593a8f41bd53bb2c02d" + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/firewall/waf/overrides/18a9b91a93364593a8f41bd53bb2c02d", handler) + + err := client.DeleteWAFOverride(context.Background(), "01a7362d577a6c3019a474fd6f485823", "18a9b91a93364593a8f41bd53bb2c02d") + assert.NoError(t, err) +} + +func TestUpdateWAFOverride(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "result": { + "id": "e160a4fca2b346a7a418f49da049c566", + "paused": false, + "urls": [ + "foo.bar.com" + ], + "priority": 0, + "groups": { + "ea8687e59929c1fd05ba97574ad43f77": "block" + }, + "rules": { + "100015": "disable" + }, + "rewrite_action": { + "default": "block" + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/zones/01a7362d577a6c3019a474fd6f485823/firewall/waf/overrides/e160a4fca2b346a7a418f49da049c566", handler) + + want := WAFOverride{ + ID: "e160a4fca2b346a7a418f49da049c566", + URLs: []string{"foo.bar.com"}, + Rules: map[string]string{"100015": "disable"}, + Groups: map[string]string{"ea8687e59929c1fd05ba97574ad43f77": "block"}, + RewriteAction: map[string]string{"default": "block"}, + } + + actual, err := client.UpdateWAFOverride(context.Background(), "01a7362d577a6c3019a474fd6f485823", "e160a4fca2b346a7a418f49da049c566", want) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/waf_test.go b/pkg/cloudflare-go/waf_test.go new file mode 100644 index 000000000..31e19bef0 --- /dev/null +++ b/pkg/cloudflare-go/waf_test.go @@ -0,0 +1,794 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestListWAFPackages(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#waf-rule-packages-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "a25a9a7e9c00afc1fb2e0245519d725b", + "name": "WordPress rules", + "description": "Common WordPress exploit protections", + "detection_mode": "traditional", + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "status": "active" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages", handler) + + want := []WAFPackage{ + { + ID: "a25a9a7e9c00afc1fb2e0245519d725b", + Name: "WordPress rules", + Description: "Common WordPress exploit protections", + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + DetectionMode: "traditional", + Sensitivity: "", + ActionMode: "", + }, + } + + d, err := client.ListWAFPackages(context.Background(), testZoneID) + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFRules(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestListWAFPackagesMultiplePages(t *testing.T) { + setup() + defer teardown() + + page := 1 + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + + reqURI, err := url.ParseRequestURI(r.RequestURI) + assert.NoError(t, err) + + query, err := url.ParseQuery(reqURI.RawQuery) + assert.NoError(t, err) + + assert.Equal(t, query, url.Values{"page": []string{strconv.Itoa(page)}, "per_page": []string{"100"}}) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "fake_id_number_%[1]d", + "name": "Fake rule name %[1]d", + "description": "Fake rule description %[1]d", + "detection_mode": "traditional", + "zone_id": "%[2]s", + "status": "active" + } + ], + "result_info": { + "page": %[1]d, + "per_page": 1, + "total_pages": 2, + "count": 1, + "total_count": 2 + } + }`, page, testZoneID) + + page++ + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages", handler) + + want := []WAFPackage{ + { + ID: "fake_id_number_1", + Name: "Fake rule name 1", + Description: "Fake rule description 1", + ZoneID: testZoneID, + DetectionMode: "traditional", + Sensitivity: "", + ActionMode: "", + }, + { + ID: "fake_id_number_2", + Name: "Fake rule name 2", + Description: "Fake rule description 2", + ZoneID: testZoneID, + DetectionMode: "traditional", + Sensitivity: "", + ActionMode: "", + }, + } + + d, err := client.ListWAFPackages(context.Background(), testZoneID) + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFRules(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestWAFPackage(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#waf-rule-packages-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": + { + "id": "a25a9a7e9c00afc1fb2e0245519d725b", + "name": "WordPress rules", + "description": "Common WordPress exploit protections", + "detection_mode": "traditional", + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "status": "active" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b", handler) + + want := WAFPackage{ + ID: "a25a9a7e9c00afc1fb2e0245519d725b", + Name: "WordPress rules", + Description: "Common WordPress exploit protections", + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + DetectionMode: "traditional", + Sensitivity: "", + ActionMode: "", + } + + d, err := client.WAFPackage(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.WAFPackage(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestUpdateWAFPackage(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + assert.Equal(t, `{"sensitivity":"high","action_mode":"challenge"}`, string(body), "Expected body '{\"sensitivity\":\"high\",\"action_mode\":\"challenge\"}', got %s", string(body)) + + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#waf-rules-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "a25a9a7e9c00afc1fb2e0245519d725b", + "name": "OWASP ModSecurity Core Rule Set", + "description": "Covers OWASP Top 10 vulnerabilities, and more.", + "detection_mode": "anomaly", + "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", + "status": "active", + "sensitivity": "high", + "action_mode": "challenge" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b", handler) + + want := WAFPackage{ + ID: "a25a9a7e9c00afc1fb2e0245519d725b", + Name: "OWASP ModSecurity Core Rule Set", + Description: "Covers OWASP Top 10 vulnerabilities, and more.", + ZoneID: "023e105f4ecef8ad9ca31a8372d0c353", + DetectionMode: "anomaly", + Sensitivity: "high", + ActionMode: "challenge", + } + + d, err := client.UpdateWAFPackage(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b", WAFPackageOptions{Sensitivity: "high", ActionMode: "challenge"}) + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } +} + +func TestListWAFGroups(t *testing.T) { + setup() + defer teardown() + + 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") + + // JSON data from: https://api.cloudflare.com/#waf-rule-groups-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "de677e5818985db1285d0e80225f06e5", + "name": "Project Honey Pot", + "description": "Group designed to protect against IP addresses that are a threat and typically used to launch DDoS attacks", + "rules_count": 10, + "modified_rules_count": 2, + "package_id": "a25a9a7e9c00afc1fb2e0245519d725b", + "mode": "on", + "allowed_modes": [ + "on", + "off" + ] + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b/groups", handler) + + want := []WAFGroup{ + { + ID: "de677e5818985db1285d0e80225f06e5", + Name: "Project Honey Pot", + Description: "Group designed to protect against IP addresses that are a threat and typically used to launch DDoS attacks", + RulesCount: 10, + ModifiedRulesCount: 2, + PackageID: "a25a9a7e9c00afc1fb2e0245519d725b", + Mode: "on", + AllowedModes: []string{"on", "off"}, + }, + } + + d, err := client.ListWAFGroups(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFGroups(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestListWAFGroupsMultiplePages(t *testing.T) { + setup() + defer teardown() + packageID := "efgh456" + + page := 1 + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + + reqURI, err := url.ParseRequestURI(r.RequestURI) + assert.NoError(t, err) + + query, err := url.ParseQuery(reqURI.RawQuery) + assert.NoError(t, err) + + assert.Equal(t, query, url.Values{"page": []string{strconv.Itoa(page)}, "per_page": []string{"100"}}) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "fake_group_id_%[1]d", + "name": "Fake Group Name %[1]d", + "description": "Fake Group Description %[1]d", + "rules_count": 1%[1]d, + "modified_rules_count": %[1]d, + "package_id": "%[2]s", + "mode": "on", + "allowed_modes": [ + "on", + "off" + ] + } + ], + "result_info": { + "page": %[1]d, + "per_page": 1, + "total_pages": 2, + "count": 1, + "total_count": 2 + } + }`, page, packageID) + + page++ + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/"+packageID+"/groups", handler) + + want := []WAFGroup{ + { + ID: "fake_group_id_1", + Name: "Fake Group Name 1", + Description: "Fake Group Description 1", + RulesCount: 11, + ModifiedRulesCount: 1, + PackageID: packageID, + Mode: "on", + AllowedModes: []string{"on", "off"}, + }, + { + ID: "fake_group_id_2", + Name: "Fake Group Name 2", + Description: "Fake Group Description 2", + RulesCount: 12, + ModifiedRulesCount: 2, + PackageID: packageID, + Mode: "on", + AllowedModes: []string{"on", "off"}, + }, + } + + d, err := client.ListWAFGroups(context.Background(), testZoneID, packageID) + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFGroups(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestWAFGroup(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#waf-rule-groups-rule-group-details + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "de677e5818985db1285d0e80225f06e5", + "name": "Project Honey Pot", + "description": "Group designed to protect against IP addresses that are a threat and typically used to launch DDoS attacks", + "rules_count": 10, + "modified_rules_count": 2, + "package_id": "a25a9a7e9c00afc1fb2e0245519d725b", + "mode": "on", + "allowed_modes": [ + "on", + "off" + ] + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b/groups/de677e5818985db1285d0e80225f06e5", handler) + + want := WAFGroup{ + ID: "de677e5818985db1285d0e80225f06e5", + Name: "Project Honey Pot", + Description: "Group designed to protect against IP addresses that are a threat and typically used to launch DDoS attacks", + RulesCount: 10, + ModifiedRulesCount: 2, + PackageID: "a25a9a7e9c00afc1fb2e0245519d725b", + Mode: "on", + AllowedModes: []string{"on", "off"}, + } + + d, err := client.WAFGroup(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b", "de677e5818985db1285d0e80225f06e5") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.WAFGroup(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b", "123") + assert.Error(t, err) +} + +func TestUpdateWAFGroup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + assert.Equal(t, `{"mode":"on"}`, string(body), "Expected body '{\"mode\":\"on\"}', got %s", string(body)) + + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#waf-rule-groups-edit-rule-group + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "de677e5818985db1285d0e80225f06e5", + "name": "Project Honey Pot", + "description": "Group designed to protect against IP addresses that are a threat and typically used to launch DDoS attacks", + "rules_count": 10, + "modified_rules_count": 2, + "package_id": "a25a9a7e9c00afc1fb2e0245519d725b", + "mode": "on", + "allowed_modes": [ + "on", + "off" + ] + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b/groups/de677e5818985db1285d0e80225f06e5", handler) + + want := WAFGroup{ + ID: "de677e5818985db1285d0e80225f06e5", + Name: "Project Honey Pot", + Description: "Group designed to protect against IP addresses that are a threat and typically used to launch DDoS attacks", + RulesCount: 10, + ModifiedRulesCount: 2, + PackageID: "a25a9a7e9c00afc1fb2e0245519d725b", + Mode: "on", + AllowedModes: []string{"on", "off"}, + } + + d, err := client.UpdateWAFGroup(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b", "de677e5818985db1285d0e80225f06e5", "on") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } +} + +func TestListWAFRules(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#waf-rules-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "f939de3be84e66e757adcdcb87908023", + "description": "SQL injection prevention for SELECT statements", + "priority": "5", + "group": { + "id": "de677e5818985db1285d0e80225f06e5", + "name": "Project Honey Pot" + }, + "package_id": "a25a9a7e9c00afc1fb2e0245519d725b", + "allowed_modes": [ + "on", + "off" + ], + "mode": "on" + } + ], + "result_info": { + "page": 1, + "per_page": 20, + "count": 1, + "total_count": 1 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b/rules", handler) + + want := []WAFRule{ + { + ID: "f939de3be84e66e757adcdcb87908023", + Description: "SQL injection prevention for SELECT statements", + Priority: "5", + PackageID: "a25a9a7e9c00afc1fb2e0245519d725b", + Group: struct { + ID string `json:"id"` + Name string `json:"name"` + }{ + ID: "de677e5818985db1285d0e80225f06e5", + Name: "Project Honey Pot", + }, + Mode: "on", + DefaultMode: "", + AllowedModes: []string{"on", "off"}, + }, + } + + d, err := client.ListWAFRules(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFRules(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestListWAFRulesMultiplePages(t *testing.T) { + setup() + defer teardown() + packageID := "efgh456" + + page := 1 + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + + reqURI, err := url.ParseRequestURI(r.RequestURI) + assert.NoError(t, err) + + query, err := url.ParseQuery(reqURI.RawQuery) + assert.NoError(t, err) + + assert.Equal(t, query, url.Values{"page": []string{strconv.Itoa(page)}, "per_page": []string{"100"}}) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "fake_rule_id_%[1]d", + "description": "Fake Rule Description %[1]d", + "priority": "%[1]d", + "group": { + "id": "fake_group_id_%[1]d", + "name": "Fake Group Name %[1]d" + }, + "package_id": "%[2]s", + "allowed_modes": [ + "on", + "off" + ], + "mode": "on" + } + ], + "result_info": { + "page": %[1]d, + "per_page": 1, + "total_pages": 2, + "count": 1, + "total_count": 2 + } + }`, page, packageID) + + page++ + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/"+packageID+"/rules", handler) + + want := []WAFRule{ + { + ID: "fake_rule_id_1", + Description: "Fake Rule Description 1", + Priority: "1", + PackageID: packageID, + Group: struct { + ID string `json:"id"` + Name string `json:"name"` + }{ + ID: "fake_group_id_1", + Name: "Fake Group Name 1", + }, + Mode: "on", + DefaultMode: "", + AllowedModes: []string{"on", "off"}, + }, + { + ID: "fake_rule_id_2", + Description: "Fake Rule Description 2", + Priority: "2", + PackageID: packageID, + Group: struct { + ID string `json:"id"` + Name string `json:"name"` + }{ + ID: "fake_group_id_2", + Name: "Fake Group Name 2", + }, + Mode: "on", + DefaultMode: "", + AllowedModes: []string{"on", "off"}, + }, + } + + d, err := client.ListWAFRules(context.Background(), testZoneID, packageID) + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFRules(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestWAFRule(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#waf-rules-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f939de3be84e66e757adcdcb87908023", + "description": "SQL injection prevention for SELECT statements", + "priority": "5", + "group": { + "id": "de677e5818985db1285d0e80225f06e5", + "name": "Project Honey Pot" + }, + "package_id": "a25a9a7e9c00afc1fb2e0245519d725b", + "allowed_modes": [ + "on", + "off" + ], + "mode": "on" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b/rules/f939de3be84e66e757adcdcb87908023", handler) + + want := WAFRule{ + ID: "f939de3be84e66e757adcdcb87908023", + Description: "SQL injection prevention for SELECT statements", + Priority: "5", + PackageID: "a25a9a7e9c00afc1fb2e0245519d725b", + Group: struct { + ID string `json:"id"` + Name string `json:"name"` + }{ + ID: "de677e5818985db1285d0e80225f06e5", + Name: "Project Honey Pot", + }, + Mode: "on", + DefaultMode: "", + AllowedModes: []string{"on", "off"}, + } + + d, err := client.WAFRule(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b", "f939de3be84e66e757adcdcb87908023") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFRules(context.Background(), testZoneID, "123") + assert.Error(t, err) +} + +func TestUpdateWAFRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + body, err := io.ReadAll(r.Body) + if err != nil { + panic(err) + } + defer r.Body.Close() + + assert.Equal(t, `{"mode":"on"}`, string(body), "Expected method '{\"mode\":\"on\"}', got %s", string(body)) + + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#waf-rules-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "f939de3be84e66e757adcdcb87908023", + "description": "SQL injection prevention for SELECT statements", + "priority": "5", + "group": { + "id": "de677e5818985db1285d0e80225f06e5", + "name": "Project Honey Pot" + }, + "package_id": "a25a9a7e9c00afc1fb2e0245519d725b", + "allowed_modes": [ + "on", + "off" + ], + "mode": "on" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/firewall/waf/packages/a25a9a7e9c00afc1fb2e0245519d725b/rules/f939de3be84e66e757adcdcb87908023", handler) + + want := WAFRule{ + ID: "f939de3be84e66e757adcdcb87908023", + Description: "SQL injection prevention for SELECT statements", + Priority: "5", + PackageID: "a25a9a7e9c00afc1fb2e0245519d725b", + Group: struct { + ID string `json:"id"` + Name string `json:"name"` + }{ + ID: "de677e5818985db1285d0e80225f06e5", + Name: "Project Honey Pot", + }, + Mode: "on", + DefaultMode: "", + AllowedModes: []string{"on", "off"}, + } + + d, err := client.UpdateWAFRule(context.Background(), testZoneID, "a25a9a7e9c00afc1fb2e0245519d725b", "f939de3be84e66e757adcdcb87908023", "on") + + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ListWAFRules(context.Background(), testZoneID, "123") + assert.Error(t, err) +} diff --git a/pkg/cloudflare-go/waiting_room.go b/pkg/cloudflare-go/waiting_room.go new file mode 100644 index 000000000..7bd98d9a3 --- /dev/null +++ b/pkg/cloudflare-go/waiting_room.go @@ -0,0 +1,629 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingWaitingRoomID = errors.New("missing required waiting room ID") + ErrMissingWaitingRoomRuleID = errors.New("missing required waiting room rule ID") +) + +// WaitingRoom describes a WaitingRoom object. +type WaitingRoom struct { + CreatedOn time.Time `json:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + Path string `json:"path"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + QueueingMethod string `json:"queueing_method,omitempty"` + CustomPageHTML string `json:"custom_page_html,omitempty"` + DefaultTemplateLanguage string `json:"default_template_language,omitempty"` + Host string `json:"host"` + ID string `json:"id,omitempty"` + NewUsersPerMinute int `json:"new_users_per_minute"` + TotalActiveUsers int `json:"total_active_users"` + SessionDuration int `json:"session_duration"` + QueueAll bool `json:"queue_all"` + DisableSessionRenewal bool `json:"disable_session_renewal"` + Suspended bool `json:"suspended"` + JsonResponseEnabled bool `json:"json_response_enabled"` + NextEventPrequeueStartTime *time.Time `json:"next_event_prequeue_start_time,omitempty"` + NextEventStartTime *time.Time `json:"next_event_start_time,omitempty"` + CookieSuffix string `json:"cookie_suffix"` + AdditionalRoutes []*WaitingRoomRoute `json:"additional_routes,omitempty"` + QueueingStatusCode int `json:"queueing_status_code"` +} + +// WaitingRoomStatus describes the status of a waiting room. +type WaitingRoomStatus struct { + Status string `json:"status"` + EventID string `json:"event_id"` + EstimatedQueuedUsers int `json:"estimated_queued_users"` + EstimatedTotalActiveUsers int `json:"estimated_total_active_users"` + MaxEstimatedTimeMinutes int `json:"max_estimated_time_minutes"` +} + +// WaitingRoomEvent describes a WaitingRoomEvent object. +type WaitingRoomEvent struct { + EventEndTime time.Time `json:"event_end_time"` + CreatedOn time.Time `json:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + PrequeueStartTime *time.Time `json:"prequeue_start_time,omitempty"` + EventStartTime time.Time `json:"event_start_time"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + QueueingMethod string `json:"queueing_method,omitempty"` + ID string `json:"id,omitempty"` + CustomPageHTML string `json:"custom_page_html,omitempty"` + NewUsersPerMinute int `json:"new_users_per_minute,omitempty"` + TotalActiveUsers int `json:"total_active_users,omitempty"` + SessionDuration int `json:"session_duration,omitempty"` + DisableSessionRenewal *bool `json:"disable_session_renewal,omitempty"` + Suspended bool `json:"suspended"` + ShuffleAtEventStart bool `json:"shuffle_at_event_start"` +} + +type WaitingRoomRule struct { + ID string `json:"id,omitempty"` + Version string `json:"version,omitempty"` + Action string `json:"action"` + Expression string `json:"expression"` + Description string `json:"description"` + LastUpdated *time.Time `json:"last_updated,omitempty"` + Enabled *bool `json:"enabled"` +} + +// WaitingRoomSettings describes zone-level waiting room settings. +type WaitingRoomSettings struct { + // Whether to allow verified search engine crawlers to bypass all waiting rooms on this zone + SearchEngineCrawlerBypass bool `json:"search_engine_crawler_bypass"` +} + +// WaitingRoomPagePreviewURL describes a WaitingRoomPagePreviewURL object. +type WaitingRoomPagePreviewURL struct { + PreviewURL string `json:"preview_url"` +} + +// WaitingRoomPagePreviewCustomHTML describes a WaitingRoomPagePreviewCustomHTML object. +type WaitingRoomPagePreviewCustomHTML struct { + CustomHTML string `json:"custom_html"` +} + +// WaitingRoomRoute describes a WaitingRoomRoute object. +type WaitingRoomRoute struct { + Host string `json:"host"` + Path string `json:"path"` +} + +// WaitingRoomDetailResponse is the API response, containing a single WaitingRoom. +type WaitingRoomDetailResponse struct { + Response + Result WaitingRoom `json:"result"` +} + +// WaitingRoomsResponse is the API response, containing an array of WaitingRooms. +type WaitingRoomsResponse struct { + Response + Result []WaitingRoom `json:"result"` +} + +// WaitingRoomSettingsResponse is the API response, containing zone-level Waiting Room settings. +type WaitingRoomSettingsResponse struct { + Response + Result WaitingRoomSettings `json:"result"` +} + +// WaitingRoomStatusResponse is the API response, containing the status of a waiting room. +type WaitingRoomStatusResponse struct { + Response + Result WaitingRoomStatus `json:"result"` +} + +// WaitingRoomPagePreviewResponse is the API response, containing the URL to a custom waiting room preview. +type WaitingRoomPagePreviewResponse struct { + Response + Result WaitingRoomPagePreviewURL `json:"result"` +} + +// WaitingRoomEventDetailResponse is the API response, containing a single WaitingRoomEvent. +type WaitingRoomEventDetailResponse struct { + Response + Result WaitingRoomEvent `json:"result"` +} + +// WaitingRoomEventsResponse is the API response, containing an array of WaitingRoomEvents. +type WaitingRoomEventsResponse struct { + Response + Result []WaitingRoomEvent `json:"result"` +} + +// WaitingRoomRulesResponse is the API response, containing an array of WaitingRoomRule. +type WaitingRoomRulesResponse struct { + Response + Result []WaitingRoomRule `json:"result"` +} + +// CreateWaitingRoom creates a new Waiting Room for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-create-waiting-room +func (api *API) CreateWaitingRoom(ctx context.Context, zoneID string, waitingRoom WaitingRoom) (*WaitingRoom, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, waitingRoom) + if err != nil { + return nil, err + } + var r WaitingRoomDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +// ListWaitingRooms returns all Waiting Room for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-list-waiting-rooms +func (api *API) ListWaitingRooms(ctx context.Context, zoneID string) ([]WaitingRoom, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WaitingRoom{}, err + } + var r WaitingRoomsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []WaitingRoom{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// WaitingRoom fetches detail about one Waiting room for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-waiting-room-details +func (api *API) WaitingRoom(ctx context.Context, zoneID, waitingRoomID string) (WaitingRoom, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s", zoneID, waitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WaitingRoom{}, err + } + var r WaitingRoomDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoom{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ChangeWaitingRoom lets you change individual settings for a Waiting room. This is +// in contrast to UpdateWaitingRoom which replaces the entire Waiting room. +// +// API reference: https://api.cloudflare.com/#waiting-room-update-waiting-room +func (api *API) ChangeWaitingRoom(ctx context.Context, zoneID, waitingRoomID string, waitingRoom WaitingRoom) (WaitingRoom, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s", zoneID, waitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, waitingRoom) + if err != nil { + return WaitingRoom{}, err + } + var r WaitingRoomDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoom{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateWaitingRoom lets you replace a Waiting Room. This is in contrast to +// ChangeWaitingRoom which lets you change individual settings. +// +// API reference: https://api.cloudflare.com/#waiting-room-update-waiting-room +func (api *API) UpdateWaitingRoom(ctx context.Context, zoneID string, waitingRoom WaitingRoom) (WaitingRoom, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s", zoneID, waitingRoom.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, waitingRoom) + if err != nil { + return WaitingRoom{}, err + } + var r WaitingRoomDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoom{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteWaitingRoom deletes a Waiting Room for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-delete-waiting-room +func (api *API) DeleteWaitingRoom(ctx context.Context, zoneID, waitingRoomID string) error { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s", zoneID, waitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r WaitingRoomDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +// WaitingRoomStatus returns the status of one Waiting Room for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-get-waiting-room-status +func (api *API) WaitingRoomStatus(ctx context.Context, zoneID, waitingRoomID string) (WaitingRoomStatus, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/status", zoneID, waitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WaitingRoomStatus{}, err + } + var r WaitingRoomStatusResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomStatus{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// WaitingRoomPagePreview uploads a custom waiting room page for preview and +// returns a preview URL. +// +// API reference: https://api.cloudflare.com/#waiting-room-create-a-custom-waiting-room-page-preview +func (api *API) WaitingRoomPagePreview(ctx context.Context, zoneID, customHTML string) (WaitingRoomPagePreviewURL, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/preview", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, WaitingRoomPagePreviewCustomHTML{CustomHTML: customHTML}) + + if err != nil { + return WaitingRoomPagePreviewURL{}, err + } + var r WaitingRoomPagePreviewResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomPagePreviewURL{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// CreateWaitingRoomEvent creates a new event for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-create-event +func (api *API) CreateWaitingRoomEvent(ctx context.Context, zoneID string, waitingRoomID string, waitingRoomEvent WaitingRoomEvent) (*WaitingRoomEvent, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events", zoneID, waitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, waitingRoomEvent) + if err != nil { + return nil, err + } + var r WaitingRoomEventDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +// ListWaitingRoomEvents returns all Waiting Room Events for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-list-events +func (api *API) ListWaitingRoomEvents(ctx context.Context, zoneID string, waitingRoomID string) ([]WaitingRoomEvent, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events", zoneID, waitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WaitingRoomEvent{}, err + } + var r WaitingRoomEventsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []WaitingRoomEvent{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// WaitingRoomEvent fetches detail about one Waiting Room Event for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-event-details +func (api *API) WaitingRoomEvent(ctx context.Context, zoneID string, waitingRoomID string, eventID string) (WaitingRoomEvent, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events/%s", zoneID, waitingRoomID, eventID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WaitingRoomEvent{}, err + } + var r WaitingRoomEventDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomEvent{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// WaitingRoomEventPreview returns an event's configuration as if it was active. +// Inherited fields from the waiting room will be displayed with their current values. +// +// API reference: https://api.cloudflare.com/#waiting-room-preview-active-event-details +func (api *API) WaitingRoomEventPreview(ctx context.Context, zoneID string, waitingRoomID string, eventID string) (WaitingRoomEvent, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events/%s/details", zoneID, waitingRoomID, eventID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WaitingRoomEvent{}, err + } + var r WaitingRoomEventDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomEvent{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ChangeWaitingRoomEvent lets you change individual settings for a Waiting Room Event. This is +// in contrast to UpdateWaitingRoomEvent which replaces the entire Waiting Room Event. +// +// API reference: https://api.cloudflare.com/#waiting-room-patch-event +func (api *API) ChangeWaitingRoomEvent(ctx context.Context, zoneID, waitingRoomID string, waitingRoomEvent WaitingRoomEvent) (WaitingRoomEvent, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events/%s", zoneID, waitingRoomID, waitingRoomEvent.ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, waitingRoomEvent) + if err != nil { + return WaitingRoomEvent{}, err + } + var r WaitingRoomEventDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomEvent{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateWaitingRoomEvent lets you replace a Waiting Room Event. This is in contrast to +// ChangeWaitingRoomEvent which lets you change individual settings. +// +// API reference: https://api.cloudflare.com/#waiting-room-update-event +func (api *API) UpdateWaitingRoomEvent(ctx context.Context, zoneID string, waitingRoomID string, waitingRoomEvent WaitingRoomEvent) (WaitingRoomEvent, error) { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events/%s", zoneID, waitingRoomID, waitingRoomEvent.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, waitingRoomEvent) + if err != nil { + return WaitingRoomEvent{}, err + } + var r WaitingRoomEventDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomEvent{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// DeleteWaitingRoomEvent deletes an event for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-delete-event +func (api *API) DeleteWaitingRoomEvent(ctx context.Context, zoneID string, waitingRoomID string, eventID string) error { + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/events/%s", zoneID, waitingRoomID, eventID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + var r WaitingRoomEventDetailResponse + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return nil +} + +type ListWaitingRoomRuleParams struct { + WaitingRoomID string +} + +type CreateWaitingRoomRuleParams struct { + WaitingRoomID string + Rule WaitingRoomRule +} + +type ReplaceWaitingRoomRuleParams struct { + WaitingRoomID string + Rules []WaitingRoomRule +} + +type UpdateWaitingRoomRuleParams struct { + WaitingRoomID string + Rule WaitingRoomRule +} + +type DeleteWaitingRoomRuleParams struct { + WaitingRoomID string + RuleID string +} + +// ListWaitingRoomRules lists all rules for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-list-waiting-room-rules +func (api *API) ListWaitingRoomRules(ctx context.Context, rc *ResourceContainer, params ListWaitingRoomRuleParams) ([]WaitingRoomRule, error) { + if params.WaitingRoomID == "" { + return nil, ErrMissingWaitingRoomID + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/rules", rc.Identifier, params.WaitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + var r WaitingRoomRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// CreateWaitingRoomRule creates a new rule for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-create-waiting-room-rule +func (api *API) CreateWaitingRoomRule(ctx context.Context, rc *ResourceContainer, params CreateWaitingRoomRuleParams) ([]WaitingRoomRule, error) { + if params.WaitingRoomID == "" { + return nil, ErrMissingWaitingRoomID + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/rules", rc.Identifier, params.WaitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Rule) + if err != nil { + return nil, err + } + + var r WaitingRoomRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// ReplaceWaitingRoomRules replaces all rules for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-replace-waiting-room-rules +func (api *API) ReplaceWaitingRoomRules(ctx context.Context, rc *ResourceContainer, params ReplaceWaitingRoomRuleParams) ([]WaitingRoomRule, error) { + if params.WaitingRoomID == "" { + return nil, ErrMissingWaitingRoomID + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/rules", rc.Identifier, params.WaitingRoomID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Rules) + if err != nil { + return nil, err + } + + var r WaitingRoomRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateWaitingRoomRule updates a rule for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-patch-waiting-room-rule +func (api *API) UpdateWaitingRoomRule(ctx context.Context, rc *ResourceContainer, params UpdateWaitingRoomRuleParams) ([]WaitingRoomRule, error) { + if params.WaitingRoomID == "" { + return nil, ErrMissingWaitingRoomID + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/rules/%s", rc.Identifier, params.WaitingRoomID, params.Rule.ID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params.Rule) + if err != nil { + return nil, err + } + + var r WaitingRoomRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DeleteWaitingRoomRule deletes a rule for a Waiting Room. +// +// API reference: https://api.cloudflare.com/#waiting-room-delete-waiting-room-rule +func (api *API) DeleteWaitingRoomRule(ctx context.Context, rc *ResourceContainer, params DeleteWaitingRoomRuleParams) ([]WaitingRoomRule, error) { + if params.WaitingRoomID == "" { + return nil, ErrMissingWaitingRoomID + } + + if params.RuleID == "" { + return nil, ErrMissingWaitingRoomRuleID + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/%s/rules/%s", rc.Identifier, params.WaitingRoomID, params.RuleID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + + var r WaitingRoomRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// GetWaitingRoomSettings fetches the Waiting Room zone-level settings for a zone. +// +// API reference: https://api.cloudflare.com/#waiting-room-get-zone-settings +func (api *API) GetWaitingRoomSettings(ctx context.Context, rc *ResourceContainer) (WaitingRoomSettings, error) { + if rc.Level != ZoneRouteLevel { + return WaitingRoomSettings{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/settings", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WaitingRoomSettings{}, err + } + var r WaitingRoomSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +type PatchWaitingRoomSettingsParams struct { + SearchEngineCrawlerBypass *bool `json:"search_engine_crawler_bypass,omitempty"` +} + +// PatchWaitingRoomSettings lets you change individual zone-level Waiting Room settings. This is +// in contrast to UpdateWaitingRoomSettings which replaces all settings. +// +// API reference: https://api.cloudflare.com/#waiting-room-patch-zone-settings +func (api *API) PatchWaitingRoomSettings(ctx context.Context, rc *ResourceContainer, params PatchWaitingRoomSettingsParams) (WaitingRoomSettings, error) { + if rc.Level != ZoneRouteLevel { + return WaitingRoomSettings{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/settings", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return WaitingRoomSettings{}, err + } + var r WaitingRoomSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +type UpdateWaitingRoomSettingsParams struct { + SearchEngineCrawlerBypass *bool `json:"search_engine_crawler_bypass,omitempty"` +} + +// UpdateWaitingRoomSettings lets you replace all zone-level Waiting Room settings. This is in contrast to +// PatchWaitingRoomSettings which lets you change individual settings. +// +// API reference: https://api.cloudflare.com/#waiting-room-update-zone-settings +func (api *API) UpdateWaitingRoomSettings(ctx context.Context, rc *ResourceContainer, params UpdateWaitingRoomSettingsParams) (WaitingRoomSettings, error) { + if rc.Level != ZoneRouteLevel { + return WaitingRoomSettings{}, fmt.Errorf(errInvalidResourceContainerAccess, rc.Level) + } + + uri := fmt.Sprintf("/zones/%s/waiting_rooms/settings", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return WaitingRoomSettings{}, err + } + var r WaitingRoomSettingsResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WaitingRoomSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/waiting_room_test.go b/pkg/cloudflare-go/waiting_room_test.go new file mode 100644 index 000000000..4d5ccf00e --- /dev/null +++ b/pkg/cloudflare-go/waiting_room_test.go @@ -0,0 +1,886 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "time" + + "github.com/stretchr/testify/assert" +) + +var waitingRoomID = "699d98642c564d2e855e9661899b7252" +var waitingRoomEventID = "25756b2dfe6e378a06b033b670413757" +var waitingRoomRuleID = "25756b2dfe6e378a06b033b670413757" +var testTimestampWaitingRoom = time.Now().UTC() +var testTimestampWaitingRoomEvent = time.Now().UTC() +var testTimestampWaitingRoomEventPrequeue = time.Now().UTC() +var testTimestampWaitingRoomEventStart = testTimestampWaitingRoomEventPrequeue.Add(5 * time.Minute) +var testTimestampWaitingRoomEventEnd = testTimestampWaitingRoomEventStart.Add(1 * time.Minute) +var waitingRoomJSON = fmt.Sprintf(` + { + "id": "%s", + "created_on": "%s", + "modified_on": "%s", + "name": "production_webinar", + "description": "Production - DO NOT MODIFY", + "queueing_method": "random", + "suspended": false, + "host": "shop.example.com", + "path": "/shop/checkout", + "queue_all": true, + "new_users_per_minute": 600, + "total_active_users": 1000, + "session_duration": 10, + "disable_session_renewal": false, + "json_response_enabled": true, + "custom_page_html": "{{#waitTimeKnown}} {{waitTime}} mins {{/waitTimeKnown}} {{^waitTimeKnown}} Queue all enabled {{/waitTimeKnown}}", + "default_template_language": "en-US", + "next_event_prequeue_start_time": null, + "next_event_start_time": "%s", + "cookie_suffix": "example_shop", + "additional_routes": [{"host": "shop2.example.com", "path": "/shop/checkout"}], + "queueing_status_code": 200 + } + `, waitingRoomID, testTimestampWaitingRoom.Format(time.RFC3339Nano), testTimestampWaitingRoom.Format(time.RFC3339Nano), + testTimestampWaitingRoomEventStart.Format(time.RFC3339Nano)) + +var waitingRoomEventJSON = fmt.Sprintf(` + { + "id": "%s", + "created_on": "%s", + "modified_on": "%s", + "name": "production_webinar_event", + "description": "Production event - DO NOT MODIFY", + "suspended": false, + "prequeue_start_time": "%s", + "event_start_time": "%s", + "event_end_time": "%s", + "shuffle_at_event_start": false, + "new_users_per_minute": 2000, + "total_active_users": 2500, + "session_duration": null, + "disable_session_renewal": null, + "queueing_method": "random", + "custom_page_html": "{{#waitTimeKnown}} {{waitTime}} mins {{/waitTimeKnown}} {{^waitTimeKnown}} Event is prequeueing / Queue all enabled {{/waitTimeKnown}}" + } + `, waitingRoomEventID, testTimestampWaitingRoomEvent.Format(time.RFC3339Nano), + testTimestampWaitingRoomEvent.Format(time.RFC3339Nano), + testTimestampWaitingRoomEventPrequeue.Format(time.RFC3339Nano), + testTimestampWaitingRoomEventStart.Format(time.RFC3339Nano), + testTimestampWaitingRoomEventEnd.Format(time.RFC3339Nano)) + +var waitingRoomStatusJSON = fmt.Sprintf(` + { + "status": "queueing", + "event_id": "%s", + "estimated_queued_users": 10, + "estimated_total_active_users": 9, + "max_estimated_time_minutes": 5 + } + `, waitingRoomEventID) + +var waitingRoomRuleJSON = fmt.Sprintf(` +{ + "id": "%s", + "version": "1", + "description": "bypass ip", + "action": "bypass_waiting_room", + "expression": "ip.src in {1.2.3.4 5.6.7.8}", + "enabled": true, + "last_updated": "%s" +} +`, waitingRoomRuleID, testTimestampWaitingRoom.Format(time.RFC3339Nano)) + +var waitingRoomPagePreviewJSON = ` + { + "preview_url": "http://waitingrooms.dev/preview/35af8c12-6d68-4608-babb-b53435a5ddfb" + } + ` + +var waitingRoomSettingsJSON = ` + { + "search_engine_crawler_bypass": true + } + ` + +var waitingRoom = WaitingRoom{ + ID: waitingRoomID, + CreatedOn: testTimestampWaitingRoom, + ModifiedOn: testTimestampWaitingRoom, + Name: "production_webinar", + Description: "Production - DO NOT MODIFY", + QueueingMethod: "random", + Suspended: false, + Host: "shop.example.com", + Path: "/shop/checkout", + QueueAll: true, + NewUsersPerMinute: 600, + TotalActiveUsers: 1000, + SessionDuration: 10, + DisableSessionRenewal: false, + JsonResponseEnabled: true, + CustomPageHTML: "{{#waitTimeKnown}} {{waitTime}} mins {{/waitTimeKnown}} {{^waitTimeKnown}} Queue all enabled {{/waitTimeKnown}}", + DefaultTemplateLanguage: "en-US", + NextEventStartTime: &testTimestampWaitingRoomEventStart, + NextEventPrequeueStartTime: nil, + CookieSuffix: "example_shop", + AdditionalRoutes: []*WaitingRoomRoute{{Host: "shop2.example.com", Path: "/shop/checkout"}}, + QueueingStatusCode: 200, +} + +var waitingRoomEvent = WaitingRoomEvent{ + ID: waitingRoomEventID, + CreatedOn: testTimestampWaitingRoomEvent, + ModifiedOn: testTimestampWaitingRoomEvent, + Name: "production_webinar_event", + Description: "Production event - DO NOT MODIFY", + Suspended: false, + PrequeueStartTime: &testTimestampWaitingRoomEventPrequeue, + EventStartTime: testTimestampWaitingRoomEventStart, + EventEndTime: testTimestampWaitingRoomEventEnd, + ShuffleAtEventStart: false, + NewUsersPerMinute: 2000, + TotalActiveUsers: 2500, + SessionDuration: 0, + DisableSessionRenewal: nil, + QueueingMethod: "random", + CustomPageHTML: "{{#waitTimeKnown}} {{waitTime}} mins {{/waitTimeKnown}} {{^waitTimeKnown}} Event is prequeueing / Queue all enabled {{/waitTimeKnown}}", +} + +var waitingRoomStatus = WaitingRoomStatus{ + Status: "queueing", + EventID: waitingRoomEventID, + EstimatedQueuedUsers: 10, + EstimatedTotalActiveUsers: 9, + MaxEstimatedTimeMinutes: 5, +} + +var waitingRoomPagePreview = WaitingRoomPagePreviewURL{ + PreviewURL: "http://waitingrooms.dev/preview/35af8c12-6d68-4608-babb-b53435a5ddfb", +} + +var waitingRoomRule = WaitingRoomRule{ + ID: waitingRoomRuleID, + Version: "1", + Action: "bypass_waiting_room", + Expression: "ip.src in {1.2.3.4 5.6.7.8}", + Description: "bypass ip", + Enabled: BoolPtr(true), + LastUpdated: &testTimestampWaitingRoom, +} + +var waitingRoomSettings = WaitingRoomSettings{ + SearchEngineCrawlerBypass: true, +} + +var waitingRoomSettingsUpdate = UpdateWaitingRoomSettingsParams{ + SearchEngineCrawlerBypass: BoolPtr(true), +} + +var waitingRoomSettingsPatch = PatchWaitingRoomSettingsParams{ + SearchEngineCrawlerBypass: BoolPtr(true), +} + +func TestListWaitingRooms(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, waitingRoomJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms", handler) + want := []WaitingRoom{waitingRoom} + + actual, err := client.ListWaitingRooms(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListWaitingRoomsNoResult(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms", handler) + want := []WaitingRoom{} + + actual, err := client.ListWaitingRooms(context.Background(), testZoneID) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestWaitingRoom(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252", handler) + want := waitingRoom + + actual, err := client.WaitingRoom(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestWaitingRoomNotFound(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + w.WriteHeader(http.StatusNotFound) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": null, + "errors": [ + { + "code": 1001, + "message": "Object not found." + } + ], + "messages": [] + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252", handler) + + _, err := client.WaitingRoom(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252") + assert.NotNil(t, err) +} + +func TestCreateWaitingRoom(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms", handler) + want := &waitingRoom + + actual, err := client.CreateWaitingRoom(context.Background(), testZoneID, waitingRoom) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateWaitingRoomError(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": false, + "errors": [ + { + "code": 1002, + "message": "new_users_per_minute must be in range [200, total_active_users]: invalid data" + } + ], + "messages": [] + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms", handler) + + _, err := client.CreateWaitingRoom(context.Background(), testZoneID, waitingRoom) + assert.NotNil(t, err) +} + +func TestUpdateWaitingRoom(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252", handler) + want := waitingRoom + + actual, err := client.UpdateWaitingRoom(context.Background(), testZoneID, waitingRoom) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestChangeWaitingRoom(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252", handler) + want := waitingRoom + + actual, err := client.ChangeWaitingRoom(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", WaitingRoom{TotalActiveUsers: 400}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteWaitingRoom(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "699d98642c564d2e855e9661899b7252" + } + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252", handler) + + err := client.DeleteWaitingRoom(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252") + assert.NoError(t, err) +} + +func TestWaitingRoomStatus(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomStatusJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/status", handler) + want := waitingRoomStatus + + actual, err := client.WaitingRoomStatus(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestWaitingRoomPagePreview(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomPagePreviewJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/preview", handler) + want := waitingRoomPagePreview + + actual, err := client.WaitingRoomPagePreview(context.Background(), testZoneID, "{{#waitTimeKnown}} {{waitTime}} mins {{/waitTimeKnown}} {{^waitTimeKnown}} Queue all enabled {{/waitTimeKnown}}") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateWaitingRoomEvent(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomEventJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events", handler) + want := &waitingRoomEvent + + actual, err := client.CreateWaitingRoomEvent(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", waitingRoomEvent) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListWaitingRoomEvents(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, waitingRoomEventJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events", handler) + want := []WaitingRoomEvent{waitingRoomEvent} + + actual, err := client.ListWaitingRoomEvents(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestListWaitingRoomEventsNoResult(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events", handler) + want := []WaitingRoomEvent{} + + actual, err := client.ListWaitingRoomEvents(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestWaitingRoomEvent(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomEventJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events/25756b2dfe6e378a06b033b670413757", handler) + want := waitingRoomEvent + + actual, err := client.WaitingRoomEvent(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", "25756b2dfe6e378a06b033b670413757") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestWaitingRoomEventPreview(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomEventJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events/25756b2dfe6e378a06b033b670413757/details", handler) + want := waitingRoomEvent + + actual, err := client.WaitingRoomEventPreview(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", "25756b2dfe6e378a06b033b670413757") + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateWaitingRoomEvent(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomEventJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events/25756b2dfe6e378a06b033b670413757", handler) + want := waitingRoomEvent + + actual, err := client.UpdateWaitingRoomEvent(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", waitingRoomEvent) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestChangeWaitingRoomEvent(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomEventJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events/25756b2dfe6e378a06b033b670413757", handler) + want := waitingRoomEvent + + actual, err := client.UpdateWaitingRoomEvent(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", waitingRoomEvent) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteWaitingRoomEvent(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "25756b2dfe6e378a06b033b670413757" + } + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/events/25756b2dfe6e378a06b033b670413757", handler) + + err := client.DeleteWaitingRoomEvent(context.Background(), testZoneID, "699d98642c564d2e855e9661899b7252", "25756b2dfe6e378a06b033b670413757") + assert.NoError(t, err) +} + +func TestListWaitingRoomRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, waitingRoomRuleJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/rules", handler) + want := []WaitingRoomRule{waitingRoomRule} + + actual, err := client.ListWaitingRoomRules(context.Background(), ZoneIdentifier(testZoneID), ListWaitingRoomRuleParams{WaitingRoomID: "699d98642c564d2e855e9661899b7252"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestCreateWaitingRoomRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, waitingRoomRuleJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/rules", handler) + want := []WaitingRoomRule{waitingRoomRule} + + actual, err := client.CreateWaitingRoomRule(context.Background(), ZoneIdentifier(testZoneID), CreateWaitingRoomRuleParams{ + WaitingRoomID: "699d98642c564d2e855e9661899b7252", + Rule: waitingRoomRule, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateWaitingRoomRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, waitingRoomRuleJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/rules/"+waitingRoomRuleID, handler) + want := []WaitingRoomRule{waitingRoomRule} + + actual, err := client.UpdateWaitingRoomRule(context.Background(), ZoneIdentifier(testZoneID), UpdateWaitingRoomRuleParams{ + WaitingRoomID: "699d98642c564d2e855e9661899b7252", + Rule: waitingRoomRule, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestDeleteWaitingRoomRule(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": [] + } + `) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/rules/"+waitingRoomRuleID, handler) + want := []WaitingRoomRule{} + + actual, err := client.DeleteWaitingRoomRule(context.Background(), ZoneIdentifier(testZoneID), DeleteWaitingRoomRuleParams{ + WaitingRoomID: "699d98642c564d2e855e9661899b7252", + RuleID: waitingRoomRuleID, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestReplaceWaitingRoomRules(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ] + } + `, waitingRoomRuleJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/699d98642c564d2e855e9661899b7252/rules", handler) + want := []WaitingRoomRule{waitingRoomRule} + + actual, err := client.ReplaceWaitingRoomRules(context.Background(), ZoneIdentifier(testZoneID), ReplaceWaitingRoomRuleParams{ + WaitingRoomID: "699d98642c564d2e855e9661899b7252", + Rules: want, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestWaitingRoomSettings(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomSettingsJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/settings", handler) + want := waitingRoomSettings + + actual, err := client.GetWaitingRoomSettings(context.Background(), ZoneIdentifier(testZoneID)) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateWaitingRoomSettings(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomSettingsJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/settings", handler) + want := waitingRoomSettings + + actual, err := client.UpdateWaitingRoomSettings(context.Background(), ZoneIdentifier(testZoneID), waitingRoomSettingsUpdate) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestChangeWaitingRoomSettings(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, waitingRoomSettingsJSON) + } + + mux.HandleFunc("/zones/"+testZoneID+"/waiting_rooms/settings", handler) + want := waitingRoomSettings + + actual, err := client.PatchWaitingRoomSettings(context.Background(), ZoneIdentifier(testZoneID), waitingRoomSettingsPatch) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/web3.go b/pkg/cloudflare-go/web3.go new file mode 100644 index 000000000..481a5332b --- /dev/null +++ b/pkg/cloudflare-go/web3.go @@ -0,0 +1,198 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + // ErrMissingIdentifier is for when identifier is required but missing. + ErrMissingIdentifier = errors.New("identifier required but missing") + // ErrMissingName is for when name is required but missing. + ErrMissingName = errors.New("name required but missing") + // ErrMissingTarget is for when target is required but missing. + ErrMissingTarget = errors.New("target required but missing") +) + +// Web3Hostname represents a web3 hostname. +type Web3Hostname struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Status string `json:"status,omitempty"` + Target string `json:"target,omitempty"` + Dnslink string `json:"dnslink,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` +} + +// Web3HostnameListParameters represents the parameters for listing web3 hostnames. +type Web3HostnameListParameters struct { + ZoneID string +} + +// Web3HostnameListResponse represents the API response body for listing web3 hostnames. +type Web3HostnameListResponse struct { + Response + Result []Web3Hostname `json:"result"` +} + +// Web3HostnameCreateParameters represents the parameters for creating a web3 hostname. +type Web3HostnameCreateParameters struct { + ZoneID string + Name string `json:"name,omitempty"` + Target string `json:"target,omitempty"` + Description string `json:"description,omitempty"` + DNSLink string `json:"dnslink,omitempty"` +} + +// Web3HostnameResponse represents an API response body for a web3 hostname. +type Web3HostnameResponse struct { + Response + Result Web3Hostname `json:"result,omitempty"` +} + +// Web3HostnameDetailsParameters represents the parameters for getting a single web3 hostname. +type Web3HostnameDetailsParameters struct { + ZoneID string + Identifier string +} + +// Web3HostnameUpdateParameters represents the parameters for editing a web3 hostname. +type Web3HostnameUpdateParameters struct { + ZoneID string + Identifier string + Description string `json:"description,omitempty"` + DNSLink string `json:"dnslink,omitempty"` +} + +// Web3HostnameDeleteResult represents the result of deleting a web3 hostname. +type Web3HostnameDeleteResult struct { + ID string `json:"id,omitempty"` +} + +// Web3HostnameDeleteResponse represents the API response body for deleting a web3 hostname. +type Web3HostnameDeleteResponse struct { + Response + Result Web3HostnameDeleteResult `json:"result,omitempty"` +} + +// ListWeb3Hostnames lists all web3 hostnames. +// +// API Reference: https://api.cloudflare.com/#web3-hostname-list-web3-hostnames +func (api *API) ListWeb3Hostnames(ctx context.Context, params Web3HostnameListParameters) ([]Web3Hostname, error) { + if params.ZoneID == "" { + return []Web3Hostname{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/web3/hostnames", params.ZoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []Web3Hostname{}, err + } + var web3ListResponse Web3HostnameListResponse + if err := json.Unmarshal(res, &web3ListResponse); err != nil { + return []Web3Hostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return web3ListResponse.Result, nil +} + +// CreateWeb3Hostname creates a web3 hostname. +// +// API Reference: https://api.cloudflare.com/#web3-hostname-create-web3-hostname +func (api *API) CreateWeb3Hostname(ctx context.Context, params Web3HostnameCreateParameters) (Web3Hostname, error) { + if params.ZoneID == "" { + return Web3Hostname{}, ErrMissingZoneID + } + if params.Name == "" { + return Web3Hostname{}, ErrMissingName + } + if params.Target == "" { + return Web3Hostname{}, ErrMissingTarget + } + + uri := fmt.Sprintf("/zones/%s/web3/hostnames", params.ZoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return Web3Hostname{}, err + } + var web3Response Web3HostnameResponse + if err := json.Unmarshal(res, &web3Response); err != nil { + return Web3Hostname{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return web3Response.Result, nil +} + +// GetWeb3Hostname gets a single web3 hostname by identifier. +// +// API Reference: https://api.cloudflare.com/#web3-hostname-web3-hostname-details +func (api *API) GetWeb3Hostname(ctx context.Context, params Web3HostnameDetailsParameters) (Web3Hostname, error) { + if params.ZoneID == "" { + return Web3Hostname{}, ErrMissingZoneID + } + if params.Identifier == "" { + return Web3Hostname{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/zones/%s/web3/hostnames/%s", params.ZoneID, params.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Web3Hostname{}, err + } + var web3Response Web3HostnameResponse + if err := json.Unmarshal(res, &web3Response); err != nil { + return Web3Hostname{}, err + } + return web3Response.Result, nil +} + +// UpdateWeb3Hostname edits a web3 hostname. +// +// API Reference: https://api.cloudflare.com/#web3-hostname-edit-web3-hostname +func (api *API) UpdateWeb3Hostname(ctx context.Context, params Web3HostnameUpdateParameters) (Web3Hostname, error) { + if params.ZoneID == "" { + return Web3Hostname{}, ErrMissingZoneID + } + if params.Identifier == "" { + return Web3Hostname{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/zones/%s/web3/hostnames/%s", params.ZoneID, params.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return Web3Hostname{}, err + } + var web3Response Web3HostnameResponse + if err := json.Unmarshal(res, &web3Response); err != nil { + return Web3Hostname{}, err + } + return web3Response.Result, nil +} + +// DeleteWeb3Hostname deletes a web3 hostname. +// +// API Reference: https://api.cloudflare.com/#web3-hostname-delete-web3-hostname +func (api *API) DeleteWeb3Hostname(ctx context.Context, params Web3HostnameDetailsParameters) (Web3HostnameDeleteResult, error) { + if params.ZoneID == "" { + return Web3HostnameDeleteResult{}, ErrMissingZoneID + } + if params.Identifier == "" { + return Web3HostnameDeleteResult{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/zones/%s/web3/hostnames/%s", params.ZoneID, params.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return Web3HostnameDeleteResult{}, err + } + var web3Response Web3HostnameDeleteResponse + if err := json.Unmarshal(res, &web3Response); err != nil { + return Web3HostnameDeleteResult{}, err + } + return web3Response.Result, nil +} diff --git a/pkg/cloudflare-go/web3_test.go b/pkg/cloudflare-go/web3_test.go new file mode 100644 index 000000000..690cbccc1 --- /dev/null +++ b/pkg/cloudflare-go/web3_test.go @@ -0,0 +1,228 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testWeb3HostnameID = "9a7806061c88ada191ed06f989cc3dac" + +func createTestWeb3Hostname() Web3Hostname { + createdOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + return Web3Hostname{ + ID: testWeb3HostnameID, + Name: "gateway.example.com", + Description: "This is my IPFS gateway.", + Status: "active", + Target: "ipfs", + Dnslink: "/ipns/onboarding.ipfs.cloudflare.com", + CreatedOn: &createdOn, + ModifiedOn: &modifiedOn, + } +} + +func TestListWeb3Hostnames(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/web3/hostnames", testZoneID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "9a7806061c88ada191ed06f989cc3dac", + "name": "gateway.example.com", + "description": "This is my IPFS gateway.", + "status": "active", + "target": "ipfs", + "dnslink": "/ipns/onboarding.ipfs.cloudflare.com", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z" + } + ] + }`) + }) + + _, err := client.ListWeb3Hostnames(context.Background(), Web3HostnameListParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + out, err := client.ListWeb3Hostnames(context.Background(), Web3HostnameListParameters{ZoneID: testZoneID}) + assert.NoError(t, err, "Got error listing web3 hostnames") + want := createTestWeb3Hostname() + if assert.NoError(t, err) { + assert.Equal(t, 1, len(out), "length of web3hosts is wrong") + assert.Equal(t, want, out[0], "structs not equal") + } +} + +func TestCreateWeb3Hostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/web3/hostnames", testZoneID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": + { + "id": "9a7806061c88ada191ed06f989cc3dac", + "name": "gateway.example.com", + "description": "This is my IPFS gateway.", + "status": "active", + "target": "ipfs", + "dnslink": "/ipns/onboarding.ipfs.cloudflare.com", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + }) + + _, err := client.CreateWeb3Hostname(context.Background(), Web3HostnameCreateParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + _, err = client.CreateWeb3Hostname(context.Background(), Web3HostnameCreateParameters{ZoneID: testZoneID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingName, err) + } + + _, err = client.CreateWeb3Hostname(context.Background(), Web3HostnameCreateParameters{ZoneID: testZoneID, Name: "gateway.example.com"}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingTarget, err) + } + + out, err := client.CreateWeb3Hostname(context.Background(), Web3HostnameCreateParameters{ZoneID: testZoneID, Name: "gateway.example.com", Target: "ipfs"}) + assert.NoError(t, err, "Got error creating web3 hostname") + want := createTestWeb3Hostname() + if assert.NoError(t, err) { + assert.Equal(t, want, out, "structs not equal") + } +} + +func TestGetWeb3Hostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/web3/hostnames/%s", testZoneID, testWeb3HostnameID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac", + "name": "gateway.example.com", + "description": "This is my IPFS gateway.", + "status": "active", + "target": "ipfs", + "dnslink": "/ipns/onboarding.ipfs.cloudflare.com", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + }) + _, err := client.GetWeb3Hostname(context.Background(), Web3HostnameDetailsParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + _, err = client.GetWeb3Hostname(context.Background(), Web3HostnameDetailsParameters{ZoneID: testZoneID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingIdentifier, err) + } + + out, err := client.GetWeb3Hostname(context.Background(), Web3HostnameDetailsParameters{ZoneID: testZoneID, Identifier: testWeb3HostnameID}) + assert.NoError(t, err, "Got error getting web3 hostname") + want := createTestWeb3Hostname() + if assert.NoError(t, err) { + assert.Equal(t, want, out, "structs not equal") + } +} + +func TestUpdateWeb3Hostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/web3/hostnames/%s", testZoneID, testWeb3HostnameID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac", + "name": "gateway.example.com", + "description": "This is my IPFS gateway.", + "status": "active", + "target": "ipfs", + "dnslink": "/ipns/onboarding.ipfs.cloudflare.com", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + }) + _, err := client.UpdateWeb3Hostname(context.Background(), Web3HostnameUpdateParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + _, err = client.UpdateWeb3Hostname(context.Background(), Web3HostnameUpdateParameters{ZoneID: testZoneID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingIdentifier, err) + } + + out, err := client.UpdateWeb3Hostname(context.Background(), Web3HostnameUpdateParameters{ZoneID: testZoneID, Identifier: testWeb3HostnameID}) + assert.NoError(t, err, "Got error getting web3 hostname") + want := createTestWeb3Hostname() + if assert.NoError(t, err) { + assert.Equal(t, want, out, "structs not equal") + } +} + +func TestDeleteWeb3Hostname(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/web3/hostnames/%s", testZoneID, testWeb3HostnameID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "9a7806061c88ada191ed06f989cc3dac" + } + } + `) + }) + _, err := client.DeleteWeb3Hostname(context.Background(), Web3HostnameDetailsParameters{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + _, err = client.DeleteWeb3Hostname(context.Background(), Web3HostnameDetailsParameters{ZoneID: testZoneID}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingIdentifier, err) + } + + out, err := client.DeleteWeb3Hostname(context.Background(), Web3HostnameDetailsParameters{ZoneID: testZoneID, Identifier: testWeb3HostnameID}) + assert.NoError(t, err, "Got error deleting web3 hostname") + if assert.NoError(t, err) { + assert.Equal(t, testWeb3HostnameID, out.ID, "delete web3 response incorrect") + } +} diff --git a/pkg/cloudflare-go/web_analytics.go b/pkg/cloudflare-go/web_analytics.go new file mode 100644 index 000000000..9f3041f1a --- /dev/null +++ b/pkg/cloudflare-go/web_analytics.go @@ -0,0 +1,411 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingWebAnalyticsSiteTag = errors.New("missing required web analytics site ID") + ErrMissingWebAnalyticsRulesetID = errors.New("missing required web analytics ruleset ID") + ErrMissingWebAnalyticsRuleID = errors.New("missing required web analytics rule ID") + ErrMissingWebAnalyticsSiteHost = errors.New("missing required web analytics host or zone_tag") + ErrConflictingWebAnalyticSiteHost = errors.New("conflicting web analytics host and zone_tag, only one must be specified") +) + +// listWebAnalyticsSitesDefaultPageSize represents the default per_pagesize of the API. +var listWebAnalyticsSitesDefaultPageSize = 10 + +// WebAnalyticsSite describes a Web Analytics Site object. +type WebAnalyticsSite struct { + SiteTag string `json:"site_tag"` + SiteToken string `json:"site_token"` + Created *time.Time `json:"created,omitempty"` + // Snippet is an encoded JS script to insert into your site HTML. + Snippet string `json:"snippet"` + // AutoInstall defines whether Cloudflare will inject the JS snippet automatically for orange-clouded sites. + AutoInstall bool `json:"auto_install"` + Ruleset WebAnalyticsRuleset `json:"ruleset"` + Rules []WebAnalyticsRule `json:"rules"` +} + +// WebAnalyticsRule describes a Web Analytics Rule object. +type WebAnalyticsRule struct { + ID string `json:"id,omitempty"` + Host string `json:"host"` + Paths []string `json:"paths"` + // Inclusive defines whether the rule includes or excludes the matched traffic from being measured in web analytics. + Inclusive bool `json:"inclusive"` + Created *time.Time `json:"created,omitempty"` + // IsPaused defines whether the rule is paused (inactive) or not. + IsPaused bool `json:"is_paused"` + Priority int `json:"priority,omitempty"` +} + +// CreateWebAnalyticsRule describes the properties required to create or update a Web Analytics Rule object. +type CreateWebAnalyticsRule struct { + ID string `json:"id,omitempty"` + Host string `json:"host"` + Paths []string `json:"paths"` + // Inclusive defines whether the rule includes or excludes the matched traffic from being measured in web analytics. + Inclusive bool `json:"inclusive"` + IsPaused bool `json:"is_paused"` +} + +// WebAnalyticsRuleset describes a Web Analytics Ruleset object. +type WebAnalyticsRuleset struct { + ID string `json:"id"` + ZoneTag string `json:"zone_tag"` + ZoneName string `json:"zone_name"` + Enabled bool `json:"enabled"` +} + +// WebAnalyticsSiteResponse is the API response, containing a single WebAnalyticsSite. +type WebAnalyticsSiteResponse struct { + Response + Result WebAnalyticsSite `json:"result"` +} + +// WebAnalyticsSitesResponse is the API response, containing an array of WebAnalyticsSite. +type WebAnalyticsSitesResponse struct { + Response + ResultInfo ResultInfo `json:"result_info"` + Result []WebAnalyticsSite `json:"result"` +} + +// WebAnalyticsRuleResponse is the API response, containing a single WebAnalyticsRule. +type WebAnalyticsRuleResponse struct { + Response + Result WebAnalyticsRule `json:"result"` +} + +type WebAnalyticsRulesetRules struct { + Ruleset WebAnalyticsRuleset `json:"ruleset"` + Rules []WebAnalyticsRule `json:"rules"` +} + +// WebAnalyticsRulesResponse is the API response, containing a WebAnalyticsRuleset and array of WebAnalyticsRule. +type WebAnalyticsRulesResponse struct { + Response + Result WebAnalyticsRulesetRules `json:"result"` +} + +// WebAnalyticsIDResponse is the API response, containing a single ID. +type WebAnalyticsIDResponse struct { + Response + Result struct { + ID string `json:"id"` + } `json:"result"` +} + +// WebAnalyticsSiteTagResponse is the API response, containing a single ID. +type WebAnalyticsSiteTagResponse struct { + Response + Result struct { + SiteTag string `json:"site_tag"` + } `json:"result"` +} + +type CreateWebAnalyticsSiteParams struct { + // Host is the host to measure traffic for. + Host string `json:"host,omitempty"` + // ZoneTag is the zone tag to measure traffic for. + ZoneTag string `json:"zone_tag,omitempty"` + // AutoInstall defines whether Cloudflare will inject the JS snippet automatically for orange-clouded sites. + AutoInstall *bool `json:"auto_install"` +} + +// CreateWebAnalyticsSite creates a new Web Analytics Site for an Account. +// +// API reference: https://api.cloudflare.com/#web-analytics-create-site +func (api *API) CreateWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params CreateWebAnalyticsSiteParams) (*WebAnalyticsSite, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.Host == "" && params.ZoneTag == "" { + return nil, ErrMissingWebAnalyticsSiteHost + } + if params.Host != "" && params.ZoneTag != "" { + return nil, ErrConflictingWebAnalyticSiteHost + } + if params.AutoInstall == nil { + // default auto_install to true for orange-clouded zones (zone_tag is specified) + params.AutoInstall = BoolPtr(params.ZoneTag != "") + } + uri := fmt.Sprintf("/accounts/%s/rum/site_info", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return nil, err + } + var r WebAnalyticsSiteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type ListWebAnalyticsSitesParams struct { + ResultInfo + // Property to order Sites by, "host" or "created". + OrderBy string `url:"order_by,omitempty"` +} + +// ListWebAnalyticsSites returns all Web Analytics Sites of an Account. +// +// API reference: https://api.cloudflare.com/#web-analytics-list-sites +func (api *API) ListWebAnalyticsSites(ctx context.Context, rc *ResourceContainer, params ListWebAnalyticsSitesParams) ([]WebAnalyticsSite, *ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return nil, nil, ErrRequiredAccountLevelResourceContainer + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = listWebAnalyticsSitesDefaultPageSize + } + + if params.Page < 1 { + params.Page = 1 + } + + var sites []WebAnalyticsSite + var lastResultInfo ResultInfo + + for { + uri := buildURI(fmt.Sprintf("/accounts/%s/rum/site_info/list", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, nil, err + } + var r WebAnalyticsSitesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + sites = append(sites, r.Result...) + lastResultInfo = r.ResultInfo + params.ResultInfo = r.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + return sites, &lastResultInfo, nil +} + +type GetWebAnalyticsSiteParams struct { + SiteTag string +} + +// GetWebAnalyticsSite fetches detail about one Web Analytics Site for an Account. +// +// API reference: https://api.cloudflare.com/#web-analytics-get-site +func (api *API) GetWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params GetWebAnalyticsSiteParams) (*WebAnalyticsSite, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.SiteTag == "" { + return nil, ErrMissingWebAnalyticsSiteTag + } + uri := fmt.Sprintf("/accounts/%s/rum/site_info/%s", rc.Identifier, params.SiteTag) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r WebAnalyticsSiteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type UpdateWebAnalyticsSiteParams struct { + SiteTag string `json:"-"` + // Host is the host to measure traffic for. + Host string `json:"host,omitempty"` + // ZoneTag is the zone tag to measure traffic for. + ZoneTag string `json:"zone_tag,omitempty"` + // AutoInstall defines whether Cloudflare will inject the JS snippet automatically for orange-clouded sites. + AutoInstall *bool `json:"auto_install"` +} + +// UpdateWebAnalyticsSite updates an existing Web Analytics Site for an Account. +// +// API reference: https://api.cloudflare.com/#web-analytics-update-site +func (api *API) UpdateWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params UpdateWebAnalyticsSiteParams) (*WebAnalyticsSite, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.SiteTag == "" { + return nil, ErrMissingWebAnalyticsSiteTag + } + if params.AutoInstall == nil { + // default auto_install to true for orange-clouded zones (zone_tag is specified) + params.AutoInstall = BoolPtr(params.ZoneTag != "") + } + uri := fmt.Sprintf("/accounts/%s/rum/site_info/%s", rc.Identifier, params.SiteTag) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return nil, err + } + var r WebAnalyticsSiteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type DeleteWebAnalyticsSiteParams struct { + SiteTag string +} + +// DeleteWebAnalyticsSite deletes an existing Web Analytics Site for an Account. +// +// API reference: https://api.cloudflare.com/#web-analytics-delete-site +func (api *API) DeleteWebAnalyticsSite(ctx context.Context, rc *ResourceContainer, params DeleteWebAnalyticsSiteParams) (*string, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.SiteTag == "" { + return nil, ErrMissingWebAnalyticsSiteTag + } + uri := fmt.Sprintf("/accounts/%s/rum/site_info/%s", rc.Identifier, params.SiteTag) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + var r WebAnalyticsSiteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result.SiteTag, nil +} + +type CreateWebAnalyticsRuleParams struct { + RulesetID string + Rule CreateWebAnalyticsRule +} + +// CreateWebAnalyticsRule creates a new Web Analytics Rule in a Web Analytics ruleset. +// +// API reference: https://api.cloudflare.com/#web-analytics-create-rule +func (api *API) CreateWebAnalyticsRule(ctx context.Context, rc *ResourceContainer, params CreateWebAnalyticsRuleParams) (*WebAnalyticsRule, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.RulesetID == "" { + return nil, ErrMissingWebAnalyticsRulesetID + } + uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rule", rc.Identifier, params.RulesetID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Rule) + if err != nil { + return nil, err + } + var r WebAnalyticsRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type ListWebAnalyticsRulesParams struct { + RulesetID string +} + +// ListWebAnalyticsRules fetches all Web Analytics Rules in a Web Analytics ruleset. +// +// API reference: https://api.cloudflare.com/#web-analytics-list-rules +func (api *API) ListWebAnalyticsRules(ctx context.Context, rc *ResourceContainer, params ListWebAnalyticsRulesParams) (*WebAnalyticsRulesetRules, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.RulesetID == "" { + return nil, ErrMissingWebAnalyticsRulesetID + } + uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rules", rc.Identifier, params.RulesetID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r WebAnalyticsRulesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} + +type DeleteWebAnalyticsRuleParams struct { + RulesetID string + RuleID string +} + +// DeleteWebAnalyticsRule deletes an existing Web Analytics Rule from a Web Analytics ruleset. +// +// API reference: https://api.cloudflare.com/#web-analytics-delete-rule +func (api *API) DeleteWebAnalyticsRule(ctx context.Context, rc *ResourceContainer, params DeleteWebAnalyticsRuleParams) (*string, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.RulesetID == "" { + return nil, ErrMissingWebAnalyticsRulesetID + } + if params.RuleID == "" { + return nil, ErrMissingWebAnalyticsRuleID + } + uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rule/%s", rc.Identifier, params.RulesetID, params.RuleID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return nil, err + } + var r WebAnalyticsIDResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result.ID, nil +} + +type UpdateWebAnalyticsRuleParams struct { + RulesetID string + RuleID string + Rule CreateWebAnalyticsRule +} + +// UpdateWebAnalyticsRule updates a Web Analytics Rule in a Web Analytics ruleset. +// +// API reference: https://api.cloudflare.com/#web-analytics-update-rule +func (api *API) UpdateWebAnalyticsRule(ctx context.Context, rc *ResourceContainer, params UpdateWebAnalyticsRuleParams) (*WebAnalyticsRule, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + if params.RulesetID == "" { + return nil, ErrMissingWebAnalyticsRulesetID + } + if params.RuleID == "" { + return nil, ErrMissingWebAnalyticsRuleID + } + uri := fmt.Sprintf("/accounts/%s/rum/v2/%s/rule/%s", rc.Identifier, params.RulesetID, params.RuleID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Rule) + if err != nil { + return nil, err + } + var r WebAnalyticsRuleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return &r.Result, nil +} diff --git a/pkg/cloudflare-go/web_analytics_test.go b/pkg/cloudflare-go/web_analytics_test.go new file mode 100644 index 000000000..cbacbb2de --- /dev/null +++ b/pkg/cloudflare-go/web_analytics_test.go @@ -0,0 +1,385 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var siteTag = "46c32e0ea0e85e90aa1a6df4596b831e" +var siteToken = "75300e6c2c5648d983fcef2a6c03d14e" +var rulesetID = "2e8804e9-674f-4652-94a4-1c664d0d6764" +var ruleID = "3caf59c9-eda3-4f99-a4a3-ee5fc2358a78" + +// var snippetFormat = `\u003c!-- Cloudflare Web Analytics --\u003e\u003cscript defer src='https://static.cloudflareinsights.com/beacon.min.js' data-cf-beacon='{\"token\": \"%s\"}'\u003e\u003c/script\u003e\u003c!-- End Cloudflare Web Analytics --\u003e`. +var snippetFormat = `%s` +var createdTimestamp = time.Now().UTC() +var siteJSON = fmt.Sprintf(` +{ + "site_tag": "%s", + "site_token": "%s", + "created": "%s", + "snippet": "%s", + "auto_install": true, + "ruleset": { + "zone_tag": "%s", + "zone_name": "example.com", + "enabled": true, + "id": "%s" + }, + "rules": [ + { + "host": "example.com", + "paths": [ + "*" + ], + "inclusive": true, + "created": "%s", + "is_paused": false, + "priority": 1000, + "id": "%s" + } + ] +} +`, siteTag, siteToken, createdTimestamp.Format(time.RFC3339Nano), fmt.Sprintf(snippetFormat, siteToken), testZoneID, rulesetID, createdTimestamp.Format(time.RFC3339Nano), ruleID) + +var rulesetJSON = fmt.Sprintf(` +{ + "id": "%s", + "zone_tag": "%s", + "zone_name": "%s", + "enabled": true +} +`, rulesetID, testZoneID, "example.com") + +var ruleJSON = fmt.Sprintf(` +{ + "id": "%s", + "host": "example.com", + "paths": [ + "*" + ], + "inclusive": true, + "created": "%s", + "is_paused": false, + "priority": 1000 +} +`, ruleID, createdTimestamp.Format(time.RFC3339Nano)) + +var site = WebAnalyticsSite{ + SiteTag: siteTag, + SiteToken: siteToken, + AutoInstall: true, + Snippet: fmt.Sprintf(snippetFormat, siteToken), + Ruleset: ruleset, + Rules: []WebAnalyticsRule{ + rule, + }, + Created: TimePtr(createdTimestamp.UTC()), +} + +var ruleset = WebAnalyticsRuleset{ + ID: rulesetID, + ZoneTag: testZoneID, + ZoneName: "example.com", + Enabled: true, +} + +var rule = WebAnalyticsRule{ + ID: ruleID, + Host: "example.com", + Paths: []string{ + "*", + }, + Inclusive: true, + Created: TimePtr(createdTimestamp.UTC()), + IsPaused: false, + Priority: 1000, +} + +func TestListWebAnalyticsSites(t *testing.T) { + setup() + defer teardown() + + 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, "1", r.URL.Query().Get("page")) + assert.Equal(t, "10", r.URL.Query().Get("per_page")) + assert.Equal(t, "host", r.URL.Query().Get("order_by")) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + %s + ], + "result_info": { + "page": 1, + "per_page": 10, + "count": 1, + "total_count": 1, + "total_pages": 1 + } + } + `, siteJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/list", handler) + want := []WebAnalyticsSite{site} + actual, resultInfo, err := client.ListWebAnalyticsSites(context.Background(), AccountIdentifier(testAccountID), ListWebAnalyticsSitesParams{ + ResultInfo: ResultInfo{ + Page: 0, + PerPage: 0, + }, + OrderBy: "host", + }) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + assert.Equal(t, &ResultInfo{ + Page: 1, + PerPage: 10, + TotalPages: 1, + Count: 1, + Total: 1, + }, resultInfo) + assert.Len(t, actual, 1) + } +} + +func TestGetWebAnalyticsSite(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, siteJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/"+siteTag, handler) + want := site + actual, err := client.GetWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), GetWebAnalyticsSiteParams{ + SiteTag: siteTag, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestCreateWebAnalyticsSite(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.True(t, strings.Contains(string(body), `"auto_install":true`)) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, siteJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info", handler) + want := site + actual, err := client.CreateWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), CreateWebAnalyticsSiteParams{ + ZoneTag: testZoneID, + //AutoInstall: BoolPtr(true), // should default to true + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestUpdateWebAnalyticsSite(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, siteJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/"+siteTag, handler) + want := site + actual, err := client.UpdateWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), UpdateWebAnalyticsSiteParams{ + SiteTag: site.SiteTag, + Host: "example.com", + AutoInstall: BoolPtr(true), + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestDeleteWebAnalyticsSite(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'GET', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "site_tag": "%s" + } + } + `, siteTag) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/site_info/"+siteTag, handler) + want := siteTag + actual, err := client.DeleteWebAnalyticsSite(context.Background(), AccountIdentifier(testAccountID), DeleteWebAnalyticsSiteParams{ + SiteTag: siteTag, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestListWebAnalyticsRules(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "ruleset": %s, + "rules": [ + %s + ] + } + } + `, rulesetJSON, ruleJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rules", handler) + want := WebAnalyticsRulesetRules{ + Ruleset: ruleset, + Rules: []WebAnalyticsRule{rule}, + } + actual, err := client.ListWebAnalyticsRules(context.Background(), AccountIdentifier(testAccountID), ListWebAnalyticsRulesParams{ + RulesetID: rulesetID, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestCreateWebAnalyticsRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, ruleJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rule", handler) + want := rule + actual, err := client.CreateWebAnalyticsRule(context.Background(), AccountIdentifier(testAccountID), CreateWebAnalyticsRuleParams{ + RulesetID: rulesetID, + Rule: CreateWebAnalyticsRule{ + Host: "example.com", + Paths: []string{"*"}, + Inclusive: true, + IsPaused: false, + }, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestUpdateWebAnalyticsRule(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": %s + } + `, ruleJSON) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rule/"+ruleID, handler) + want := rule + actual, err := client.UpdateWebAnalyticsRule(context.Background(), AccountIdentifier(testAccountID), UpdateWebAnalyticsRuleParams{ + RulesetID: rulesetID, + RuleID: ruleID, + Rule: CreateWebAnalyticsRule{ + Host: "example.com", + Paths: []string{"*"}, + Inclusive: true, + IsPaused: false, + }, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} + +func TestDeleteWebAnalyticsRule(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "%s" + } + } + `, ruleID) + } + mux.HandleFunc("/accounts/"+testAccountID+"/rum/v2/"+rulesetID+"/rule/"+ruleID, handler) + want := ruleID + actual, err := client.DeleteWebAnalyticsRule(context.Background(), AccountIdentifier(testAccountID), DeleteWebAnalyticsRuleParams{ + RulesetID: rulesetID, + RuleID: ruleID, + }) + if assert.NoError(t, err) { + assert.Equal(t, &want, actual) + } +} diff --git a/pkg/cloudflare-go/workers.go b/pkg/cloudflare-go/workers.go new file mode 100644 index 000000000..2ffc492dd --- /dev/null +++ b/pkg/cloudflare-go/workers.go @@ -0,0 +1,631 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + "time" + + "github.com/goccy/go-json" +) + +// WorkerRequestParams provides parameters for worker requests for both enterprise and standard requests. +type WorkerRequestParams struct { + ZoneID string + ScriptName string +} + +type CreateWorkerParams struct { + ScriptName string + Script string + + // DispatchNamespaceName uploads the worker to a WFP dispatch namespace if provided + DispatchNamespaceName *string + + // Module changes the Content-Type header to specify the script is an + // ES Module syntax script. + Module bool + + // Logpush opts the worker into Workers Logpush logging. A nil value leaves + // the current setting unchanged. + // + // Documentation: https://developers.cloudflare.com/workers/platform/logpush/ + Logpush *bool + + // TailConsumers specifies a list of Workers that will consume the logs of + // the attached Worker. + // Documentation: https://developers.cloudflare.com/workers/platform/tail-workers/ + TailConsumers *[]WorkersTailConsumer + + // Bindings should be a map where the keys are the binding name, and the + // values are the binding content + Bindings map[string]WorkerBinding + + // CompatibilityDate is a date in the form yyyy-mm-dd, + // which will be used to determine which version of the Workers runtime is used. + // https://developers.cloudflare.com/workers/platform/compatibility-dates/ + CompatibilityDate string + + // CompatibilityFlags are the names of features of the Workers runtime to be enabled or disabled, + // usually used together with CompatibilityDate. + // https://developers.cloudflare.com/workers/platform/compatibility-dates/#compatibility-flags + CompatibilityFlags []string + + Placement *Placement + + // Tags are used to better manage CRUD operations at scale. + // https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/platform/tags/ + Tags []string +} + +func (p CreateWorkerParams) RequiresMultipart() bool { + switch { + case p.Module: + return true + case p.Logpush != nil: + return true + case p.Placement != nil: + return true + case len(p.Bindings) > 0: + return true + case p.CompatibilityDate != "": + return true + case len(p.CompatibilityFlags) > 0: + return true + case p.TailConsumers != nil: + return true + case len(p.Tags) > 0: + return true + } + + return false +} + +type UpdateWorkersScriptContentParams struct { + ScriptName string + Script string + + // DispatchNamespaceName uploads the worker to a WFP dispatch namespace if provided + DispatchNamespaceName *string + + // Module changes the Content-Type header to specify the script is an + // ES Module syntax script. + Module bool +} + +type UpdateWorkersScriptSettingsParams struct { + ScriptName string + + // Logpush opts the worker into Workers Logpush logging. A nil value leaves + // the current setting unchanged. + // + // Documentation: https://developers.cloudflare.com/workers/platform/logpush/ + Logpush *bool + + // TailConsumers specifies a list of Workers that will consume the logs of + // the attached Worker. + // Documentation: https://developers.cloudflare.com/workers/platform/tail-workers/ + TailConsumers *[]WorkersTailConsumer + + // Bindings should be a map where the keys are the binding name, and the + // values are the binding content + Bindings map[string]WorkerBinding + + // CompatibilityDate is a date in the form yyyy-mm-dd, + // which will be used to determine which version of the Workers runtime is used. + // https://developers.cloudflare.com/workers/platform/compatibility-dates/ + CompatibilityDate string + + // CompatibilityFlags are the names of features of the Workers runtime to be enabled or disabled, + // usually used together with CompatibilityDate. + // https://developers.cloudflare.com/workers/platform/compatibility-dates/#compatibility-flags + CompatibilityFlags []string + + Placement *Placement +} + +// WorkerScriptParams provides a worker script and the associated bindings. +type WorkerScriptParams struct { + ScriptName string + + // Module changes the Content-Type header to specify the script is an + // ES Module syntax script. + Module bool + + // Bindings should be a map where the keys are the binding name, and the + // values are the binding content + Bindings map[string]WorkerBinding +} + +// WorkerRoute is used to map traffic matching a URL pattern to a workers +// +// API reference: https://api.cloudflare.com/#worker-routes-properties +type WorkerRoute struct { + ID string `json:"id,omitempty"` + Pattern string `json:"pattern"` + ScriptName string `json:"script,omitempty"` +} + +// WorkerRoutesResponse embeds Response struct and slice of WorkerRoutes. +type WorkerRoutesResponse struct { + Response + Routes []WorkerRoute `json:"result"` +} + +// WorkerRouteResponse embeds Response struct and a single WorkerRoute. +type WorkerRouteResponse struct { + Response + WorkerRoute `json:"result"` +} + +// WorkerScript Cloudflare Worker struct with metadata. +type WorkerScript struct { + WorkerMetaData + Script string `json:"script"` + UsageModel string `json:"usage_model,omitempty"` +} + +type WorkersTailConsumer struct { + Service string `json:"service"` + Environment *string `json:"environment,omitempty"` + Namespace *string `json:"namespace,omitempty"` +} + +// WorkerMetaData contains worker script information such as size, creation & modification dates. +type WorkerMetaData struct { + ID string `json:"id,omitempty"` + ETAG string `json:"etag,omitempty"` + Size int `json:"size,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + Logpush *bool `json:"logpush,omitempty"` + TailConsumers *[]WorkersTailConsumer `json:"tail_consumers,omitempty"` + LastDeployedFrom *string `json:"last_deployed_from,omitempty"` + DeploymentId *string `json:"deployment_id,omitempty"` + PlacementMode *PlacementMode `json:"placement_mode,omitempty"` + PipelineHash *string `json:"pipeline_hash,omitempty"` +} + +// WorkerListResponse wrapper struct for API response to worker script list API call. +type WorkerListResponse struct { + Response + ResultInfo + WorkerList []WorkerMetaData `json:"result"` +} + +// WorkerScriptResponse wrapper struct for API response to worker script calls. +type WorkerScriptResponse struct { + Response + Module bool + WorkerScript `json:"result"` +} + +// WorkerScriptSettingsResponse wrapper struct for API response to worker script settings calls. +type WorkerScriptSettingsResponse struct { + Response + WorkerMetaData +} + +type ListWorkersParams struct{} + +type DeleteWorkerParams struct { + ScriptName string + + // DispatchNamespaceName is the dispatch namespace the Worker is uploaded to. + DispatchNamespace *string +} + +type PlacementMode string + +const ( + PlacementModeOff PlacementMode = "" + PlacementModeSmart PlacementMode = "smart" +) + +type Placement struct { + Mode PlacementMode `json:"mode"` +} + +// DeleteWorker deletes a single Worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-delete-worker +func (api *API) DeleteWorker(ctx context.Context, rc *ResourceContainer, params DeleteWorkerParams) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", rc.Identifier, params.ScriptName) + if params.DispatchNamespace != nil && *params.DispatchNamespace != "" { + uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s", rc.Identifier, *params.DispatchNamespace, params.ScriptName) + } + + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + var r WorkerScriptResponse + if err != nil { + return err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} + +// GetWorker fetch raw script content for your worker returns string containing +// worker code js. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-download-worker +func (api *API) GetWorker(ctx context.Context, rc *ResourceContainer, scriptName string) (WorkerScriptResponse, error) { + return api.GetWorkerWithDispatchNamespace(ctx, rc, scriptName, "") +} + +// GetWorker fetch raw script content for your worker returns string containing +// worker code js. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-download-worker +func (api *API) GetWorkerWithDispatchNamespace(ctx context.Context, rc *ResourceContainer, scriptName string, dispatchNamespace string) (WorkerScriptResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkerScriptResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerScriptResponse{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", rc.Identifier, scriptName) + if dispatchNamespace != "" { + uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s/content", rc.Identifier, dispatchNamespace, scriptName) + } + res, err := api.makeRequestContextWithHeadersComplete(ctx, http.MethodGet, uri, nil, nil) + var r WorkerScriptResponse + if err != nil { + return r, err + } + + // Check if the response type is multipart, in which case this was a module worker + mediaType, mediaParams, _ := mime.ParseMediaType(res.Headers.Get("content-type")) + if strings.HasPrefix(mediaType, "multipart/") { + bytesReader := bytes.NewReader(res.Body) + mimeReader := multipart.NewReader(bytesReader, mediaParams["boundary"]) + mimePart, err := mimeReader.NextPart() + if err != nil { + return r, fmt.Errorf("could not get multipart response body: %w", err) + } + mimePartBody, err := io.ReadAll(mimePart) + if err != nil { + return r, fmt.Errorf("could not read multipart response body: %w", err) + } + r.Script = string(mimePartBody) + r.Module = true + } else { + r.Script = string(res.Body) + r.Module = false + } + + r.Success = true + return r, nil +} + +// ListWorkers returns list of Workers for given account. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-list-workers +func (api *API) ListWorkers(ctx context.Context, rc *ResourceContainer, params ListWorkersParams) (WorkerListResponse, *ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return WorkerListResponse{}, &ResultInfo{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerListResponse{}, &ResultInfo{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkerListResponse{}, &ResultInfo{}, err + } + + var r WorkerListResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WorkerListResponse{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r, &r.ResultInfo, nil +} + +// UploadWorker pushes raw script content for your Worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-upload-worker-module +func (api *API) UploadWorker(ctx context.Context, rc *ResourceContainer, params CreateWorkerParams) (WorkerScriptResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkerScriptResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerScriptResponse{}, ErrMissingAccountID + } + + body := []byte(params.Script) + var ( + contentType = "application/javascript" + err error + ) + + if params.RequiresMultipart() { + contentType, body, err = formatMultipartBody(params) + if err != nil { + return WorkerScriptResponse{}, err + } + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s", rc.Identifier, params.ScriptName) + if params.DispatchNamespaceName != nil && *params.DispatchNamespaceName != "" { + uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s", rc.Identifier, *params.DispatchNamespaceName, params.ScriptName) + } + + headers := make(http.Header) + headers.Set("Content-Type", contentType) + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPut, uri, body, headers) + + var r WorkerScriptResponse + if err != nil { + return r, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return r, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r, nil +} + +// GetWorkersScriptContent returns the pure script content of a worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-get-content +func (api *API) GetWorkersScriptContent(ctx context.Context, rc *ResourceContainer, scriptName string) (string, error) { + if rc.Level != AccountRouteLevel { + return "", ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return "", ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/content/v2", rc.Identifier, scriptName) + res, err := api.makeRequestContextWithHeadersComplete(ctx, http.MethodGet, uri, nil, nil) + if err != nil { + return "", err + } + + return string(res.Body), nil +} + +// UpdateWorkersScriptContent pushes only script content, no metadata. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-put-content +func (api *API) UpdateWorkersScriptContent(ctx context.Context, rc *ResourceContainer, params UpdateWorkersScriptContentParams) (WorkerScriptResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkerScriptResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerScriptResponse{}, ErrMissingAccountID + } + + body := []byte(params.Script) + var ( + contentType = "application/javascript" + err error + ) + + if params.Module { + var formattedParams CreateWorkerParams + formattedParams.Script = params.Script + formattedParams.ScriptName = params.ScriptName + formattedParams.Module = params.Module + formattedParams.DispatchNamespaceName = params.DispatchNamespaceName + contentType, body, err = formatMultipartBody(formattedParams) + if err != nil { + return WorkerScriptResponse{}, err + } + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/content", rc.Identifier, params.ScriptName) + if params.DispatchNamespaceName != nil { + uri = fmt.Sprintf("/accounts/%s/workers/dispatch_namespaces/%s/scripts/%s/content", rc.Identifier, *params.DispatchNamespaceName, params.ScriptName) + } + + headers := make(http.Header) + headers.Set("Content-Type", contentType) + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPut, uri, body, headers) + + var r WorkerScriptResponse + if err != nil { + return r, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return r, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r, nil +} + +// GetWorkersScriptSettings returns the metadata of a worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-get-settings +func (api *API) GetWorkersScriptSettings(ctx context.Context, rc *ResourceContainer, scriptName string) (WorkerScriptSettingsResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkerScriptSettingsResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerScriptSettingsResponse{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/settings", rc.Identifier, scriptName) + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, nil) + var r WorkerScriptSettingsResponse + if err != nil { + return r, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return r, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + r.Success = true + + return r, nil +} + +// UpdateWorkersScriptSettings pushes only script metadata. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-script-patch-settings +func (api *API) UpdateWorkersScriptSettings(ctx context.Context, rc *ResourceContainer, params UpdateWorkersScriptSettingsParams) (WorkerScriptSettingsResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkerScriptSettingsResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerScriptSettingsResponse{}, ErrMissingAccountID + } + + body, err := json.Marshal(params) + if err != nil { + return WorkerScriptSettingsResponse{}, err + } + headers := make(http.Header) + headers.Set("Content-Type", "application/json") + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/settings", rc.Identifier, params.ScriptName) + res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPatch, uri, body, headers) + var r WorkerScriptSettingsResponse + if err != nil { + return r, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return r, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + r.Success = true + + return r, nil +} + +// Returns content-type, body, error. +func formatMultipartBody(params CreateWorkerParams) (string, []byte, error) { + var buf = &bytes.Buffer{} + var mpw = multipart.NewWriter(buf) + defer mpw.Close() + + // Write metadata part + var scriptPartName string + meta := struct { + BodyPart string `json:"body_part,omitempty"` + MainModule string `json:"main_module,omitempty"` + Bindings []workerBindingMeta `json:"bindings"` + Logpush *bool `json:"logpush,omitempty"` + TailConsumers *[]WorkersTailConsumer `json:"tail_consumers,omitempty"` + CompatibilityDate string `json:"compatibility_date,omitempty"` + CompatibilityFlags []string `json:"compatibility_flags,omitempty"` + Placement *Placement `json:"placement,omitempty"` + Tags []string `json:"tags"` + }{ + Bindings: make([]workerBindingMeta, 0, len(params.Bindings)), + Logpush: params.Logpush, + TailConsumers: params.TailConsumers, + CompatibilityDate: params.CompatibilityDate, + CompatibilityFlags: params.CompatibilityFlags, + Placement: params.Placement, + Tags: params.Tags, + } + + if params.Module { + scriptPartName = "worker.mjs" + meta.MainModule = scriptPartName + } else { + scriptPartName = "script" + meta.BodyPart = scriptPartName + } + + bodyWriters := make([]workerBindingBodyWriter, 0, len(params.Bindings)) + for name, b := range params.Bindings { + bindingMeta, bodyWriter, err := b.serialize(name) + if err != nil { + return "", nil, err + } + + meta.Bindings = append(meta.Bindings, bindingMeta) + bodyWriters = append(bodyWriters, bodyWriter) + } + + var hdr = textproto.MIMEHeader{} + hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"`, "metadata")) + hdr.Set("content-type", "application/json") + pw, err := mpw.CreatePart(hdr) + if err != nil { + return "", nil, err + } + metaJSON, err := json.Marshal(meta) + if err != nil { + return "", nil, err + } + _, err = pw.Write(metaJSON) + if err != nil { + return "", nil, err + } + + // Write script part + hdr = textproto.MIMEHeader{} + + contentType := "application/javascript" + if params.Module { + contentType = "application/javascript+module" + hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"; filename="%[1]s"`, scriptPartName)) + } else { + hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"`, scriptPartName)) + } + hdr.Set("content-type", contentType) + + pw, err = mpw.CreatePart(hdr) + if err != nil { + return "", nil, err + } + _, err = pw.Write([]byte(params.Script)) + if err != nil { + return "", nil, err + } + + // Write other bindings with parts + for _, w := range bodyWriters { + if w != nil { + err = w(mpw) + if err != nil { + return "", nil, err + } + } + } + + mpw.Close() + + return mpw.FormDataContentType(), buf.Bytes(), nil +} diff --git a/pkg/cloudflare-go/workers_account_settings.go b/pkg/cloudflare-go/workers_account_settings.go new file mode 100644 index 000000000..55e0d30e7 --- /dev/null +++ b/pkg/cloudflare-go/workers_account_settings.go @@ -0,0 +1,83 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type WorkersAccountSettings struct { + DefaultUsageModel string `json:"default_usage_model,omitempty"` + GreenCompute bool `json:"green_compute,omitempty"` +} + +type CreateWorkersAccountSettingsParameters struct { + DefaultUsageModel string `json:"default_usage_model,omitempty"` + GreenCompute bool `json:"green_compute,omitempty"` +} + +type CreateWorkersAccountSettingsResponse struct { + Response + Result WorkersAccountSettings +} + +type WorkersAccountSettingsParameters struct{} + +type WorkersAccountSettingsResponse struct { + Response + Result WorkersAccountSettings +} + +// CreateWorkersAccountSettings sets the account settings for Workers. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-account-settings-create-worker-account-settings +func (api *API) CreateWorkersAccountSettings(ctx context.Context, rc *ResourceContainer, params CreateWorkersAccountSettingsParameters) (WorkersAccountSettings, error) { + if rc.Identifier == "" { + return WorkersAccountSettings{}, ErrMissingAccountID + } + + if rc.Level != AccountRouteLevel { + return WorkersAccountSettings{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/workers/account-settings", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return WorkersAccountSettings{}, err + } + + var workersAccountSettingsResponse CreateWorkersAccountSettingsResponse + if err := json.Unmarshal(res, &workersAccountSettingsResponse); err != nil { + return WorkersAccountSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return workersAccountSettingsResponse.Result, nil +} + +// WorkersAccountSettings returns the current account settings for Workers. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-account-settings-fetch-worker-account-settings +func (api *API) WorkersAccountSettings(ctx context.Context, rc *ResourceContainer, params WorkersAccountSettingsParameters) (WorkersAccountSettings, error) { + if rc.Identifier == "" { + return WorkersAccountSettings{}, ErrMissingAccountID + } + + if rc.Level != AccountRouteLevel { + return WorkersAccountSettings{}, ErrRequiredAccountLevelResourceContainer + } + + uri := fmt.Sprintf("/accounts/%s/workers/account-settings", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, params) + if err != nil { + return WorkersAccountSettings{}, err + } + + var workersAccountSettingsResponse CreateWorkersAccountSettingsResponse + if err := json.Unmarshal(res, &workersAccountSettingsResponse); err != nil { + return WorkersAccountSettings{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return workersAccountSettingsResponse.Result, nil +} diff --git a/pkg/cloudflare-go/workers_account_settings_test.go b/pkg/cloudflare-go/workers_account_settings_test.go new file mode 100644 index 000000000..0bdd35fd3 --- /dev/null +++ b/pkg/cloudflare-go/workers_account_settings_test.go @@ -0,0 +1,67 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorkersAccountSettings_CreateAccountSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/account-settings", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "default_usage_model": "unbound", + "green_compute": false + } +}`) + }) + res, err := client.CreateWorkersAccountSettings(context.Background(), AccountIdentifier(testAccountID), CreateWorkersAccountSettingsParameters{DefaultUsageModel: "unbound", GreenCompute: false}) + want := WorkersAccountSettings{ + DefaultUsageModel: "unbound", + GreenCompute: false, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersAccountSettings_GetAccountSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/account-settings", testAccountID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "default_usage_model": "unbound", + "green_compute": true + } +}`) + }) + + res, err := client.WorkersAccountSettings(context.Background(), AccountIdentifier(testAccountID), WorkersAccountSettingsParameters{}) + want := WorkersAccountSettings{ + DefaultUsageModel: "unbound", + GreenCompute: true, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} diff --git a/pkg/cloudflare-go/workers_bindings.go b/pkg/cloudflare-go/workers_bindings.go new file mode 100644 index 000000000..0516c5b3f --- /dev/null +++ b/pkg/cloudflare-go/workers_bindings.go @@ -0,0 +1,617 @@ +package cloudflare + +import ( + "context" + rand "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + + "github.com/goccy/go-json" +) + +// WorkerBindingType represents a particular type of binding. +type WorkerBindingType string + +func (b WorkerBindingType) String() string { + return string(b) +} + +const ( + // WorkerDurableObjectBindingType is the type for Durable Object bindings. + WorkerDurableObjectBindingType WorkerBindingType = "durable_object_namespace" + // WorkerInheritBindingType is the type for inherited bindings. + WorkerInheritBindingType WorkerBindingType = "inherit" + // WorkerKvNamespaceBindingType is the type for KV Namespace bindings. + WorkerKvNamespaceBindingType WorkerBindingType = "kv_namespace" + // WorkerWebAssemblyBindingType is the type for Web Assembly module bindings. + WorkerWebAssemblyBindingType WorkerBindingType = "wasm_module" + // WorkerSecretTextBindingType is the type for secret text bindings. + WorkerSecretTextBindingType WorkerBindingType = "secret_text" + // WorkerPlainTextBindingType is the type for plain text bindings. + WorkerPlainTextBindingType WorkerBindingType = "plain_text" + // WorkerServiceBindingType is the type for service bindings. + WorkerServiceBindingType WorkerBindingType = "service" + // WorkerR2BucketBindingType is the type for R2 bucket bindings. + WorkerR2BucketBindingType WorkerBindingType = "r2_bucket" + // WorkerAnalyticsEngineBindingType is the type for Analytics Engine dataset bindings. + WorkerAnalyticsEngineBindingType WorkerBindingType = "analytics_engine" + // WorkerQueueBindingType is the type for queue bindings. + WorkerQueueBindingType WorkerBindingType = "queue" + // DispatchNamespaceBindingType is the type for WFP namespace bindings. + DispatchNamespaceBindingType WorkerBindingType = "dispatch_namespace" + // WorkerD1DataseBindingType is for D1 databases. + WorkerD1DataseBindingType WorkerBindingType = "d1" +) + +type ListWorkerBindingsParams struct { + ScriptName string + DispatchNamespace *string +} + +// WorkerBindingListItem a struct representing an individual binding in a list of bindings. +type WorkerBindingListItem struct { + Name string `json:"name"` + Binding WorkerBinding +} + +// WorkerBindingListResponse wrapper struct for API response to worker binding list API call. +type WorkerBindingListResponse struct { + Response + BindingList []WorkerBindingListItem +} + +// Workers supports multiple types of bindings, e.g. KV namespaces or WebAssembly modules, and each type +// of binding will be represented differently in the upload request body. At a high-level, every binding +// will specify metadata, which is a JSON object with the properties "name" and "type". Some types of bindings +// will also have additional metadata properties. For example, KV bindings also specify the KV namespace. +// In addition to the metadata, some binding types may need to include additional data as part of the +// multipart form. For example, WebAssembly bindings will include the contents of the WebAssembly module. + +// WorkerBinding is the generic interface implemented by all of +// the various binding types. +type WorkerBinding interface { + Type() WorkerBindingType + + // serialize is responsible for returning the binding metadata as well as an optionally + // returning a function that can modify the multipart form body. For example, this is used + // by WebAssembly bindings to add a new part containing the WebAssembly module contents. + serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) +} + +// workerBindingMeta is the metadata portion of the binding. +type workerBindingMeta = map[string]interface{} + +// workerBindingBodyWriter allows for a binding to add additional parts to the multipart body. +type workerBindingBodyWriter func(*multipart.Writer) error + +// WorkerInheritBinding will just persist whatever binding content was previously uploaded. +type WorkerInheritBinding struct { + // Optional parameter that allows for renaming a binding without changing + // its contents. If `OldName` is empty, the binding name will not be changed. + OldName string +} + +// Type returns the type of the binding. +func (b WorkerInheritBinding) Type() WorkerBindingType { + return WorkerInheritBindingType +} + +func (b WorkerInheritBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + meta := workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + } + + if b.OldName != "" { + meta["old_name"] = b.OldName + } + + return meta, nil, nil +} + +// WorkerKvNamespaceBinding is a binding to a Workers KV Namespace. +// +// https://developers.cloudflare.com/workers/archive/api/resource-bindings/kv-namespaces/ +type WorkerKvNamespaceBinding struct { + NamespaceID string +} + +// Type returns the type of the binding. +func (b WorkerKvNamespaceBinding) Type() WorkerBindingType { + return WorkerKvNamespaceBindingType +} + +func (b WorkerKvNamespaceBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.NamespaceID == "" { + return nil, nil, fmt.Errorf(`namespace ID for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "namespace_id": b.NamespaceID, + }, nil, nil +} + +// WorkerDurableObjectBinding is a binding to a Workers Durable Object. +// +// https://api.cloudflare.com/#durable-objects-namespace-properties +type WorkerDurableObjectBinding struct { + ClassName string + ScriptName string +} + +// Type returns the type of the binding. +func (b WorkerDurableObjectBinding) Type() WorkerBindingType { + return WorkerDurableObjectBindingType +} + +func (b WorkerDurableObjectBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.ClassName == "" { + return nil, nil, fmt.Errorf(`ClassName for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "class_name": b.ClassName, + "script_name": b.ScriptName, + }, nil, nil +} + +// WorkerWebAssemblyBinding is a binding to a WebAssembly module. +// +// https://developers.cloudflare.com/workers/archive/api/resource-bindings/webassembly-modules/ +type WorkerWebAssemblyBinding struct { + Module io.Reader +} + +// Type returns the type of the binding. +func (b WorkerWebAssemblyBinding) Type() WorkerBindingType { + return WorkerWebAssemblyBindingType +} + +func (b WorkerWebAssemblyBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + partName := getRandomPartName() + + bodyWriter := func(mpw *multipart.Writer) error { + var hdr = textproto.MIMEHeader{} + hdr.Set("content-disposition", fmt.Sprintf(`form-data; name="%s"`, partName)) + hdr.Set("content-type", "application/wasm") + pw, err := mpw.CreatePart(hdr) + if err != nil { + return err + } + _, err = io.Copy(pw, b.Module) + return err + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "part": partName, + }, bodyWriter, nil +} + +// WorkerPlainTextBinding is a binding to plain text. +// +// https://developers.cloudflare.com/workers/tooling/api/scripts/#add-a-plain-text-binding +type WorkerPlainTextBinding struct { + Text string +} + +// Type returns the type of the binding. +func (b WorkerPlainTextBinding) Type() WorkerBindingType { + return WorkerPlainTextBindingType +} + +func (b WorkerPlainTextBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.Text == "" { + return nil, nil, fmt.Errorf(`text for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "text": b.Text, + }, nil, nil +} + +// WorkerSecretTextBinding is a binding to secret text. +// +// https://developers.cloudflare.com/workers/tooling/api/scripts/#add-a-secret-text-binding +type WorkerSecretTextBinding struct { + Text string +} + +// Type returns the type of the binding. +func (b WorkerSecretTextBinding) Type() WorkerBindingType { + return WorkerSecretTextBindingType +} + +func (b WorkerSecretTextBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.Text == "" { + return nil, nil, fmt.Errorf(`text for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "text": b.Text, + }, nil, nil +} + +type WorkerServiceBinding struct { + Service string + Environment *string +} + +func (b WorkerServiceBinding) Type() WorkerBindingType { + return WorkerServiceBindingType +} + +func (b WorkerServiceBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.Service == "" { + return nil, nil, fmt.Errorf(`service for binding "%s" cannot be empty`, bindingName) + } + + meta := workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "service": b.Service, + } + + if b.Environment != nil { + meta["environment"] = *b.Environment + } + + return meta, nil, nil +} + +// WorkerR2BucketBinding is a binding to an R2 bucket. +type WorkerR2BucketBinding struct { + BucketName string +} + +// Type returns the type of the binding. +func (b WorkerR2BucketBinding) Type() WorkerBindingType { + return WorkerR2BucketBindingType +} + +func (b WorkerR2BucketBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.BucketName == "" { + return nil, nil, fmt.Errorf(`BucketName for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "bucket_name": b.BucketName, + }, nil, nil +} + +// WorkerAnalyticsEngineBinding is a binding to an Analytics Engine dataset. +type WorkerAnalyticsEngineBinding struct { + Dataset string +} + +// Type returns the type of the binding. +func (b WorkerAnalyticsEngineBinding) Type() WorkerBindingType { + return WorkerAnalyticsEngineBindingType +} + +func (b WorkerAnalyticsEngineBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.Dataset == "" { + return nil, nil, fmt.Errorf(`dataset for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "dataset": b.Dataset, + }, nil, nil +} + +// WorkerQueueBinding is a binding to a Workers Queue. +// +// https://developers.cloudflare.com/workers/platform/bindings/#queue-bindings +type WorkerQueueBinding struct { + Binding string + Queue string +} + +// Type returns the type of the binding. +func (b WorkerQueueBinding) Type() WorkerBindingType { + return WorkerQueueBindingType +} + +func (b WorkerQueueBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.Binding == "" { + return nil, nil, fmt.Errorf(`binding name for binding "%s" cannot be empty`, bindingName) + } + if b.Queue == "" { + return nil, nil, fmt.Errorf(`queue name for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "type": b.Type(), + "name": b.Binding, + "queue_name": b.Queue, + }, nil, nil +} + +// DispatchNamespaceBinding is a binding to a Workers for Platforms namespace +// +// https://developers.cloudflare.com/workers/platform/bindings/#dispatch-namespace-bindings-workers-for-platforms +type DispatchNamespaceBinding struct { + Binding string + Namespace string + Outbound *NamespaceOutboundOptions +} + +type NamespaceOutboundOptions struct { + Worker WorkerReference + Params []OutboundParamSchema +} + +type WorkerReference struct { + Service string + Environment *string +} + +type OutboundParamSchema struct { + Name string +} + +// Type returns the type of the binding. +func (b DispatchNamespaceBinding) Type() WorkerBindingType { + return DispatchNamespaceBindingType +} + +func (b DispatchNamespaceBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.Binding == "" { + return nil, nil, fmt.Errorf(`binding name for binding "%s" cannot be empty`, bindingName) + } + if b.Namespace == "" { + return nil, nil, fmt.Errorf(`namespace name for binding "%s" cannot be empty`, bindingName) + } + + meta := workerBindingMeta{ + "type": b.Type(), + "name": b.Binding, + "namespace": b.Namespace, + } + + if b.Outbound != nil { + if b.Outbound.Worker.Service == "" { + return nil, nil, fmt.Errorf(`outbound options for binding "%s" must have a service name`, bindingName) + } + + var params []map[string]interface{} + for _, param := range b.Outbound.Params { + params = append(params, map[string]interface{}{ + "name": param.Name, + }) + } + + meta["outbound"] = map[string]interface{}{ + "worker": map[string]interface{}{ + "service": b.Outbound.Worker.Service, + "environment": b.Outbound.Worker.Environment, + }, + "params": params, + } + } + + return meta, nil, nil +} + +// WorkerD1DatabaseBinding is a binding to a D1 instance. +type WorkerD1DatabaseBinding struct { + DatabaseID string +} + +// Type returns the type of the binding. +func (b WorkerD1DatabaseBinding) Type() WorkerBindingType { + return WorkerD1DataseBindingType +} + +func (b WorkerD1DatabaseBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + if b.DatabaseID == "" { + return nil, nil, fmt.Errorf(`database ID for binding "%s" cannot be empty`, bindingName) + } + + return workerBindingMeta{ + "name": bindingName, + "type": b.Type(), + "id": b.DatabaseID, + }, nil, nil +} + +// UnsafeBinding is for experimental or deprecated bindings, and allows specifying any binding type or property. +type UnsafeBinding map[string]interface{} + +// Type returns the type of the binding. +func (b UnsafeBinding) Type() WorkerBindingType { + return "" +} + +func (b UnsafeBinding) serialize(bindingName string) (workerBindingMeta, workerBindingBodyWriter, error) { + b["name"] = bindingName + return b, nil, nil +} + +// Each binding that adds a part to the multipart form body will need +// a unique part name so we just generate a random 128bit hex string. +func getRandomPartName() string { + randBytes := make([]byte, 16) + rand.Read(randBytes) //nolint:errcheck + return hex.EncodeToString(randBytes) +} + +// ListWorkerBindings returns all the bindings for a particular worker. +func (api *API) ListWorkerBindings(ctx context.Context, rc *ResourceContainer, params ListWorkerBindingsParams) (WorkerBindingListResponse, error) { + if params.ScriptName == "" { + return WorkerBindingListResponse{}, errors.New("script name is required") + } + + if rc.Level != AccountRouteLevel { + return WorkerBindingListResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkerBindingListResponse{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/bindings", rc.Identifier, params.ScriptName) + if params.DispatchNamespace != nil && *params.DispatchNamespace != "" { + uri = fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s/scripts/%s/bindings", rc.Identifier, *params.DispatchNamespace, params.ScriptName) + } + + var jsonRes struct { + Response + Bindings []workerBindingMeta `json:"result"` + } + var r WorkerBindingListResponse + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return r, err + } + err = json.Unmarshal(res, &jsonRes) + if err != nil { + return r, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + r = WorkerBindingListResponse{ + Response: jsonRes.Response, + BindingList: make([]WorkerBindingListItem, 0, len(jsonRes.Bindings)), + } + for _, jsonBinding := range jsonRes.Bindings { + name, ok := jsonBinding["name"].(string) + if !ok { + return r, fmt.Errorf("binding missing name %v", jsonBinding) + } + bType, ok := jsonBinding["type"].(string) + if !ok { + return r, fmt.Errorf("binding missing type %v", jsonBinding) + } + bindingListItem := WorkerBindingListItem{ + Name: name, + } + + switch WorkerBindingType(bType) { + case WorkerDurableObjectBindingType: + class_name := jsonBinding["class_name"].(string) + script_name := jsonBinding["script_name"].(string) + bindingListItem.Binding = WorkerDurableObjectBinding{ + ClassName: class_name, + ScriptName: script_name, + } + case WorkerKvNamespaceBindingType: + namespaceID := jsonBinding["namespace_id"].(string) + bindingListItem.Binding = WorkerKvNamespaceBinding{ + NamespaceID: namespaceID, + } + case WorkerQueueBindingType: + queueName := jsonBinding["queue_name"].(string) + bindingListItem.Binding = WorkerQueueBinding{ + Binding: name, + Queue: queueName, + } + case WorkerWebAssemblyBindingType: + bindingListItem.Binding = WorkerWebAssemblyBinding{ + Module: &bindingContentReader{ + api: api, + ctx: ctx, + accountID: rc.Identifier, + params: ¶ms, + bindingName: name, + }, + } + case WorkerPlainTextBindingType: + text := jsonBinding["text"].(string) + bindingListItem.Binding = WorkerPlainTextBinding{ + Text: text, + } + case WorkerServiceBindingType: + service := jsonBinding["service"].(string) + environment := jsonBinding["environment"].(string) + bindingListItem.Binding = WorkerServiceBinding{ + Service: service, + Environment: &environment, + } + case WorkerSecretTextBindingType: + bindingListItem.Binding = WorkerSecretTextBinding{} + case WorkerR2BucketBindingType: + bucketName := jsonBinding["bucket_name"].(string) + bindingListItem.Binding = WorkerR2BucketBinding{ + BucketName: bucketName, + } + case WorkerAnalyticsEngineBindingType: + dataset := jsonBinding["dataset"].(string) + bindingListItem.Binding = WorkerAnalyticsEngineBinding{ + Dataset: dataset, + } + case WorkerD1DataseBindingType: + database_id := jsonBinding["database_id"].(string) + bindingListItem.Binding = WorkerD1DatabaseBinding{ + DatabaseID: database_id, + } + default: + bindingListItem.Binding = WorkerInheritBinding{} + } + r.BindingList = append(r.BindingList, bindingListItem) + } + + return r, nil +} + +// bindingContentReader is an io.Reader that will lazily load the +// raw bytes for a binding from the API when the Read() method +// is first called. This is only useful for binding types +// that store raw bytes, like WebAssembly modules. +type bindingContentReader struct { + api *API + accountID string + params *ListWorkerBindingsParams + ctx context.Context + bindingName string + content []byte + position int +} + +func (b *bindingContentReader) Read(p []byte) (n int, err error) { + // Lazily load the content when Read() is first called + if b.content == nil { + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/bindings/%s/content", b.accountID, b.params.ScriptName, b.bindingName) + res, err := b.api.makeRequestContext(b.ctx, http.MethodGet, uri, nil) + if err != nil { + return 0, err + } + b.content = res + } + + if b.position >= len(b.content) { + return 0, io.EOF + } + + bytesRemaining := len(b.content) - b.position + bytesToProcess := 0 + if len(p) < bytesRemaining { + bytesToProcess = len(p) + } else { + bytesToProcess = bytesRemaining + } + + for i := 0; i < bytesToProcess; i++ { + p[i] = b.content[b.position] + b.position = b.position + 1 + } + + return bytesToProcess, nil +} diff --git a/pkg/cloudflare-go/workers_bindings_test.go b/pkg/cloudflare-go/workers_bindings_test.go new file mode 100644 index 000000000..bb7f141fa --- /dev/null +++ b/pkg/cloudflare-go/workers_bindings_test.go @@ -0,0 +1,237 @@ +package cloudflare + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" +) + +func TestListWorkerBindings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/my-script/bindings", 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, listBindingsResponseData) + }) + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/my-script/bindings/MY_WASM/content", 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/wasm") + _, _ = w.Write([]byte("mock multi-script wasm")) + }) + + res, err := client.ListWorkerBindings(context.Background(), AccountIdentifier(testAccountID), ListWorkerBindingsParams{ + ScriptName: "my-script", + }) + assert.NoError(t, err) + + assert.Equal(t, successResponse, res.Response) + assert.Equal(t, 9, len(res.BindingList)) + + assert.Equal(t, res.BindingList[0], WorkerBindingListItem{ + Name: "MY_KV", + Binding: WorkerKvNamespaceBinding{ + NamespaceID: "89f5f8fd93f94cb98473f6f421aa3b65", + }, + }) + assert.Equal(t, WorkerKvNamespaceBindingType, res.BindingList[0].Binding.Type()) + + assert.Equal(t, "MY_WASM", res.BindingList[1].Name) + wasmBinding := res.BindingList[1].Binding.(WorkerWebAssemblyBinding) + wasmModuleContent, err := io.ReadAll(wasmBinding.Module) + assert.NoError(t, err) + assert.Equal(t, []byte("mock multi-script wasm"), wasmModuleContent) + assert.Equal(t, WorkerWebAssemblyBindingType, res.BindingList[1].Binding.Type()) + + assert.Equal(t, res.BindingList[2], WorkerBindingListItem{ + Name: "MY_PLAIN_TEXT", + Binding: WorkerPlainTextBinding{ + Text: "text", + }, + }) + assert.Equal(t, WorkerPlainTextBindingType, res.BindingList[2].Binding.Type()) + + assert.Equal(t, res.BindingList[3], WorkerBindingListItem{ + Name: "MY_SECRET_TEXT", + Binding: WorkerSecretTextBinding{}, + }) + assert.Equal(t, WorkerSecretTextBindingType, res.BindingList[3].Binding.Type()) + + environment := "MY_ENVIRONMENT" + assert.Equal(t, res.BindingList[4], WorkerBindingListItem{ + Name: "MY_SERVICE_BINDING", + Binding: WorkerServiceBinding{ + Service: "MY_SERVICE", + Environment: &environment, + }, + }) + assert.Equal(t, WorkerServiceBindingType, res.BindingList[4].Binding.Type()) + + assert.Equal(t, res.BindingList[5], WorkerBindingListItem{ + Name: "MY_NEW_BINDING", + Binding: WorkerInheritBinding{}, + }) + assert.Equal(t, WorkerInheritBindingType, res.BindingList[5].Binding.Type()) + + assert.Equal(t, res.BindingList[6], WorkerBindingListItem{ + Name: "MY_BUCKET", + Binding: WorkerR2BucketBinding{ + BucketName: "bucket", + }, + }) + assert.Equal(t, WorkerR2BucketBindingType, res.BindingList[6].Binding.Type()) + + assert.Equal(t, res.BindingList[7], WorkerBindingListItem{ + Name: "MY_DATASET", + Binding: WorkerAnalyticsEngineBinding{ + Dataset: "my_dataset", + }, + }) + + assert.Equal(t, WorkerAnalyticsEngineBindingType, res.BindingList[7].Binding.Type()) + + assert.Equal(t, res.BindingList[8], WorkerBindingListItem{ + Name: "MY_DATABASE", + Binding: WorkerD1DatabaseBinding{ + DatabaseID: "cef5331f-e5c7-4c8a-a415-7908ae45f92a", + }, + }) + assert.Equal(t, WorkerD1DataseBindingType, res.BindingList[8].Binding.Type()) +} + +func TestListWorkerBindings_Wfp(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces/my-namespace/scripts/my-script/bindings", 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, listBindingsResponseData) + }) + + res, err := client.ListWorkerBindings(context.Background(), AccountIdentifier(testAccountID), ListWorkerBindingsParams{ + ScriptName: "my-script", + DispatchNamespace: &[]string{"my-namespace"}[0], + }) + assert.NoError(t, err) + + assert.Equal(t, successResponse, res.Response) + assert.Equal(t, 9, len(res.BindingList)) + + assert.Equal(t, res.BindingList[0], WorkerBindingListItem{ + Name: "MY_KV", + Binding: WorkerKvNamespaceBinding{ + NamespaceID: "89f5f8fd93f94cb98473f6f421aa3b65", + }, + }) + assert.Equal(t, WorkerKvNamespaceBindingType, res.BindingList[0].Binding.Type()) + + // WASM binding - No binding content endpoint exists for WfP + + assert.Equal(t, res.BindingList[2], WorkerBindingListItem{ + Name: "MY_PLAIN_TEXT", + Binding: WorkerPlainTextBinding{ + Text: "text", + }, + }) + assert.Equal(t, WorkerPlainTextBindingType, res.BindingList[2].Binding.Type()) + + assert.Equal(t, res.BindingList[3], WorkerBindingListItem{ + Name: "MY_SECRET_TEXT", + Binding: WorkerSecretTextBinding{}, + }) + assert.Equal(t, WorkerSecretTextBindingType, res.BindingList[3].Binding.Type()) + + environment := "MY_ENVIRONMENT" + assert.Equal(t, res.BindingList[4], WorkerBindingListItem{ + Name: "MY_SERVICE_BINDING", + Binding: WorkerServiceBinding{ + Service: "MY_SERVICE", + Environment: &environment, + }, + }) + assert.Equal(t, WorkerServiceBindingType, res.BindingList[4].Binding.Type()) + + assert.Equal(t, res.BindingList[5], WorkerBindingListItem{ + Name: "MY_NEW_BINDING", + Binding: WorkerInheritBinding{}, + }) + assert.Equal(t, WorkerInheritBindingType, res.BindingList[5].Binding.Type()) + + assert.Equal(t, res.BindingList[6], WorkerBindingListItem{ + Name: "MY_BUCKET", + Binding: WorkerR2BucketBinding{ + BucketName: "bucket", + }, + }) + assert.Equal(t, WorkerR2BucketBindingType, res.BindingList[6].Binding.Type()) + + assert.Equal(t, res.BindingList[7], WorkerBindingListItem{ + Name: "MY_DATASET", + Binding: WorkerAnalyticsEngineBinding{ + Dataset: "my_dataset", + }, + }) + + assert.Equal(t, WorkerAnalyticsEngineBindingType, res.BindingList[7].Binding.Type()) + + assert.Equal(t, res.BindingList[8], WorkerBindingListItem{ + Name: "MY_DATABASE", + Binding: WorkerD1DatabaseBinding{ + DatabaseID: "cef5331f-e5c7-4c8a-a415-7908ae45f92a", + }, + }) + assert.Equal(t, WorkerD1DataseBindingType, res.BindingList[8].Binding.Type()) +} + +func ExampleUnsafeBinding() { + pretty := func(meta workerBindingMeta) string { + buf := bytes.NewBufferString("") + encoder := json.NewEncoder(buf) + encoder.SetIndent("", " ") + if err := encoder.Encode(meta); err != nil { + fmt.Println("error:", err) + } + return buf.String() + } + + binding_a := WorkerServiceBinding{ + Service: "foo", + } + meta_a, _, _ := binding_a.serialize("my_binding") + meta_a_json := pretty(meta_a) + fmt.Println(meta_a_json) + + binding_b := UnsafeBinding{ + "type": "service", + "service": "foo", + } + meta_b, _, _ := binding_b.serialize("my_binding") + meta_b_json := pretty(meta_b) + fmt.Println(meta_b_json) + + fmt.Println(meta_a_json == meta_b_json) + // Output: + // { + // "name": "my_binding", + // "service": "foo", + // "type": "service" + // } + // + // { + // "name": "my_binding", + // "service": "foo", + // "type": "service" + // } + // + // true +} diff --git a/pkg/cloudflare-go/workers_cron_triggers.go b/pkg/cloudflare-go/workers_cron_triggers.go new file mode 100644 index 000000000..c231c994c --- /dev/null +++ b/pkg/cloudflare-go/workers_cron_triggers.go @@ -0,0 +1,91 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// WorkerCronTriggerResponse represents the response from the Worker cron trigger +// API endpoint. +type WorkerCronTriggerResponse struct { + Response + Result WorkerCronTriggerSchedules `json:"result"` +} + +// WorkerCronTriggerSchedules contains the schedule of Worker cron triggers. +type WorkerCronTriggerSchedules struct { + Schedules []WorkerCronTrigger `json:"schedules"` +} + +// WorkerCronTrigger holds an individual cron schedule for a worker. +type WorkerCronTrigger struct { + Cron string `json:"cron"` + CreatedOn *time.Time `json:"created_on,omitempty"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` +} + +type ListWorkerCronTriggersParams struct { + ScriptName string +} + +type UpdateWorkerCronTriggersParams struct { + ScriptName string + Crons []WorkerCronTrigger +} + +// ListWorkerCronTriggers fetches all available cron triggers for a single Worker +// script. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-cron-trigger-get-cron-triggers +func (api *API) ListWorkerCronTriggers(ctx context.Context, rc *ResourceContainer, params ListWorkerCronTriggersParams) ([]WorkerCronTrigger, error) { + if rc.Level != AccountRouteLevel { + return []WorkerCronTrigger{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return []WorkerCronTrigger{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/schedules", rc.Identifier, params.ScriptName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WorkerCronTrigger{}, err + } + + result := WorkerCronTriggerResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []WorkerCronTrigger{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.Schedules, err +} + +// UpdateWorkerCronTriggers updates a single schedule for a Worker cron trigger. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-cron-trigger-update-cron-triggers +func (api *API) UpdateWorkerCronTriggers(ctx context.Context, rc *ResourceContainer, params UpdateWorkerCronTriggersParams) ([]WorkerCronTrigger, error) { + if rc.Level != AccountRouteLevel { + return []WorkerCronTrigger{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return []WorkerCronTrigger{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/schedules", rc.Identifier, params.ScriptName) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Crons) + if err != nil { + return []WorkerCronTrigger{}, err + } + + result := WorkerCronTriggerResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return []WorkerCronTrigger{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result.Result.Schedules, err +} diff --git a/pkg/cloudflare-go/workers_cron_triggers_test.go b/pkg/cloudflare-go/workers_cron_triggers_test.go new file mode 100644 index 000000000..0194d75ef --- /dev/null +++ b/pkg/cloudflare-go/workers_cron_triggers_test.go @@ -0,0 +1,87 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestListWorkerCronTriggers(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "schedules": [ + { + "cron": "*/30 * * * *", + "created_on": "2017-01-01T00:00:00Z", + "modified_on": "2017-01-01T00:00:00Z" + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/example-script/schedules", handler) + createdOn, _ := time.Parse(time.RFC3339, "2017-01-01T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-01-01T00:00:00Z") + want := []WorkerCronTrigger{{ + Cron: "*/30 * * * *", + ModifiedOn: &modifiedOn, + CreatedOn: &createdOn, + }} + + actual, err := client.ListWorkerCronTriggers(context.Background(), AccountIdentifier(testAccountID), ListWorkerCronTriggersParams{ScriptName: "example-script"}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateWorkerCronTriggers(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "schedules": [ + { + "cron": "*/30 * * * *", + "created_on": "2017-01-01T00:00:00Z", + "modified_on": "2017-01-01T00:00:00Z" + } + ] + } + }`) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/example-script/schedules", handler) + createdOn, _ := time.Parse(time.RFC3339, "2017-01-01T00:00:00Z") + modifiedOn, _ := time.Parse(time.RFC3339, "2017-01-01T00:00:00Z") + want := []WorkerCronTrigger{{ + Cron: "*/30 * * * *", + ModifiedOn: &modifiedOn, + CreatedOn: &createdOn, + }} + + actual, err := client.UpdateWorkerCronTriggers(context.Background(), AccountIdentifier(testAccountID), UpdateWorkerCronTriggersParams{ScriptName: "example-script", Crons: want}) + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} diff --git a/pkg/cloudflare-go/workers_domain.go b/pkg/cloudflare-go/workers_domain.go new file mode 100644 index 000000000..a70954284 --- /dev/null +++ b/pkg/cloudflare-go/workers_domain.go @@ -0,0 +1,151 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingHostname = errors.New("required hostname missing") + ErrMissingService = errors.New("required service missing") + ErrMissingEnvironment = errors.New("required environment missing") +) + +type AttachWorkersDomainParams struct { + ID string `json:"id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + Hostname string `json:"hostname,omitempty"` + Service string `json:"service,omitempty"` + Environment string `json:"environment,omitempty"` +} + +type WorkersDomain struct { + ID string `json:"id,omitempty"` + ZoneID string `json:"zone_id,omitempty"` + ZoneName string `json:"zone_name,omitempty"` + Hostname string `json:"hostname,omitempty"` + Service string `json:"service,omitempty"` + Environment string `json:"environment,omitempty"` +} + +type WorkersDomainResponse struct { + Response + Result WorkersDomain `json:"result"` +} + +type ListWorkersDomainParams struct { + ZoneID string `url:"zone_id,omitempty"` + ZoneName string `url:"zone_name,omitempty"` + Hostname string `url:"hostname,omitempty"` + Service string `url:"service,omitempty"` + Environment string `url:"environment,omitempty"` +} + +type WorkersDomainListResponse struct { + Response + Result []WorkersDomain `json:"result"` +} + +// ListWorkersDomains lists all Worker Domains. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-domain-list-domains +func (api *API) ListWorkersDomains(ctx context.Context, rc *ResourceContainer, params ListWorkersDomainParams) ([]WorkersDomain, error) { + if rc.Identifier == "" { + return []WorkersDomain{}, ErrMissingAccountID + } + + uri := buildURI(fmt.Sprintf("/accounts/%s/workers/domains", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WorkersDomain{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r WorkersDomainListResponse + if err := json.Unmarshal(res, &r); err != nil { + return []WorkersDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// AttachWorkersDomain attaches a worker to a zone and hostname. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-domain-attach-to-domain +func (api *API) AttachWorkersDomain(ctx context.Context, rc *ResourceContainer, domain AttachWorkersDomainParams) (WorkersDomain, error) { + if rc.Identifier == "" { + return WorkersDomain{}, ErrMissingAccountID + } + + if domain.ZoneID == "" { + return WorkersDomain{}, ErrMissingZoneID + } + + if domain.Hostname == "" { + return WorkersDomain{}, ErrMissingHostname + } + + if domain.Service == "" { + return WorkersDomain{}, ErrMissingService + } + + if domain.Environment == "" { + return WorkersDomain{}, ErrMissingEnvironment + } + + uri := fmt.Sprintf("/accounts/%s/workers/domains", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, domain) + if err != nil { + return WorkersDomain{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r WorkersDomainResponse + if err := json.Unmarshal(res, &r); err != nil { + return WorkersDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// GetWorkersDomain gets a single Worker Domain. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-domain-get-a-domain +func (api *API) GetWorkersDomain(ctx context.Context, rc *ResourceContainer, domainID string) (WorkersDomain, error) { + if rc.Identifier == "" { + return WorkersDomain{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/domains/%s", rc.Identifier, domainID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkersDomain{}, fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + var r WorkersDomainResponse + if err := json.Unmarshal(res, &r); err != nil { + return WorkersDomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// DetachWorkersDomain detaches a worker from a zone and hostname. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-domain-detach-from-domain +func (api *API) DetachWorkersDomain(ctx context.Context, rc *ResourceContainer, domainID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/domains/%s", rc.Identifier, domainID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return fmt.Errorf("%s: %w", errMakeRequestError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/workers_domain_test.go b/pkg/cloudflare-go/workers_domain_test.go new file mode 100644 index 000000000..443929f28 --- /dev/null +++ b/pkg/cloudflare-go/workers_domain_test.go @@ -0,0 +1,181 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testWorkerDomainID = "dbe10b4bc17c295377eabd600e1787fd" + +var expectedWorkerDomain = WorkersDomain{ + ID: testWorkerDomainID, + ZoneID: "593c9c94de529bbbfaac7c53ced0447d", + ZoneName: "example.com", + Hostname: "foo.example.com", + Service: "foo", + Environment: "production", +} + +func TestWorkersDomain_GetDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/domains/%s", testAccountID, testWorkerDomainID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "dbe10b4bc17c295377eabd600e1787fd", + "zone_id": "593c9c94de529bbbfaac7c53ced0447d", + "zone_name": "example.com", + "hostname": "foo.example.com", + "service": "foo", + "environment": "production" + } + }`) + }) + _, err := client.GetWorkersDomain(context.Background(), AccountIdentifier(""), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + res, err := client.GetWorkersDomain(context.Background(), AccountIdentifier(testAccountID), testWorkerDomainID) + if assert.NoError(t, err) { + assert.Equal(t, expectedWorkerDomain, res) + } +} + +func TestWorkersDomain_ListDomains(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/domains", testAccountID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "id": "dbe10b4bc17c295377eabd600e1787fd", + "zone_id": "593c9c94de529bbbfaac7c53ced0447d", + "zone_name": "example.com", + "hostname": "foo.example.com", + "service": "foo", + "environment": "production" + } + ] + }`) + }) + _, err := client.ListWorkersDomains(context.Background(), AccountIdentifier(""), ListWorkersDomainParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + res, err := client.ListWorkersDomains(context.Background(), AccountIdentifier(testAccountID), ListWorkersDomainParams{}) + if assert.NoError(t, err) { + assert.Equal(t, 1, len(res)) + assert.Equal(t, expectedWorkerDomain, res[0]) + } +} + +func TestWorkersDomain_AttachDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/domains", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "dbe10b4bc17c295377eabd600e1787fd", + "zone_id": "593c9c94de529bbbfaac7c53ced0447d", + "zone_name": "example.com", + "hostname": "foo.example.com", + "service": "foo", + "environment": "production" + } + }`) + }) + _, err := client.AttachWorkersDomain(context.Background(), AccountIdentifier(""), AttachWorkersDomainParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + _, err = client.AttachWorkersDomain(context.Background(), AccountIdentifier(testAccountID), AttachWorkersDomainParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingZoneID, err) + } + + _, err = client.AttachWorkersDomain(context.Background(), AccountIdentifier(testAccountID), AttachWorkersDomainParams{ + ZoneID: testZoneID, + }) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingHostname, err) + } + + _, err = client.AttachWorkersDomain(context.Background(), AccountIdentifier(testAccountID), AttachWorkersDomainParams{ + ZoneID: testZoneID, + Hostname: "foo.example.com", + }) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingService, err) + } + + _, err = client.AttachWorkersDomain(context.Background(), AccountIdentifier(testAccountID), AttachWorkersDomainParams{ + ZoneID: testZoneID, + Hostname: "foo.example.com", + Service: "foo", + }) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingEnvironment, err) + } + + res, err := client.AttachWorkersDomain(context.Background(), AccountIdentifier(testAccountID), AttachWorkersDomainParams{ + ZoneID: testZoneID, + Hostname: "foo.example.com", + Service: "foo", + Environment: "production", + }) + if assert.NoError(t, err) { + assert.Equal(t, expectedWorkerDomain, res) + } +} + +func TestWorkersDomain_DetachDomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/domains/%s", testAccountID, testWorkerDomainID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "dbe10b4bc17c295377eabd600e1787fd", + "zone_id": "593c9c94de529bbbfaac7c53ced0447d", + "zone_name": "example.com", + "hostname": "foo.example.com", + "service": "foo", + "environment": "production" + } + }`) + }) + err := client.DetachWorkersDomain(context.Background(), AccountIdentifier(""), testWorkerDomainID) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + err = client.DetachWorkersDomain(context.Background(), AccountIdentifier(testAccountID), testWorkerDomainID) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/workers_for_platforms.go b/pkg/cloudflare-go/workers_for_platforms.go new file mode 100644 index 000000000..eda77eaee --- /dev/null +++ b/pkg/cloudflare-go/workers_for_platforms.go @@ -0,0 +1,139 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type WorkersForPlatformsDispatchNamespace struct { + NamespaceId string `json:"namespace_id"` + NamespaceName string `json:"namespace_name"` + CreatedOn *time.Time `json:"created_on,omitempty"` + CreatedBy string `json:"created_by"` + ModifiedOn *time.Time `json:"modified_on,omitempty"` + ModifiedBy string `json:"modified_by"` +} + +type ListWorkersForPlatformsDispatchNamespaceResponse struct { + Response + Result []WorkersForPlatformsDispatchNamespace `json:"result"` +} + +type GetWorkersForPlatformsDispatchNamespaceResponse struct { + Response + Result WorkersForPlatformsDispatchNamespace `json:"result"` +} + +type CreateWorkersForPlatformsDispatchNamespaceParams struct { + Name string `json:"name"` +} + +// ListWorkersForPlatformsDispatchNamespaces lists the dispatch namespaces. +// +// API reference: https://developers.cloudflare.com/api/operations/namespace-worker-list +func (api *API) ListWorkersForPlatformsDispatchNamespaces(ctx context.Context, rc *ResourceContainer) (*ListWorkersForPlatformsDispatchNamespaceResponse, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return nil, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + var r ListWorkersForPlatformsDispatchNamespaceResponse + if err != nil { + return nil, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &r, nil +} + +// GetWorkersForPlatformsDispatchNamespace gets a specific dispatch namespace. +// +// API reference: https://developers.cloudflare.com/api/operations/namespace-worker-get-namespace +func (api *API) GetWorkersForPlatformsDispatchNamespace(ctx context.Context, rc *ResourceContainer, name string) (*GetWorkersForPlatformsDispatchNamespaceResponse, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return nil, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s", rc.Identifier, name) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + + var r GetWorkersForPlatformsDispatchNamespaceResponse + if err != nil { + return nil, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &r, nil +} + +// CreateWorkersForPlatformsDispatchNamespace creates a new dispatch namespace. +// +// API reference: https://developers.cloudflare.com/api/operations/namespace-worker-create +func (api *API) CreateWorkersForPlatformsDispatchNamespace(ctx context.Context, rc *ResourceContainer, params CreateWorkersForPlatformsDispatchNamespaceParams) (*GetWorkersForPlatformsDispatchNamespaceResponse, error) { + if rc.Level != AccountRouteLevel { + return nil, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return nil, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + + var r GetWorkersForPlatformsDispatchNamespaceResponse + if err != nil { + return nil, err + } + + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return &r, nil +} + +// DeleteWorkersForPlatformsDispatchNamespace deletes a dispatch namespace. +// +// API reference: https://developers.cloudflare.com/api/operations/namespace-worker-delete-namespace +func (api *API) DeleteWorkersForPlatformsDispatchNamespace(ctx context.Context, rc *ResourceContainer, name string) error { + if rc.Level != AccountRouteLevel { + return ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/dispatch/namespaces/%s", rc.Identifier, name) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/workers_for_platforms_test.go b/pkg/cloudflare-go/workers_for_platforms_test.go new file mode 100644 index 000000000..720fcc0b9 --- /dev/null +++ b/pkg/cloudflare-go/workers_for_platforms_test.go @@ -0,0 +1,140 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + listDispatchNamespaces = `{ + "result": [ + { + "namespace_id": "6446f71d-13b3-4bbc-a8a4-9e18760499c8", + "namespace_name": "test", + "created_on": "2024-02-20T17:26:15.4134Z", + "created_by": "4e599df4216133509abaac54b109a647", + "modified_on": "2024-02-20T17:26:15.4134Z", + "modified_by": "4e599df4216133509abaac54b109a647" + }, + { + "namespace_id": "d6851dad-d412-4509-ae13-a364bc5f125a", + "namespace_name": "test-2", + "created_on": "2024-02-20T20:28:36.560575Z", + "created_by": "4e599df4216133509abaac54b109a647", + "modified_on": "2024-02-20T20:28:36.560575Z", + "modified_by": "4e599df4216133509abaac54b109a647" + } + ], + "success": true, + "errors": [], + "messages": [] +}` + + getDispatchNamespace = `{ + "result": { + "namespace_id": "6446f71d-13b3-4bbc-a8a4-9e18760499c8", + "namespace_name": "test", + "created_on": "2024-02-20T17:26:15.4134Z", + "created_by": "4e599df4216133509abaac54b109a647", + "modified_on": "2024-02-20T17:26:15.4134Z", + "modified_by": "4e599df4216133509abaac54b109a647" + }, + "success": true, + "errors": [], + "messages": [] +}` + + deleteDispatchNamespace = `{ + "result": null, + "success": true, + "errors": [], + "messages": [] +}` +) + +func TestListWorkersForPlatformsDispatchNamespaces(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces", 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, listDispatchNamespaces) + }) + + res, err := client.ListWorkersForPlatformsDispatchNamespaces(context.Background(), AccountIdentifier(testAccountID)) + + assert.NoError(t, err) + assert.Len(t, res.Result, 2) + + assert.Equal(t, "6446f71d-13b3-4bbc-a8a4-9e18760499c8", res.Result[0].NamespaceId) + assert.Equal(t, "test", res.Result[0].NamespaceName) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result[0].CreatedBy) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result[0].ModifiedBy) + + assert.Equal(t, "d6851dad-d412-4509-ae13-a364bc5f125a", res.Result[1].NamespaceId) + assert.Equal(t, "test-2", res.Result[1].NamespaceName) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result[1].CreatedBy) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result[1].ModifiedBy) +} + +func TestGetWorkersForPlatformsDispatchNamespace(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces/test", 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, getDispatchNamespace) + }) + + res, err := client.GetWorkersForPlatformsDispatchNamespace(context.Background(), AccountIdentifier(testAccountID), "test") + + assert.NoError(t, err) + + assert.Equal(t, "6446f71d-13b3-4bbc-a8a4-9e18760499c8", res.Result.NamespaceId) + assert.Equal(t, "test", res.Result.NamespaceName) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result.CreatedBy) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result.ModifiedBy) +} + +func TestCreateWorkersForPlatformsDispatchNamespace(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, getDispatchNamespace) + }) + + res, err := client.CreateWorkersForPlatformsDispatchNamespace(context.Background(), AccountIdentifier(testAccountID), CreateWorkersForPlatformsDispatchNamespaceParams{ + Name: "test", + }) + + assert.NoError(t, err) + + assert.Equal(t, "6446f71d-13b3-4bbc-a8a4-9e18760499c8", res.Result.NamespaceId) + assert.Equal(t, "test", res.Result.NamespaceName) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result.CreatedBy) + assert.Equal(t, "4e599df4216133509abaac54b109a647", res.Result.ModifiedBy) +} + +func TestDeleteWorkersForPlatformsDispatchNamespace(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces/test", 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, deleteDispatchNamespace) + }) + + err := client.DeleteWorkersForPlatformsDispatchNamespace(context.Background(), AccountIdentifier(testAccountID), "test") + + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/workers_kv.go b/pkg/cloudflare-go/workers_kv.go new file mode 100644 index 000000000..d7e1f0401 --- /dev/null +++ b/pkg/cloudflare-go/workers_kv.go @@ -0,0 +1,378 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/goccy/go-json" +) + +// CreateWorkersKVNamespaceParams provides parameters for creating and updating storage namespaces. +type CreateWorkersKVNamespaceParams struct { + Title string `json:"title"` +} + +type UpdateWorkersKVNamespaceParams struct { + NamespaceID string `json:"-"` + Title string `json:"title"` +} + +// WorkersKVPair is used in an array in the request to the bulk KV api. +type WorkersKVPair struct { + Key string `json:"key"` + Value string `json:"value"` + Expiration int `json:"expiration,omitempty"` + ExpirationTTL int `json:"expiration_ttl,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` + Base64 bool `json:"base64,omitempty"` +} + +// WorkersKVNamespaceResponse is the response received when creating storage namespaces. +type WorkersKVNamespaceResponse struct { + Response + Result WorkersKVNamespace `json:"result"` +} + +// WorkersKVNamespace contains the unique identifier and title of a storage namespace. +type WorkersKVNamespace struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// ListWorkersKVNamespacesResponse contains a slice of storage namespaces associated with an +// account, pagination information, and an embedded response struct. +type ListWorkersKVNamespacesResponse struct { + Response + Result []WorkersKVNamespace `json:"result"` + ResultInfo `json:"result_info"` +} + +// StorageKey is a key name used to identify a storage value. +type StorageKey struct { + Name string `json:"name"` + Expiration int `json:"expiration"` + Metadata interface{} `json:"metadata"` +} + +// ListStorageKeysResponse contains a slice of keys belonging to a storage namespace, +// pagination information, and an embedded response struct. +type ListStorageKeysResponse struct { + Response + Result []StorageKey `json:"result"` + ResultInfo `json:"result_info"` +} + +type ListWorkersKVNamespacesParams struct { + ResultInfo +} + +type WriteWorkersKVEntryParams struct { + NamespaceID string + Key string + Value []byte +} + +type WriteWorkersKVEntriesParams struct { + NamespaceID string + KVs []*WorkersKVPair +} + +type GetWorkersKVParams struct { + NamespaceID string + Key string +} + +type DeleteWorkersKVEntryParams struct { + NamespaceID string + Key string +} + +type DeleteWorkersKVEntriesParams struct { + NamespaceID string + Keys []string +} + +type ListWorkersKVsParams struct { + NamespaceID string `url:"-"` + Limit int `url:"limit,omitempty"` + Cursor string `url:"cursor,omitempty"` + Prefix string `url:"prefix,omitempty"` +} + +// CreateWorkersKVNamespace creates a namespace under the given title. +// A 400 is returned if the account already owns a namespace with this title. +// A namespace must be explicitly deleted to be replaced. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-create-a-namespace +func (api *API) CreateWorkersKVNamespace(ctx context.Context, rc *ResourceContainer, params CreateWorkersKVNamespaceParams) (WorkersKVNamespaceResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkersKVNamespaceResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkersKVNamespaceResponse{}, ErrMissingIdentifier + } + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return WorkersKVNamespaceResponse{}, err + } + + result := WorkersKVNamespaceResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// ListWorkersKVNamespaces lists storage namespaces. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-list-namespaces +func (api *API) ListWorkersKVNamespaces(ctx context.Context, rc *ResourceContainer, params ListWorkersKVNamespacesParams) ([]WorkersKVNamespace, *ResultInfo, error) { + if rc.Level != AccountRouteLevel { + return []WorkersKVNamespace{}, &ResultInfo{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return []WorkersKVNamespace{}, &ResultInfo{}, ErrMissingIdentifier + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + if params.PerPage < 1 { + params.PerPage = 50 + } + if params.Page < 1 { + params.Page = 1 + } + + var namespaces []WorkersKVNamespace + var nsResponse ListWorkersKVNamespacesResponse + for { + nsResponse = ListWorkersKVNamespacesResponse{} + uri := buildURI(fmt.Sprintf("/accounts/%s/storage/kv/namespaces", rc.Identifier), params) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []WorkersKVNamespace{}, &ResultInfo{}, err + } + + err = json.Unmarshal(res, &nsResponse) + if err != nil { + return []WorkersKVNamespace{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal workers KV namespaces JSON data: %w", err) + } + + namespaces = append(namespaces, nsResponse.Result...) + params.ResultInfo = nsResponse.ResultInfo.Next() + + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + + return namespaces, &nsResponse.ResultInfo, nil +} + +// DeleteWorkersKVNamespace deletes the namespace corresponding to the given ID. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-remove-a-namespace +func (api *API) DeleteWorkersKVNamespace(ctx context.Context, rc *ResourceContainer, namespaceID string) (Response, error) { + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s", rc.Identifier, namespaceID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// UpdateWorkersKVNamespace modifies a KV namespace based on the ID. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-rename-a-namespace +func (api *API) UpdateWorkersKVNamespace(ctx context.Context, rc *ResourceContainer, params UpdateWorkersKVNamespaceParams) (Response, error) { + if rc.Level != AccountRouteLevel { + return Response{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return Response{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s", rc.Identifier, params.NamespaceID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// WriteWorkersKVEntry writes a single KV value based on the key. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-write-key-value-pair-with-metadata +func (api *API) WriteWorkersKVEntry(ctx context.Context, rc *ResourceContainer, params WriteWorkersKVEntryParams) (Response, error) { + if rc.Level != AccountRouteLevel { + return Response{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return Response{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", rc.Identifier, params.NamespaceID, url.PathEscape(params.Key)) + res, err := api.makeRequestContextWithHeaders( + ctx, http.MethodPut, uri, params.Value, http.Header{"Content-Type": []string{"application/octet-stream"}}, + ) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// WriteWorkersKVEntries writes multiple KVs at once. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-write-multiple-key-value-pairs +func (api *API) WriteWorkersKVEntries(ctx context.Context, rc *ResourceContainer, params WriteWorkersKVEntriesParams) (Response, error) { + if rc.Level != AccountRouteLevel { + return Response{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return Response{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/bulk", rc.Identifier, params.NamespaceID) + res, err := api.makeRequestContextWithHeaders( + ctx, http.MethodPut, uri, params.KVs, http.Header{"Content-Type": []string{"application/json"}}, + ) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// GetWorkersKV returns the value associated with the given key in the +// given namespace. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-read-key-value-pair +func (api API) GetWorkersKV(ctx context.Context, rc *ResourceContainer, params GetWorkersKVParams) ([]byte, error) { + if rc.Level != AccountRouteLevel { + return []byte(``), ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return []byte(``), ErrMissingIdentifier + } + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", rc.Identifier, params.NamespaceID, url.PathEscape(params.Key)) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + return res, nil +} + +// DeleteWorkersKVEntry deletes a key and value for a provided storage namespace. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-delete-key-value-pair +func (api API) DeleteWorkersKVEntry(ctx context.Context, rc *ResourceContainer, params DeleteWorkersKVEntryParams) (Response, error) { + if rc.Level != AccountRouteLevel { + return Response{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return Response{}, ErrMissingIdentifier + } + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/values/%s", rc.Identifier, params.NamespaceID, url.PathEscape(params.Key)) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result, err +} + +// DeleteWorkersKVEntries deletes multiple KVs at once. +// +// API reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-delete-multiple-key-value-pairs +func (api *API) DeleteWorkersKVEntries(ctx context.Context, rc *ResourceContainer, params DeleteWorkersKVEntriesParams) (Response, error) { + if rc.Level != AccountRouteLevel { + return Response{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return Response{}, ErrMissingIdentifier + } + uri := fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/bulk", rc.Identifier, params.NamespaceID) + res, err := api.makeRequestContextWithHeaders( + ctx, http.MethodDelete, uri, params.Keys, http.Header{"Content-Type": []string{"application/json"}}, + ) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// ListWorkersKVKeys lists a namespace's keys. +// +// API Reference: https://developers.cloudflare.com/api/operations/workers-kv-namespace-list-a-namespace'-s-keys +func (api API) ListWorkersKVKeys(ctx context.Context, rc *ResourceContainer, params ListWorkersKVsParams) (ListStorageKeysResponse, error) { + if rc.Level != AccountRouteLevel { + return ListStorageKeysResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return ListStorageKeysResponse{}, ErrMissingIdentifier + } + + uri := buildURI( + fmt.Sprintf("/accounts/%s/storage/kv/namespaces/%s/keys", rc.Identifier, params.NamespaceID), + params, + ) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ListStorageKeysResponse{}, err + } + + result := ListStorageKeysResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return result, err +} diff --git a/pkg/cloudflare-go/workers_kv_example_test.go b/pkg/cloudflare-go/workers_kv_example_test.go new file mode 100644 index 000000000..233adbc66 --- /dev/null +++ b/pkg/cloudflare-go/workers_kv_example_test.go @@ -0,0 +1,210 @@ +package cloudflare_test + +import ( + "context" + "encoding/base64" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +const ( + namespace = "xxxxxx96ee002e8xxxxxx665354c0449" + accountID = "xxxxxx10ee002e8xxxxxx665354c0410" +) + +func ExampleAPI_CreateWorkersKVNamespace() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + req := cloudflare.CreateWorkersKVNamespaceParams{Title: "test_namespace2"} + response, err := api.CreateWorkersKVNamespace(context.Background(), cloudflare.AccountIdentifier(accountID), req) + if err != nil { + log.Fatal(err) + } + + fmt.Println(response) +} + +func ExampleAPI_ListWorkersKVNamespaces() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + lsr, _, err := api.ListWorkersKVNamespaces(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.ListWorkersKVNamespacesParams{}) + if err != nil { + log.Fatal(err) + } + + fmt.Println(lsr) + + resp, _, err := api.ListWorkersKVNamespaces(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.ListWorkersKVNamespacesParams{ResultInfo: cloudflare.ResultInfo{ + PerPage: 10, + }}) + if err != nil { + log.Fatal(err) + } + + fmt.Println(resp) +} + +func ExampleAPI_DeleteWorkersKVNamespace() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + response, err := api.DeleteWorkersKVNamespace(context.Background(), cloudflare.AccountIdentifier(accountID), namespace) + if err != nil { + log.Fatal(err) + } + + fmt.Println(response) +} + +func ExampleAPI_UpdateWorkersKVNamespace() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + resp, err := api.UpdateWorkersKVNamespace(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.UpdateWorkersKVNamespaceParams{ + NamespaceID: namespace, + Title: "test_title", + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println(resp) +} + +func ExampleAPI_WriteWorkersKVEntry() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + payload := []byte("test payload") + key := "test_key" + + resp, err := api.WriteWorkersKVEntry(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.WriteWorkersKVEntryParams{ + NamespaceID: namespace, + Key: key, + Value: payload, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println(resp) +} + +func ExampleAPI_WriteWorkersKVEntries() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + payload := []*cloudflare.WorkersKVPair{ + { + Key: "key1", + Value: "value1", + }, + { + Key: "key2", + Value: base64.StdEncoding.EncodeToString([]byte("value2")), + Base64: true, + Metadata: "key2's value will be decoded in base64 before it is stored", + }, + } + + resp, err := api.WriteWorkersKVEntries(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.WriteWorkersKVEntriesParams{ + NamespaceID: namespace, + KVs: payload, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println(resp) +} + +func ExampleAPI_GetWorkersKV() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + key := "test_key" + resp, err := api.GetWorkersKV(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.GetWorkersKVParams{NamespaceID: namespace, Key: key}) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%s\n", resp) +} + +func ExampleAPI_DeleteWorkersKVEntry() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + key := "test_key" + resp, err := api.DeleteWorkersKVEntry(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.DeleteWorkersKVEntryParams{ + NamespaceID: namespace, + Key: key, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("%+v\n", resp) +} + +func ExampleAPI_DeleteWorkersKVEntries() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + keys := []string{"key1", "key2", "key3"} + + resp, err := api.DeleteWorkersKVEntries(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.DeleteWorkersKVEntriesParams{ + NamespaceID: namespace, + Keys: keys, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println(resp) +} + +func ExampleAPI_ListWorkersKVKeys() { + api, err := cloudflare.New(apiKey, user) + if err != nil { + log.Fatal(err) + } + + limit := 50 + prefix := "my-prefix" + cursor := "AArAbNSOuYcr4HmzGH02-cfDN8Ck9ejOwkn_Ai5rsn7S9NEqVJBenU9-gYRlrsziyjKLx48hNDLvtYzBAmkPsLGdye8ECr5PqFYcIOfUITdhkyTc1x6bV8nmyjz5DO-XaZH4kYY1KfqT8NRBIe5sic6yYt3FUDttGjafy0ivi-Up-TkVdRB0OxCf3O3OB-svG6DXheV5XTdDNrNx1o_CVqy2l2j0F4iKV1qFe_KhdkjC7Y6QjhUZ1MOb3J_uznNYVCoxZ-bVAAsJmXA" + + resp, err := api.ListWorkersKVKeys(context.Background(), cloudflare.AccountIdentifier(accountID), cloudflare.ListWorkersKVsParams{ + NamespaceID: namespace, + Prefix: prefix, + Limit: limit, + Cursor: cursor, + }) + if err != nil { + log.Fatal(err) + } + + fmt.Println(resp) +} diff --git a/pkg/cloudflare-go/workers_kv_test.go b/pkg/cloudflare-go/workers_kv_test.go new file mode 100644 index 000000000..80d7b140b --- /dev/null +++ b/pkg/cloudflare-go/workers_kv_test.go @@ -0,0 +1,506 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkersKV_CreateWorkersKVNamespace(t *testing.T) { + setup() + defer teardown() + + response := `{ + "result": { + "id" : "3aeaxxxxee014exxxx4cf66xxxxc0448", + "title": "test_namespace" + }, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc("/accounts/"+testAccountID+"/storage/kv/namespaces", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.CreateWorkersKVNamespace(context.Background(), AccountIdentifier(testAccountID), CreateWorkersKVNamespaceParams{Title: "Namespace"}) + want := WorkersKVNamespaceResponse{ + successResponse, + WorkersKVNamespace{ + ID: "3aeaxxxxee014exxxx4cf66xxxxc0448", + Title: "test_namespace", + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Response, res.Response) + } +} + +func TestWorkersKV_DeleteWorkersKVNamespace(t *testing.T) { + setup() + defer teardown() + + namespace := "3aeaxxxxee014exxxx4cf66xxxxc0448" + response := `{ + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s", namespace), 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/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.DeleteWorkersKVNamespace(context.Background(), AccountIdentifier(testAccountID), namespace) + want := successResponse + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersKV_ListWorkersKVNamespaces(t *testing.T) { + setup() + defer teardown() + + response := `{ + "result": [ + {"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "title": "test_namespace_1" + }, + {"id": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "title": "test_namespace_2" + } + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 100, + "count": 2, + "total_count": 2, + "total_pages": 1 + } + }` + + mux.HandleFunc("/accounts/"+testAccountID+"/storage/kv/namespaces", 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/javascript") + fmt.Fprint(w, response) + }) + + res, _, err := client.ListWorkersKVNamespaces(context.Background(), AccountIdentifier(testAccountID), ListWorkersKVNamespacesParams{}) + want := []WorkersKVNamespace{ + { + ID: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + Title: "test_namespace_1", + }, + { + ID: "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + Title: "test_namespace_2", + }, + } + + if assert.NoError(t, err) { + sort.Slice(res, func(i, j int) bool { + return res[i].ID < res[j].ID + }) + sort.Slice(want, func(i, j int) bool { + return want[i].ID < want[j].ID + }) + assert.Equal(t, res, want) + } +} + +func TestWorkersKV_ListWorkersKVNamespaceMultiplePages(t *testing.T) { + setup() + defer teardown() + + response1 := `{ + "result": [ + {"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "title": "test_namespace_1" + } + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 100, + "count": 1, + "total_count": 2, + "total_pages": 2 + } + }` + + response2 := `{ + "result": [ + {"id": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + "title": "test_namespace_2" + } + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 2, + "per_page": 100, + "count": 1, + "total_count": 2, + "total_pages": 2 + } + }` + + mux.HandleFunc("/accounts/"+testAccountID+"/storage/kv/namespaces", 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/javascript") + + if r.URL.Query().Get("page") == "1" { + fmt.Fprint(w, response1) + return + } else if r.URL.Query().Get("page") == "2" { + fmt.Fprint(w, response2) + return + } else { + panic(errors.New("Got a request for an unexpected page")) + } + }) + + res, _, err := client.ListWorkersKVNamespaces(context.Background(), AccountIdentifier(testAccountID), ListWorkersKVNamespacesParams{}) + want := []WorkersKVNamespace{ + { + ID: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + Title: "test_namespace_1", + }, + { + ID: "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", + Title: "test_namespace_2", + }, + } + + if assert.NoError(t, err) { + sort.Slice(res, func(i, j int) bool { + return res[i].ID < res[j].ID + }) + sort.Slice(want, func(i, j int) bool { + return want[i].ID < want[j].ID + }) + assert.Equal(t, res, want) + } +} + +func TestWorkersKV_UpdateWorkersKVNamespace(t *testing.T) { + setup() + defer teardown() + + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": null, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s", namespace), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.UpdateWorkersKVNamespace(context.Background(), AccountIdentifier(testAccountID), UpdateWorkersKVNamespaceParams{Title: "Namespace", NamespaceID: namespace}) + want := successResponse + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersKV_WriteWorkersKVEntry(t *testing.T) { + setup() + defer teardown() + + key := "test_key" + value := []byte("test_value") + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": null, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/values/%s", namespace, key), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/octet-stream") + fmt.Fprint(w, response) + }) + + want := successResponse + res, err := client.WriteWorkersKVEntry(context.Background(), AccountIdentifier(testAccountID), WriteWorkersKVEntryParams{NamespaceID: namespace, Key: key, Value: value}) + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersKV_WriteWorkersKVEntries(t *testing.T) { + setup() + defer teardown() + + kvs := []*WorkersKVPair{ + {Key: "key1", Value: "value1"}, + {Key: "key2", Value: "value2"}, + {Key: "key3", Value: "value3", Metadata: "meta3", Base64: true}, + } + + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": null, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/bulk", namespace), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + }) + + want := successResponse + res, err := client.WriteWorkersKVEntries(context.Background(), AccountIdentifier(testAccountID), WriteWorkersKVEntriesParams{NamespaceID: namespace, KVs: kvs}) + require.NoError(t, err) + assert.Equal(t, want, res) +} + +func TestWorkersKV_ReadWorkersKV(t *testing.T) { + setup() + defer teardown() + + key := "test_key" + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/values/%s", namespace, key), 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", "text/plain") + fmt.Fprint(w, "test_value") + }) + + res, err := client.GetWorkersKV(context.Background(), AccountIdentifier(testAccountID), GetWorkersKVParams{NamespaceID: namespace, Key: key}) + want := []byte("test_value") + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersKV_DeleteWorkersKVEntry(t *testing.T) { + setup() + defer teardown() + + key := "test_key" + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": null, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/values/%s", namespace, key), 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/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.DeleteWorkersKVEntry(context.Background(), AccountIdentifier(testAccountID), DeleteWorkersKVEntryParams{NamespaceID: namespace, Key: key}) + want := successResponse + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersKV_DeleteWorkersKVBulk(t *testing.T) { + setup() + defer teardown() + + keys := []string{"key1", "key2", "key3"} + + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": null, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/bulk", namespace), 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, response) + }) + + want := successResponse + res, err := client.DeleteWorkersKVEntries(context.Background(), AccountIdentifier(testAccountID), DeleteWorkersKVEntriesParams{NamespaceID: namespace, Keys: keys}) + require.NoError(t, err) + assert.Equal(t, want, res) +} + +func TestWorkersKV_ListKeys(t *testing.T) { + setup() + defer teardown() + + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": [ + {"name": "test_key_1"}, + {"name": "test_key_2"} + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 20, + "count": 2, + "total_count": 2, + "total_pages": 1 + } + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/keys", namespace), 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/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.ListWorkersKVKeys(context.Background(), AccountIdentifier(testAccountID), ListWorkersKVsParams{NamespaceID: namespace}) + + want := ListStorageKeysResponse{ + successResponse, + []StorageKey{ + {Name: "test_key_1"}, + {Name: "test_key_2"}, + }, + ResultInfo{ + Page: 1, + PerPage: 20, + Count: 2, + TotalPages: 1, + Total: 2, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Response, res.Response) + assert.Equal(t, want.ResultInfo, res.ResultInfo) + + sort.Slice(res.Result, func(i, j int) bool { + return res.Result[i].Name < res.Result[j].Name + }) + + sort.Slice(want.Result, func(i, j int) bool { + return want.Result[i].Name < want.Result[j].Name + }) + assert.Equal(t, want.Result, res.Result) + } +} + +func TestWorkersKV_ListKeysWithParameters(t *testing.T) { + setup() + defer teardown() + + cursor := "AArAbNSOuYcr4HmzGH02-cfDN8Ck9ejOwkn_Ai5rsn7S9NEqVJBenU9-gYRlrsziyjKLx48hNDLvtYzBAmkPsLGdye8ECr5PqFYcIOfUITdhkyTc1x6bV8nmyjz5DO-XaZH4kYY1KfqT8NRBIe5sic6yYt3FUDttGjafy0ivi-Up-TkVdRB0OxCf3O3OB-svG6DXheV5XTdDNrNx1o_CVqy2l2j0F4iKV1qFe_KhdkjC7Y6QjhUZ1MOb3J_uznNYVCoxZ-bVAAsJmXA" + namespace := "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + response := `{ + "result": [ + { + "name": "test_key_1", + "metadata": "test_key_1_meta" + }, + { + "name": "test_key_2", + "metadata": { + "test2_meta_key": "test2_meta_value" + } + } + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 20, + "count": 2, + "total_count": 2, + "total_pages": 1, + "cursor": "` + cursor + `" + } + }` + + mux.HandleFunc(fmt.Sprintf("/accounts/"+testAccountID+"/storage/kv/namespaces/%s/keys", namespace), 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/javascript") + fmt.Fprint(w, response) + }) + + limit, prefix := 25, "test-prefix" + res, err := client.ListWorkersKVKeys(context.Background(), AccountIdentifier(testAccountID), ListWorkersKVsParams{ + NamespaceID: namespace, + Limit: limit, + Prefix: prefix, + }) + + want := ListStorageKeysResponse{ + successResponse, + []StorageKey{ + { + Name: "test_key_1", + Metadata: "test_key_1_meta", + }, + { + Name: "test_key_2", + Metadata: map[string]interface{}{ + "test2_meta_key": "test2_meta_value", + }, + }, + }, + ResultInfo{ + Page: 1, + PerPage: 20, + Count: 2, + TotalPages: 1, + Total: 2, + Cursor: cursor, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Response, res.Response) + assert.Equal(t, want.ResultInfo, res.ResultInfo) + + sort.Slice(res.Result, func(i, j int) bool { + return res.Result[i].Name < res.Result[j].Name + }) + + sort.Slice(want.Result, func(i, j int) bool { + return want.Result[i].Name < want.Result[j].Name + }) + assert.Equal(t, want.Result, res.Result) + } +} diff --git a/pkg/cloudflare-go/workers_routes.go b/pkg/cloudflare-go/workers_routes.go new file mode 100644 index 000000000..9fc7f7a89 --- /dev/null +++ b/pkg/cloudflare-go/workers_routes.go @@ -0,0 +1,162 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +var ErrMissingWorkerRouteID = errors.New("missing required route ID") + +type ListWorkerRoutes struct{} + +type CreateWorkerRouteParams struct { + Pattern string `json:"pattern"` + Script string `json:"script,omitempty"` +} + +type ListWorkerRoutesParams struct{} + +type UpdateWorkerRouteParams struct { + ID string `json:"id,omitempty"` + Pattern string `json:"pattern"` + Script string `json:"script,omitempty"` +} + +// CreateWorkerRoute creates worker route for a script. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-routes-create-route +func (api *API) CreateWorkerRoute(ctx context.Context, rc *ResourceContainer, params CreateWorkerRouteParams) (WorkerRouteResponse, error) { + if rc.Level != ZoneRouteLevel { + return WorkerRouteResponse{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if rc.Identifier == "" { + return WorkerRouteResponse{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/zones/%s/workers/routes", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params) + if err != nil { + return WorkerRouteResponse{}, err + } + + var r WorkerRouteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WorkerRouteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// DeleteWorkerRoute deletes worker route for a script. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-routes-delete-route +func (api *API) DeleteWorkerRoute(ctx context.Context, rc *ResourceContainer, routeID string) (WorkerRouteResponse, error) { + if rc.Level != ZoneRouteLevel { + return WorkerRouteResponse{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if rc.Identifier == "" { + return WorkerRouteResponse{}, ErrMissingIdentifier + } + + if routeID == "" { + return WorkerRouteResponse{}, errors.New("missing required route ID") + } + + uri := fmt.Sprintf("/zones/%s/workers/routes/%s", rc.Identifier, routeID) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return WorkerRouteResponse{}, err + } + var r WorkerRouteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WorkerRouteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// ListWorkerRoutes returns list of Worker routes. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-routes-list-routes +func (api *API) ListWorkerRoutes(ctx context.Context, rc *ResourceContainer, params ListWorkerRoutesParams) (WorkerRoutesResponse, error) { + if rc.Level != ZoneRouteLevel { + return WorkerRoutesResponse{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if rc.Identifier == "" { + return WorkerRoutesResponse{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/zones/%s/workers/routes", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkerRoutesResponse{}, err + } + var r WorkerRoutesResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WorkerRoutesResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r, nil +} + +// GetWorkerRoute returns a Workers route. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-routes-get-route +func (api *API) GetWorkerRoute(ctx context.Context, rc *ResourceContainer, routeID string) (WorkerRouteResponse, error) { + if rc.Level != ZoneRouteLevel { + return WorkerRouteResponse{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if rc.Identifier == "" { + return WorkerRouteResponse{}, ErrMissingIdentifier + } + + uri := fmt.Sprintf("/zones/%s/workers/routes/%s", rc.Identifier, routeID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkerRouteResponse{}, err + } + var r WorkerRouteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WorkerRouteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// UpdateWorkerRoute updates worker route for a script. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-routes-update-route +func (api *API) UpdateWorkerRoute(ctx context.Context, rc *ResourceContainer, params UpdateWorkerRouteParams) (WorkerRouteResponse, error) { + if rc.Level != ZoneRouteLevel { + return WorkerRouteResponse{}, fmt.Errorf(errInvalidResourceContainerAccess, ZoneRouteLevel) + } + + if rc.Identifier == "" { + return WorkerRouteResponse{}, ErrMissingIdentifier + } + + if params.ID == "" { + return WorkerRouteResponse{}, ErrMissingWorkerRouteID + } + + uri := fmt.Sprintf("/zones/%s/workers/routes/%s", rc.Identifier, params.ID) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return WorkerRouteResponse{}, err + } + var r WorkerRouteResponse + err = json.Unmarshal(res, &r) + if err != nil { + return WorkerRouteResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} diff --git a/pkg/cloudflare-go/workers_routes_test.go b/pkg/cloudflare-go/workers_routes_test.go new file mode 100644 index 000000000..8d36fdc9d --- /dev/null +++ b/pkg/cloudflare-go/workers_routes_test.go @@ -0,0 +1,121 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCreateWorkersRoute(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/workers/routes", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, createWorkerRouteResponse) + }) + + res, err := client.CreateWorkerRoute(context.Background(), ZoneIdentifier(testZoneID), CreateWorkerRouteParams{ + Pattern: "app1.example.com/*", + Script: "example", + }) + + want := WorkerRouteResponse{successResponse, WorkerRoute{ID: "e7a57d8746e74ae49c25994dadb421b1"}} + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestDeleteWorkersRoute(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/workers/routes/e7a57d8746e74ae49c25994dadb421b1", 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, deleteWorkerRouteResponseData) + }) + res, err := client.DeleteWorkerRoute(context.Background(), ZoneIdentifier(testZoneID), "e7a57d8746e74ae49c25994dadb421b1") + want := WorkerRouteResponse{successResponse, + WorkerRoute{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + }} + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestListWorkersRoute(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/workers/routes", 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, listWorkerRouteResponse) + }) + + res, err := client.ListWorkerRoutes(context.Background(), ZoneIdentifier(testZoneID), ListWorkerRoutesParams{}) + want := WorkerRoutesResponse{successResponse, + []WorkerRoute{ + {ID: "e7a57d8746e74ae49c25994dadb421b1", Pattern: "app1.example.com/*", ScriptName: "test_script_1"}, + {ID: "f8b68e9857f85bf59c25994dadb421b1", Pattern: "app2.example.com/*", ScriptName: "test_script_2"}, + {ID: "2b5bf4240cd34c77852fac70b1bf745a", Pattern: "app3.example.com/*", ScriptName: "test_script_3"}, + }, + } + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestGetWorkersRoute(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/workers/routes/1234", 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, getRouteResponseData) + }) + + res, err := client.GetWorkerRoute(context.Background(), ZoneIdentifier(testZoneID), "1234") + want := WorkerRouteResponse{successResponse, + WorkerRoute{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + Pattern: "app1.example.com/*", + ScriptName: "script-name"}, + } + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestUpdateWorkersRoute(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/"+testZoneID+"/workers/routes/e7a57d8746e74ae49c25994dadb421b1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, updateWorkerRouteEntResponse) + }) + + res, err := client.UpdateWorkerRoute(context.Background(), ZoneIdentifier(testZoneID), UpdateWorkerRouteParams{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + Pattern: "app3.example.com/*", + Script: "test_script_1", + }) + want := WorkerRouteResponse{successResponse, + WorkerRoute{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + Pattern: "app3.example.com/*", + ScriptName: "test_script_1", + }} + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} diff --git a/pkg/cloudflare-go/workers_secrets.go b/pkg/cloudflare-go/workers_secrets.go new file mode 100644 index 000000000..cfcd597fd --- /dev/null +++ b/pkg/cloudflare-go/workers_secrets.go @@ -0,0 +1,125 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +// WorkersPutSecretRequest provides parameters for creating and updating secrets. +type WorkersPutSecretRequest struct { + Name string `json:"name"` + Text string `json:"text"` + Type WorkerBindingType `json:"type"` +} + +// WorkersSecret contains the name and type of the secret. +type WorkersSecret struct { + Name string `json:"name"` + Type string `json:"secret_text"` +} + +// WorkersPutSecretResponse is the response received when creating or updating a secret. +type WorkersPutSecretResponse struct { + Response + Result WorkersSecret `json:"result"` +} + +// WorkersListSecretsResponse is the response received when listing secrets. +type WorkersListSecretsResponse struct { + Response + Result []WorkersSecret `json:"result"` +} + +type SetWorkersSecretParams struct { + ScriptName string + Secret *WorkersPutSecretRequest +} + +type DeleteWorkersSecretParams struct { + ScriptName string + SecretName string +} + +type ListWorkersSecretsParams struct { + ScriptName string +} + +// SetWorkersSecret creates or updates a secret. +// +// API reference: https://api.cloudflare.com/ +func (api *API) SetWorkersSecret(ctx context.Context, rc *ResourceContainer, params SetWorkersSecretParams) (WorkersPutSecretResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkersPutSecretResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkersPutSecretResponse{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/secrets", rc.Identifier, params.ScriptName) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Secret) + if err != nil { + return WorkersPutSecretResponse{}, err + } + + result := WorkersPutSecretResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// DeleteWorkersSecret deletes a secret. +// +// API reference: https://api.cloudflare.com/ +func (api *API) DeleteWorkersSecret(ctx context.Context, rc *ResourceContainer, params DeleteWorkersSecretParams) (Response, error) { + if rc.Level != AccountRouteLevel { + return Response{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return Response{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/secrets/%s", rc.Identifier, params.ScriptName, params.SecretName) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return Response{}, err + } + + result := Response{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} + +// ListWorkersSecrets lists secrets for a given worker +// API reference: https://api.cloudflare.com/ +func (api *API) ListWorkersSecrets(ctx context.Context, rc *ResourceContainer, params ListWorkersSecretsParams) (WorkersListSecretsResponse, error) { + if rc.Level != AccountRouteLevel { + return WorkersListSecretsResponse{}, ErrRequiredAccountLevelResourceContainer + } + + if rc.Identifier == "" { + return WorkersListSecretsResponse{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/secrets", rc.Identifier, params.ScriptName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkersListSecretsResponse{}, err + } + + result := WorkersListSecretsResponse{} + if err := json.Unmarshal(res, &result); err != nil { + return result, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return result, err +} diff --git a/pkg/cloudflare-go/workers_secrets_test.go b/pkg/cloudflare-go/workers_secrets_test.go new file mode 100644 index 000000000..dc3ac99fa --- /dev/null +++ b/pkg/cloudflare-go/workers_secrets_test.go @@ -0,0 +1,111 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetWorkersSecret(t *testing.T) { + setup() + defer teardown() + + response := `{ + "result": { + "name" : "my-secret", + "type": "secret_text" + }, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/test-script/secrets", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/javascript") + fmt.Fprint(w, response) + }) + req := &WorkersPutSecretRequest{ + Name: "my-secret", + Text: "super-secret", + } + res, err := client.SetWorkersSecret(context.Background(), AccountIdentifier(testAccountID), SetWorkersSecretParams{ScriptName: "test-script", Secret: req}) + want := WorkersPutSecretResponse{ + successResponse, + WorkersSecret{ + Name: "test", + Type: "secret_text", + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Response, res.Response) + } +} + +func TestDeleteWorkersSecret(t *testing.T) { + setup() + defer teardown() + + response := `{ + "result": { + "name" : "test", + "type": "secret_text" + }, + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/test-script/secrets/my-secret", 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/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.DeleteWorkersSecret(context.Background(), AccountIdentifier(testAccountID), DeleteWorkersSecretParams{ScriptName: "test-script", SecretName: "my-secret"}) + want := successResponse + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestListWorkersSecret(t *testing.T) { + setup() + defer teardown() + + response := `{ + "result": [{ + "name" : "my-secret", + "type": "secret_text" + }], + "success": true, + "errors": [], + "messages": [] + }` + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/test-script/secrets", 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/javascript") + fmt.Fprint(w, response) + }) + + res, err := client.ListWorkersSecrets(context.Background(), AccountIdentifier(testAccountID), ListWorkersSecretsParams{ScriptName: "test-script"}) + want := WorkersListSecretsResponse{ + successResponse, + []WorkersSecret{ + { + Name: "my-secret", + Type: "secret_text", + }, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Response, res.Response) + } +} diff --git a/pkg/cloudflare-go/workers_subdomain.go b/pkg/cloudflare-go/workers_subdomain.go new file mode 100644 index 000000000..4fc2e7c4d --- /dev/null +++ b/pkg/cloudflare-go/workers_subdomain.go @@ -0,0 +1,58 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + + "github.com/goccy/go-json" +) + +type WorkersSubdomain struct { + Name string `json:"name,omitempty"` +} + +type WorkersSubdomainResponse struct { + Response + Result WorkersSubdomain +} + +// WorkersCreateSubdomain Creates a Workers subdomain for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-subdomain-create-subdomain +func (api *API) WorkersCreateSubdomain(ctx context.Context, rc *ResourceContainer, params WorkersSubdomain) (WorkersSubdomain, error) { + if rc.Identifier == "" { + return WorkersSubdomain{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/subdomain", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return WorkersSubdomain{}, err + } + var r WorkersSubdomainResponse + if err := json.Unmarshal(res, &r); err != nil { + return WorkersSubdomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// WorkersGetSubdomain Creates a Workers subdomain for an account. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-subdomain-get-subdomain +func (api *API) WorkersGetSubdomain(ctx context.Context, rc *ResourceContainer) (WorkersSubdomain, error) { + if rc.Identifier == "" { + return WorkersSubdomain{}, ErrMissingAccountID + } + + uri := fmt.Sprintf("/accounts/%s/workers/subdomain", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkersSubdomain{}, err + } + var r WorkersSubdomainResponse + if err := json.Unmarshal(res, &r); err != nil { + return WorkersSubdomain{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} diff --git a/pkg/cloudflare-go/workers_subdomain_test.go b/pkg/cloudflare-go/workers_subdomain_test.go new file mode 100644 index 000000000..d460cce02 --- /dev/null +++ b/pkg/cloudflare-go/workers_subdomain_test.go @@ -0,0 +1,72 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorkersSubdomain_CreateSubdomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/subdomain", testAccountID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "example-subdomain" + } +}`) + }) + + _, err := client.WorkersCreateSubdomain(context.Background(), AccountIdentifier(""), WorkersSubdomain{}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + res, err := client.WorkersCreateSubdomain(context.Background(), AccountIdentifier(testAccountID), WorkersSubdomain{Name: "example-subdomain"}) + + want := WorkersSubdomain{Name: "example-subdomain"} + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersSubdomain_GetSubdomain(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/subdomain", testAccountID), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "name": "example-subdomain" + } +}`) + }) + + _, err := client.WorkersGetSubdomain(context.Background(), AccountIdentifier("")) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingAccountID, err) + } + + res, err := client.WorkersGetSubdomain(context.Background(), AccountIdentifier(testAccountID)) + + want := WorkersSubdomain{Name: "example-subdomain"} + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} diff --git a/pkg/cloudflare-go/workers_tail.go b/pkg/cloudflare-go/workers_tail.go new file mode 100644 index 000000000..733bc98dc --- /dev/null +++ b/pkg/cloudflare-go/workers_tail.go @@ -0,0 +1,114 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +var ( + ErrMissingScriptName = errors.New("required script name missing") + ErrMissingTailID = errors.New("required tail id missing") +) + +type WorkersTail struct { + ID string `json:"id"` + URL string `json:"url"` + ExpiresAt *time.Time `json:"expires_at"` +} + +type StartWorkersTailResponse struct { + Response + Result WorkersTail +} + +type ListWorkersTailParameters struct { + AccountID string + ScriptName string +} + +type ListWorkersTailResponse struct { + Response + Result WorkersTail +} + +// StartWorkersTail Starts a tail that receives logs and exception from a Worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-tail-logs-start-tail +func (api *API) StartWorkersTail(ctx context.Context, rc *ResourceContainer, scriptName string) (WorkersTail, error) { + if rc.Identifier == "" { + return WorkersTail{}, ErrMissingAccountID + } + + if scriptName == "" { + return WorkersTail{}, ErrMissingScriptName + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/tails", rc.Identifier, scriptName) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return WorkersTail{}, err + } + + var workerstailResponse StartWorkersTailResponse + if err := json.Unmarshal(res, &workerstailResponse); err != nil { + return WorkersTail{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return workerstailResponse.Result, nil +} + +// ListWorkersTail Get list of tails currently deployed on a Worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-tail-logs-list-tails +func (api *API) ListWorkersTail(ctx context.Context, rc *ResourceContainer, params ListWorkersTailParameters) (WorkersTail, error) { + if rc.Identifier == "" { + return WorkersTail{}, ErrMissingAccountID + } + + if params.ScriptName == "" { + return WorkersTail{}, ErrMissingScriptName + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/tails", rc.Identifier, params.ScriptName) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return WorkersTail{}, err + } + + var workerstailResponse ListWorkersTailResponse + if err := json.Unmarshal(res, &workerstailResponse); err != nil { + return WorkersTail{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return workerstailResponse.Result, nil +} + +// DeleteWorkersTail Deletes a tail from a Worker. +// +// API reference: https://developers.cloudflare.com/api/operations/worker-tail-logs-delete-tail +func (api *API) DeleteWorkersTail(ctx context.Context, rc *ResourceContainer, scriptName, tailID string) error { + if rc.Identifier == "" { + return ErrMissingAccountID + } + + if scriptName == "" { + return ErrMissingScriptName + } + + if tailID == "" { + return ErrMissingTailID + } + + uri := fmt.Sprintf("/accounts/%s/workers/scripts/%s/tails/%s", rc.Identifier, scriptName, tailID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/workers_tail_test.go b/pkg/cloudflare-go/workers_tail_test.go new file mode 100644 index 000000000..ed57b4d80 --- /dev/null +++ b/pkg/cloudflare-go/workers_tail_test.go @@ -0,0 +1,118 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + testScriptName = "this-is_my_script-01" + testTailID = "03dc9f77817b488fb26c5861ec18f791" +) + +func TestWorkersTail_StartWorkersTail(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/scripts/%s/tails", testAccountID, testScriptName), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "03dc9f77817b488fb26c5861ec18f791", + "url": "wss://tail.developers.workers.dev/03dc9f77817b488fb26c5861ec18f791", + "expires_at": "2021-08-20T19:15:51Z" + } +}`) + }) + + _, err := client.StartWorkersTail(context.Background(), AccountIdentifier(testAccountID), "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingScriptName, err) + } + + res, err := client.StartWorkersTail(context.Background(), AccountIdentifier(testAccountID), testScriptName) + expiresAt, _ := time.Parse(time.RFC3339, "2021-08-20T19:15:51Z") + want := WorkersTail{ + ID: "03dc9f77817b488fb26c5861ec18f791", + URL: "wss://tail.developers.workers.dev/03dc9f77817b488fb26c5861ec18f791", + ExpiresAt: &expiresAt, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersTail_ListWorkersTail(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/scripts/%s/tails", testAccountID, testScriptName), 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.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "03dc9f77817b488fb26c5861ec18f791", + "url": "wss://tail.developers.workers.dev/03dc9f77817b488fb26c5861ec18f791", + "expires_at": "2021-08-20T19:15:51Z" + } +}`) + }) + + _, err := client.ListWorkersTail(context.Background(), AccountIdentifier(testAccountID), ListWorkersTailParameters{ScriptName: ""}) + if assert.Error(t, err) { + assert.Equal(t, ErrMissingScriptName, err) + } + + res, err := client.ListWorkersTail(context.Background(), AccountIdentifier(testAccountID), ListWorkersTailParameters{ScriptName: testScriptName}) + expiresAt, _ := time.Parse(time.RFC3339, "2021-08-20T19:15:51Z") + want := WorkersTail{ + ID: "03dc9f77817b488fb26c5861ec18f791", + URL: "wss://tail.developers.workers.dev/03dc9f77817b488fb26c5861ec18f791", + ExpiresAt: &expiresAt, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestWorkersTail_DeleteWorkersTail(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/accounts/%s/workers/scripts/%s/tails/%s", testAccountID, testScriptName, testTailID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], +}`) + }) + + err := client.DeleteWorkersTail(context.Background(), AccountIdentifier(testAccountID), "", "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingScriptName, err) + } + + err = client.DeleteWorkersTail(context.Background(), AccountIdentifier(testAccountID), testScriptName, "") + if assert.Error(t, err) { + assert.Equal(t, ErrMissingTailID, err) + } + + err = client.DeleteWorkersTail(context.Background(), AccountIdentifier(testAccountID), testScriptName, testTailID) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/workers_test.go b/pkg/cloudflare-go/workers_test.go new file mode 100644 index 000000000..6175440a1 --- /dev/null +++ b/pkg/cloudflare-go/workers_test.go @@ -0,0 +1,1451 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + "time" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + deleteWorkerResponseData = `{ + "result": null, + "success": true, + "errors": [], + "messages": [] +}` + + updateWorkerRouteResponse = `{ + "result": { + "id": "e7a57d8746e74ae49c25994dadb421b1", + "pattern": "app3.example.com/*", + "enabled": true + }, + "success": true, + "errors": [], + "messages": [] +}` + updateWorkerRouteEntResponse = `{ + "result": { + "id": "e7a57d8746e74ae49c25994dadb421b1", + "pattern": "app3.example.com/*", + "script": "test_script_1" + }, + "success": true, + "errors": [], + "messages": [] +}` + createWorkerRouteResponse = `{ + "result": { + "id": "e7a57d8746e74ae49c25994dadb421b1" + }, + "success": true, + "errors": [], + "messages": [] +}` + listRouteResponseData = `{ + "result": [ + { + "id": "e7a57d8746e74ae49c25994dadb421b1", + "pattern": "app1.example.com/*", + "enabled": true + }, + { + "id": "f8b68e9857f85bf59c25994dadb421b1", + "pattern": "app2.example.com/*", + "enabled": false + } + ], + "success": true, + "errors": [], + "messages": [] +}` + listWorkerRouteResponse = `{ + "result": [ + { + "id": "e7a57d8746e74ae49c25994dadb421b1", + "pattern": "app1.example.com/*", + "script": "test_script_1" + }, + { + "id": "f8b68e9857f85bf59c25994dadb421b1", + "pattern": "app2.example.com/*", + "script": "test_script_2" + }, + { + "id": "2b5bf4240cd34c77852fac70b1bf745a", + "pattern": "app3.example.com/*", + "script": "test_script_3" + } + ], + "success": true, + "errors": [], + "messages": [] +}` + getRouteResponseData = `{ + "result": { + "id": "e7a57d8746e74ae49c25994dadb421b1", + "pattern": "app1.example.com/*", + "script": "script-name" + }, + "success": true, + "errors": [], + "messages": [] +}` + listBindingsResponseData = `{ + "result": [ + { + "name": "MY_KV", + "namespace_id": "89f5f8fd93f94cb98473f6f421aa3b65", + "type": "kv_namespace" + }, + { + "name": "MY_WASM", + "type": "wasm_module" + }, + { + "name": "MY_PLAIN_TEXT", + "type": "plain_text", + "text": "text" + }, + { + "name": "MY_SECRET_TEXT", + "type": "secret_text" + }, + { + "name": "MY_SERVICE_BINDING", + "type": "service", + "service": "MY_SERVICE", + "environment": "MY_ENVIRONMENT" + }, + { + "name": "MY_NEW_BINDING", + "type": "some_imaginary_new_binding_type" + }, + { + "name": "MY_BUCKET", + "type": "r2_bucket", + "bucket_name": "bucket" + }, + { + "name": "MY_DATASET", + "type": "analytics_engine", + "dataset": "my_dataset" + }, + { + "name": "MY_DATABASE", + "type": "d1", + "database_id": "cef5331f-e5c7-4c8a-a415-7908ae45f92a" + } + ], + "success": true, + "errors": [], + "messages": [] + }` + listWorkersResponseData = `{ + "result": [ + { + "id": "bar", + "created_on": "2018-04-22T17:10:48.938097Z", + "modified_on": "2018-04-22T17:10:48.938097Z", + "etag": "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a" + }, + { + "id": "baz", + "created_on": "2018-04-22T17:10:48.938097Z", + "modified_on": "2018-04-22T17:10:48.938097Z", + "etag": "380dg51e97e80b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43088b" + } + ], + "success": true, + "errors": [], + "messages": [] +}` + workerMetadata = `{ + "id": "e7a57d8746e74ae49c25994dadb421b1", + "etag": "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + "logpush": true + }` + workerScript = `addEventListener('fetch', event => { + event.passThroughOnException() + event.respondWith(handleRequest(event.request)) +}) + +async function handleRequest(request) { + return fetch(request) +}` + workerModuleScript = `export default { + async fetch(request, env, event) { + event.passThroughOnException() + return fetch(request) + } +}` + workerModuleScriptDownloadResponse = ` +--workermodulescriptdownload +Content-Disposition: form-data; name="worker.js" + +export default { + async fetch(request, env, event) { + event.passThroughOnException() + return fetch(request) + } +} +--workermodulescriptdownload-- +` +) + +var ( + successResponse = Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}} + deleteWorkerRouteResponseData = createWorkerRouteResponse + attachWorkerToDomainResponse = fmt.Sprintf(`{ + "result": { + "id": "e7a57d8746e74ae49c25994dadb421b1", + "zone_id": "%s", + "service":"test_script_1", + "hostname":"api4.example.com", + "environment":"production" + }, + "success": true, + "errors": [], + "messages": [] +}`, testZoneID) +) + +type ( + WorkersTestScriptResponse struct { + Script string `json:"script"` + UsageModel string `json:"usage_model,omitempty"` + Handlers []string `json:"handlers"` + ID string `json:"id,omitempty"` + ETAG string `json:"etag,omitempty"` + Size uint `json:"size,omitempty"` + CreatedOn string `json:"created_on,omitempty"` + ModifiedOn string `json:"modified_on,omitempty"` + LastDeployedFrom *string `json:"last_deployed_from,omitempty"` + DeploymentId *string `json:"deployment_id,omitempty"` + CompatibilityDate *string `json:"compatibility_date,omitempty"` + Logpush *bool `json:"logpush,omitempty"` + TailConsumers *[]WorkersTailConsumer `json:"tail_consumers,omitempty"` + PlacementMode *string `json:"placement_mode,omitempty"` + } + workersTestResponseOpt func(r *WorkersTestScriptResponse) +) + +var ( + expectedWorkersServiceWorkerScript = "addEventListener('fetch', event => {\n event.passThroughOnException()\n event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n return fetch(request)\n}" + expectedWorkersModuleWorkerScript = "export default {\n async fetch(request, env, event) {\n event.passThroughOnException()\n return fetch(request)\n }\n}" + WorkersDefaultTestResponse = WorkersTestScriptResponse{ + Script: expectedWorkersServiceWorkerScript, + Handlers: []string{"fetch"}, + UsageModel: "unbound", + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Size: 191, + LastDeployedFrom: StringPtr("dash"), + Logpush: BoolPtr(false), + CompatibilityDate: StringPtr("2022-07-12"), + } +) + +//nolint:unused +func withWorkerScript(content string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.Script = content } +} + +//nolint:unused +func withWorkerUsageModel(um string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.UsageModel = um } +} + +//nolint:unused +func withWorkerHandlers(h []string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.Handlers = h } +} + +//nolint:unused +func withWorkerID(id string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.ID = id } +} + +//nolint:unused +func withWorkerEtag(etag string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.ETAG = etag } +} + +//nolint:unused +func withWorkerSize(size uint) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.Size = size } +} + +//nolint:unused +func withWorkerCreatedOn(co time.Time) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.CreatedOn = co.Format(time.RFC3339Nano) } +} + +//nolint:unused +func withWorkerModifiedOn(mo time.Time) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.ModifiedOn = mo.Format(time.RFC3339Nano) } +} + +//nolint:unused +func withWorkerLogpush(logpush *bool) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.Logpush = logpush } +} + +//nolint:unused +func withWorkerPlacementMode(mode *string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.PlacementMode = mode } +} + +//nolint:unused +func withWorkerTailConsumers(consumers ...WorkersTailConsumer) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.TailConsumers = &consumers } +} + +//nolint:unused +func withWorkerLastDeployedFrom(from *string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.LastDeployedFrom = from } +} + +//nolint:unused +func withWorkerDeploymentId(dID *string) workersTestResponseOpt { + return func(r *WorkersTestScriptResponse) { r.DeploymentId = dID } +} + +func workersScriptResponse(t testing.TB, opts ...workersTestResponseOpt) string { + var responseConfig = WorkersDefaultTestResponse + for _, opt := range opts { + opt(&responseConfig) + } + + bytes, err := json.Marshal(struct { + Response + Result WorkersTestScriptResponse `json:"result"` + }{ + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + Result: responseConfig, + }) + require.NoError(t, err) + + return string(bytes) +} + +func getFormValue(r *http.Request, key string) ([]byte, error) { + err := r.ParseMultipartForm(1024 * 1024) + if err != nil { + return nil, err + } + + // In Go 1.10 there was a bug where field values with a content-type + // but without a filename would end up in Form.File but in versions + // before and after 1.10 they would be in form.Value. Here we check + // both in order to handle both scenarios + // https://golang.org/doc/go1.11#mime/multipart + + // pre/post v1.10 + if values, ok := r.MultipartForm.Value[key]; ok { + return []byte(values[0]), nil + } + + // v1.10 + if fileHeaders, ok := r.MultipartForm.File[key]; ok { + file, err := fileHeaders[0].Open() + if err != nil { + return nil, err + } + return io.ReadAll(file) + } + + return nil, fmt.Errorf("no value found for key %v", key) +} + +func getFileDetails(r *http.Request, key string) (*multipart.FileHeader, error) { + err := r.ParseMultipartForm(1024 * 1024) + if err != nil { + return nil, err + } + + fileHeaders := r.MultipartForm.File[key] + + if len(fileHeaders) > 0 { + return fileHeaders[0], nil + } + + return nil, fmt.Errorf("no value found for key %v", key) +} + +type multipartUpload struct { + Script string + BindingMeta map[string]workerBindingMeta + Logpush *bool + CompatibilityDate string + CompatibilityFlags []string + Placement *Placement + Tags []string +} + +func parseMultipartUpload(r *http.Request) (multipartUpload, error) { + // Parse the metadata + mdBytes, err := getFormValue(r, "metadata") + if err != nil { + return multipartUpload{}, err + } + + var metadata struct { + BodyPart string `json:"body_part,omitempty"` + MainModule string `json:"main_module,omitempty"` + Bindings []workerBindingMeta `json:"bindings"` + Logpush *bool `json:"logpush,omitempty"` + CompatibilityDate string `json:"compatibility_date,omitempty"` + CompatibilityFlags []string `json:"compatibility_flags,omitempty"` + Placement *Placement `json:"placement,omitempty"` + Tags []string `json:"tags"` + } + err = json.Unmarshal(mdBytes, &metadata) + if err != nil { + return multipartUpload{}, err + } + + // Get the script + script, err := getFormValue(r, metadata.BodyPart) + if err != nil { + script, err = getFormValue(r, metadata.MainModule) + + if err != nil { + return multipartUpload{}, err + } + } + + // Since bindings are specified in the Go API as a map but are uploaded as a + // JSON array, the ordering of uploaded bindings is non-deterministic. To make + // it easier to compare for equality without running into ordering issues, we + // convert it back to a map + bindingMeta := make(map[string]workerBindingMeta) + for _, binding := range metadata.Bindings { + bindingMeta[binding["name"].(string)] = binding + } + + return multipartUpload{ + Script: string(script), + BindingMeta: bindingMeta, + Logpush: metadata.Logpush, + CompatibilityDate: metadata.CompatibilityDate, + CompatibilityFlags: metadata.CompatibilityFlags, + Placement: metadata.Placement, + Tags: metadata.Tags, + }, nil +} + +func TestDeleteWorker(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", 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/javascript") + fmt.Fprint(w, deleteWorkerResponseData) + }) + + err := client.DeleteWorker(context.Background(), AccountIdentifier(testAccountID), DeleteWorkerParams{ScriptName: "bar"}) + assert.NoError(t, err) +} + +func TestDeleteNamespacedWorker(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces/foo/scripts/bar", 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/javascript") + fmt.Fprint(w, deleteWorkerResponseData) + }) + + err := client.DeleteWorker(context.Background(), AccountIdentifier(testAccountID), DeleteWorkerParams{ + ScriptName: "bar", + DispatchNamespace: &[]string{"foo"}[0], + }) + assert.NoError(t, err) +} + +func TestGetWorker(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo", 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/javascript") + fmt.Fprint(w, workerScript) + }) + res, err := client.GetWorker(context.Background(), AccountIdentifier(testAccountID), "foo") + want := WorkerScriptResponse{ + successResponse, + false, + WorkerScript{ + Script: workerScript, + }} + if assert.NoError(t, err) { + assert.Equal(t, want.Script, res.Script) + } +} + +func TestGetWorker_Module(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo", 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", "multipart/form-data; boundary=workermodulescriptdownload") + fmt.Fprint(w, workerModuleScriptDownloadResponse) + }) + + res, err := client.GetWorker(context.Background(), AccountIdentifier(testAccountID), "foo") + want := WorkerScriptResponse{ + successResponse, + true, + WorkerScript{ + Script: workerModuleScript, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Script, res.Script) + } +} + +func TestGetWorkerWithDispatchNamespace_Module(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/dispatch/namespaces/bar/scripts/foo/content", 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", "multipart/form-data; boundary=workermodulescriptdownload") + fmt.Fprint(w, workerModuleScriptDownloadResponse) + }) + + res, err := client.GetWorkerWithDispatchNamespace(context.Background(), AccountIdentifier(testAccountID), "foo", "bar") + want := WorkerScriptResponse{ + successResponse, + true, + WorkerScript{ + Script: workerModuleScript, + }, + } + + if assert.NoError(t, err) { + assert.Equal(t, want.Script, res.Script) + } +} + +func TestGetWorkersScriptContent(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo/content/v2", 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/javascript") + fmt.Fprint(w, workerScript) + }) + + res, err := client.GetWorkersScriptContent(context.Background(), AccountIdentifier(testAccountID), "foo") + want := workerScript + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestUpdateWorkersScriptContent(t *testing.T) { + setup() + defer teardown() + + formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z") + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo/content", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + contentTypeHeader := r.Header.Get("content-type") + assert.Equal(t, "application/javascript", contentTypeHeader, "Expected content-type request header to be 'application/javascript', got %s", contentTypeHeader) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t, withWorkerModifiedOn(formattedTime))) + }) + + res, err := client.UpdateWorkersScriptContent(context.Background(), AccountIdentifier(testAccountID), UpdateWorkersScriptContentParams{ScriptName: "foo", Script: workerScript}) + want := WorkerScriptResponse{ + successResponse, + false, + WorkerScript{ + Script: workerScript, + }, + } + if assert.NoError(t, err) { + assert.Equal(t, want.Script, res.Script) + } +} + +func TestGetWorkersScriptSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo/settings", 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/javascript") + fmt.Fprint(w, workerMetadata) + }) + + res, err := client.GetWorkersScriptSettings(context.Background(), AccountIdentifier(testAccountID), "foo") + logpush := true + want := WorkerScriptSettingsResponse{ + successResponse, + WorkerMetaData{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Logpush: &logpush, + }} + if assert.NoError(t, err) { + assert.Equal(t, want.WorkerMetaData, res.WorkerMetaData) + } +} + +func TestUpdateWorkersScriptSettings(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo/settings", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/javascript") + fmt.Fprint(w, workerMetadata) + }) + + res, err := client.UpdateWorkersScriptSettings(context.Background(), AccountIdentifier(testAccountID), UpdateWorkersScriptSettingsParams{ScriptName: "foo"}) + logpush := true + want := WorkerScriptSettingsResponse{ + successResponse, + WorkerMetaData{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Logpush: &logpush, + }} + if assert.NoError(t, err) { + assert.Equal(t, want.WorkerMetaData, res.WorkerMetaData) + } +} + +func TestListWorkers(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts", 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, listWorkersResponseData) + }) + + res, _, err := client.ListWorkers(context.Background(), AccountIdentifier(testAccountID), ListWorkersParams{}) + sampleDate, _ := time.Parse(time.RFC3339Nano, "2018-04-22T17:10:48.938097Z") + want := []WorkerMetaData{ + { + ID: "bar", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + CreatedOn: sampleDate, + ModifiedOn: sampleDate, + }, + { + ID: "baz", + ETAG: "380dg51e97e80b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43088b", + CreatedOn: sampleDate, + ModifiedOn: sampleDate, + }, + } + if assert.NoError(t, err) { + assert.Equal(t, want, res.WorkerList) + } +} + +func TestUploadWorker_Basic(t *testing.T) { + setup() + defer teardown() + + formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z") + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + contentTypeHeader := r.Header.Get("content-type") + assert.Equal(t, "application/javascript", contentTypeHeader, "Expected content-type request header to be 'application/javascript', got %s", contentTypeHeader) + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t, withWorkerModifiedOn(formattedTime))) + }) + res, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ScriptName: "foo", Script: workerScript}) + want := WorkerScriptResponse{ + successResponse, + false, + WorkerScript{ + Script: workerScript, + UsageModel: "unbound", + WorkerMetaData: WorkerMetaData{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Size: 191, + ModifiedOn: formattedTime, + Logpush: BoolPtr(false), + LastDeployedFrom: StringPtr("dash"), + }, + }} + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestUploadWorker_Module(t *testing.T) { + setup() + defer teardown() + + formattedCreatedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z") + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo", func(w http.ResponseWriter, r *http.Request) { + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + assert.Equal(t, workerModuleScript, mpUpload.Script) + + workerFileDetails, err := getFileDetails(r, "worker.mjs") + if !assert.NoError(t, err) { + assert.FailNow(t, "worker file not found in multipart form body") + } + contentTypeHeader := workerFileDetails.Header.Get("content-type") + expectedContentType := "application/javascript+module" + assert.Equal(t, expectedContentType, contentTypeHeader, "Expected content-type request header to be %s, got %s", expectedContentType, contentTypeHeader) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t, withWorkerScript(expectedWorkersModuleWorkerScript), withWorkerCreatedOn(formattedCreatedTime))) + }) + res, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ScriptName: "foo", Script: workerModuleScript, Module: true}) + want := WorkerScriptResponse{ + Response: successResponse, + Module: false, + WorkerScript: WorkerScript{ + Script: workerModuleScript, + UsageModel: "unbound", + WorkerMetaData: WorkerMetaData{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Size: 191, + CreatedOn: formattedCreatedTime, + Logpush: BoolPtr(false), + LastDeployedFrom: StringPtr("dash"), + }, + }} + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestUploadWorker_WithDurableObjectBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "durable_object_namespace", + "class_name": "TheClass", + "script_name": "the_script", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerDurableObjectBinding{ + ClassName: "TheClass", + ScriptName: "the_script", + }, + }, + }) + + assert.NoError(t, err) +} + +func TestUploadWorker_WithInheritBinding(t *testing.T) { + setup() + defer teardown() + + formattedTime, _ := time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z") + // Setup route handler for both single-script and multi-script + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "inherit", + }, + "b2": { + "name": "b2", + "type": "inherit", + "old_name": "old_binding_name", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t, withWorkerModifiedOn(formattedTime))) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + want := WorkerScriptResponse{ + Response: successResponse, + Module: false, + WorkerScript: WorkerScript{ + Script: workerScript, + UsageModel: "unbound", + WorkerMetaData: WorkerMetaData{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Size: 191, + ModifiedOn: formattedTime, + Logpush: BoolPtr(false), + LastDeployedFrom: StringPtr("dash"), + }, + }} + + res, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerInheritBinding{}, + "b2": WorkerInheritBinding{ + OldName: "old_binding_name", + }, + }}) + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestUploadWorker_WithKVBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "kv_namespace", + "namespace_id": "test-namespace", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerKvNamespaceBinding{ + NamespaceID: "test-namespace", + }, + }}) + assert.NoError(t, err) +} + +func TestUploadWorker_WithWasmBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + partName := mpUpload.BindingMeta["b1"]["part"].(string) + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "wasm_module", + "part": partName, + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + wasmContent, err := getFormValue(r, partName) + assert.NoError(t, err) + assert.Equal(t, []byte("fake-wasm"), wasmContent) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerWebAssemblyBinding{ + Module: strings.NewReader("fake-wasm"), + }, + }, + }) + + assert.NoError(t, err) +} + +func TestUploadWorker_WithPlainTextBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "plain_text", + "text": "plain text value", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerPlainTextBinding{ + Text: "plain text value", + }, + }, + }) + + assert.NoError(t, err) +} + +func TestUploadWorker_ModuleWithPlainTextBinding(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "plain_text", + "text": "plain text value", + }, + } + assert.Equal(t, workerModuleScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + workerFileDetails, err := getFileDetails(r, "worker.mjs") + if !assert.NoError(t, err) { + assert.FailNow(t, "worker file not found in multipart form body") + } + contentDispositonHeader := workerFileDetails.Header.Get("content-disposition") + expectedContentDisposition := fmt.Sprintf(`form-data; name="%s"; filename="%[1]s"`, "worker.mjs") + assert.Equal(t, expectedContentDisposition, contentDispositonHeader, "Expected content-disposition request header to be %s, got %s", expectedContentDisposition, contentDispositonHeader) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t, withWorkerScript(expectedWorkersModuleWorkerScript))) + }) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerModuleScript, + Module: true, + Bindings: map[string]WorkerBinding{ + "b1": WorkerPlainTextBinding{ + Text: "plain text value", + }, + }, + }) + + assert.NoError(t, err) +} + +func TestUploadWorker_WithSecretTextBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "secret_text", + "text": "secret text value", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerSecretTextBinding{ + Text: "secret text value", + }, + }, + }) + assert.NoError(t, err) +} + +func TestUploadWorker_WithServiceBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "service", + "service": "the_service", + }, + "b2": { + "name": "b2", + "type": "service", + "service": "the_service", + "environment": "the_environment", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerServiceBinding{ + Service: "the_service", + }, + "b2": WorkerServiceBinding{ + Service: "the_service", + Environment: StringPtr("the_environment"), + }, + }, + }) + assert.NoError(t, err) +} + +func TestUploadWorker_WithLogpush(t *testing.T) { + setup() + defer teardown() + + var ( + formattedTime, _ = time.Parse(time.RFC3339Nano, "2018-06-09T15:17:01.989141Z") + logpush = BoolPtr(true) + ) + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/foo", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expected := true + assert.Equal(t, &expected, mpUpload.Logpush) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t, withWorkerScript(expectedWorkersModuleWorkerScript), withWorkerLogpush(logpush), withWorkerModifiedOn(formattedTime))) + }) + res, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ScriptName: "foo", Script: workerScript, Logpush: logpush}) + want := WorkerScriptResponse{ + Response: successResponse, + Module: false, + WorkerScript: WorkerScript{ + Script: expectedWorkersModuleWorkerScript, + UsageModel: "unbound", + WorkerMetaData: WorkerMetaData{ + ID: "e7a57d8746e74ae49c25994dadb421b1", + ETAG: "279cf40d86d70b82f6cd3ba90a646b3ad995912da446836d7371c21c6a43977a", + Size: 191, + ModifiedOn: formattedTime, + Logpush: logpush, + LastDeployedFrom: StringPtr("dash"), + }, + }} + if assert.NoError(t, err) { + assert.Equal(t, want, res) + } +} + +func TestUploadWorker_WithCompatibilityFlags(t *testing.T) { + setup() + defer teardown() + + compatibilityDate := time.Now().Format("2006-01-02") + compatibilityFlags := []string{"formdata_parser_supports_files"} + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, compatibilityDate, mpUpload.CompatibilityDate) + assert.Equal(t, compatibilityFlags, mpUpload.CompatibilityFlags) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + CompatibilityDate: compatibilityDate, + CompatibilityFlags: compatibilityFlags, + }) + assert.NoError(t, err) +} + +func TestUploadWorker_WithQueueBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "queue", + "queue_name": "test-queue", + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": WorkerQueueBinding{ + Binding: "b1", + Queue: "test-queue", + }, + }}) + assert.NoError(t, err) +} + +func TestUploadWorker_WithDispatchNamespaceBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + expectedBindings := map[string]workerBindingMeta{ + "b1": { + "name": "b1", + "type": "dispatch_namespace", + "namespace": "n1", + "outbound": map[string]interface{}{ + "worker": map[string]interface{}{ + "service": "w1", + "environment": "e1", + }, + "params": []interface{}{ + map[string]interface{}{"name": "param1"}, + }, + }, + }, + } + assert.Equal(t, workerScript, mpUpload.Script) + assert.Equal(t, expectedBindings, mpUpload.BindingMeta) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + environmentName := "e1" + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": DispatchNamespaceBinding{ + Binding: "b1", + Namespace: "n1", + Outbound: &NamespaceOutboundOptions{ + Worker: WorkerReference{ + Service: "w1", + Environment: &environmentName, + }, + Params: []OutboundParamSchema{ + { + Name: "param1", + }, + }, + }, + }, + }}) + assert.NoError(t, err) +} + +func TestUploadWorker_WithSmartPlacementEnabled(t *testing.T) { + setup() + defer teardown() + + placementMode := PlacementModeSmart + response := workersScriptResponse(t, withWorkerScript(expectedWorkersModuleWorkerScript), withWorkerPlacementMode(StringPtr("smart"))) + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + assert.Equal(t, workerScript, mpUpload.Script) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + t.Run("Test enabling Smart Placement", func(t *testing.T) { + worker, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Placement: &Placement{ + Mode: placementMode, + }, + }) + assert.NoError(t, err) + assert.Equal(t, placementMode, *worker.PlacementMode) + }) + + t.Run("Test disabling placement", func(t *testing.T) { + placementMode = PlacementModeOff + response = workersScriptResponse(t, withWorkerScript(expectedWorkersModuleWorkerScript)) + + worker, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Placement: &Placement{ + Mode: placementMode, + }, + }) + assert.NoError(t, err) + assert.Nil(t, worker.PlacementMode) + }) +} + +func TestUploadWorker_WithTailConsumers(t *testing.T) { + setup() + defer teardown() + + response := workersScriptResponse(t, + withWorkerScript(expectedWorkersModuleWorkerScript)) + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + assert.NoError(t, err) + + assert.Equal(t, workerScript, mpUpload.Script) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, response) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + t.Run("adds tail consumers", func(t *testing.T) { + tailConsumers := []WorkersTailConsumer{ + {Service: "my-service-a"}, + {Service: "my-service-b", Environment: StringPtr("production")}, + {Service: "a-namespaced-service", Namespace: StringPtr("a-dispatch-namespace")}, + } + response = workersScriptResponse(t, + withWorkerScript(expectedWorkersModuleWorkerScript), + withWorkerTailConsumers(tailConsumers...)) + + worker, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + TailConsumers: &tailConsumers, + }) + assert.NoError(t, err) + require.NotNil(t, worker.TailConsumers) + assert.Len(t, *worker.TailConsumers, 3) + }) +} + +func TestUploadWorker_ToDispatchNamespace(t *testing.T) { + setup() + defer teardown() + + namespaceName := "n1" + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + require.NoError(t, err) + + assert.Equal(t, workerScript, mpUpload.Script) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc( + fmt.Sprintf("/accounts/"+testAccountID+"/workers/dispatch/namespaces/%s/scripts/bar", namespaceName), + handler, + ) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + DispatchNamespaceName: &namespaceName, + Bindings: map[string]WorkerBinding{ + "b1": WorkerPlainTextBinding{ + Text: "hello", + }, + }, + }) + assert.NoError(t, err) +} + +func TestUploadWorker_ToDispatchNamespace_Tags(t *testing.T) { + setup() + defer teardown() + + namespaceName := "n1" + tags := []string{"hello=there", "another-tag"} + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + require.NoError(t, err) + + assert.Equal(t, workerScript, mpUpload.Script) + + assert.EqualValues(t, tags, mpUpload.Tags) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc( + fmt.Sprintf("/accounts/"+testAccountID+"/workers/dispatch/namespaces/%s/scripts/bar", namespaceName), + handler, + ) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + DispatchNamespaceName: &namespaceName, + Tags: tags, + }) + assert.NoError(t, err) +} + +func TestUploadWorker_UnsafeBinding(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + mpUpload, err := parseMultipartUpload(r) + require.NoError(t, err) + + assert.Equal(t, workerScript, mpUpload.Script) + + require.Contains(t, mpUpload.BindingMeta, "b1") + assert.Contains(t, mpUpload.BindingMeta["b1"], "name") + assert.Equal(t, "b1", mpUpload.BindingMeta["b1"]["name"]) + assert.Contains(t, mpUpload.BindingMeta["b1"], "type") + assert.Equal(t, "dynamic_dispatch", mpUpload.BindingMeta["b1"]["type"]) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, workersScriptResponse(t)) + } + mux.HandleFunc("/accounts/"+testAccountID+"/workers/scripts/bar", handler) + + _, err := client.UploadWorker(context.Background(), AccountIdentifier(testAccountID), CreateWorkerParams{ + ScriptName: "bar", + Script: workerScript, + Bindings: map[string]WorkerBinding{ + "b1": UnsafeBinding{ + "type": "dynamic_dispatch", + }, + }, + }) + assert.NoError(t, err) +} diff --git a/pkg/cloudflare-go/zaraz.go b/pkg/cloudflare-go/zaraz.go new file mode 100644 index 000000000..3ffc4f5ed --- /dev/null +++ b/pkg/cloudflare-go/zaraz.go @@ -0,0 +1,430 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type ZarazConfig struct { + DebugKey string `json:"debugKey"` + Tools map[string]ZarazTool `json:"tools"` + Triggers map[string]ZarazTrigger `json:"triggers"` + ZarazVersion int64 `json:"zarazVersion"` + Consent ZarazConsent `json:"consent,omitempty"` + DataLayer *bool `json:"dataLayer,omitempty"` + Dlp []any `json:"dlp,omitempty"` + HistoryChange *bool `json:"historyChange,omitempty"` + Settings ZarazConfigSettings `json:"settings,omitempty"` + Variables map[string]ZarazVariable `json:"variables,omitempty"` +} + +type ZarazWorker struct { + EscapedWorkerName string `json:"escapedWorkerName"` + WorkerTag string `json:"workerTag"` + MutableId string `json:"mutableId,omitempty"` +} +type ZarazConfigSettings struct { + AutoInjectScript *bool `json:"autoInjectScript"` + InjectIframes *bool `json:"injectIframes,omitempty"` + Ecommerce *bool `json:"ecommerce,omitempty"` + HideQueryParams *bool `json:"hideQueryParams,omitempty"` + HideIpAddress *bool `json:"hideIPAddress,omitempty"` + HideUserAgent *bool `json:"hideUserAgent,omitempty"` + HideExternalReferer *bool `json:"hideExternalReferer,omitempty"` + CookieDomain string `json:"cookieDomain,omitempty"` + InitPath string `json:"initPath,omitempty"` + ScriptPath string `json:"scriptPath,omitempty"` + TrackPath string `json:"trackPath,omitempty"` + EventsApiPath string `json:"eventsApiPath,omitempty"` + McRootPath string `json:"mcRootPath,omitempty"` + ContextEnricher ZarazWorker `json:"contextEnricher,omitempty"` +} + +// Deprecated: To be removed pending migration of existing configs. +type ZarazNeoEvent struct { + BlockingTriggers []string `json:"blockingTriggers"` + FiringTriggers []string `json:"firingTriggers"` + Data map[string]any `json:"data"` + ActionType string `json:"actionType,omitempty"` +} + +type ZarazAction struct { + BlockingTriggers []string `json:"blockingTriggers"` + FiringTriggers []string `json:"firingTriggers"` + Data map[string]any `json:"data"` + ActionType string `json:"actionType,omitempty"` +} + +type ZarazToolType string + +const ( + ZarazToolLibrary ZarazToolType = "library" + ZarazToolComponent ZarazToolType = "component" + ZarazToolCustomMc ZarazToolType = "custom-mc" +) + +type ZarazTool struct { + BlockingTriggers []string `json:"blockingTriggers"` + Enabled *bool `json:"enabled"` + DefaultFields map[string]any `json:"defaultFields"` + Name string `json:"name"` + NeoEvents []ZarazNeoEvent `json:"neoEvents"` + Actions map[string]ZarazAction `json:"actions"` + Type ZarazToolType `json:"type"` + DefaultPurpose string `json:"defaultPurpose,omitempty"` + Library string `json:"library,omitempty"` + Component string `json:"component,omitempty"` + Permissions []string `json:"permissions"` + Settings map[string]any `json:"settings"` + Worker ZarazWorker `json:"worker,omitempty"` +} + +type ZarazTriggerSystem string + +const ZarazPageload ZarazTriggerSystem = "pageload" + +type ZarazLoadRuleOp string + +type ZarazRuleType string + +const ( + ZarazClickListener ZarazRuleType = "clickListener" + ZarazTimer ZarazRuleType = "timer" + ZarazFormSubmission ZarazRuleType = "formSubmission" + ZarazVariableMatch ZarazRuleType = "variableMatch" + ZarazScrollDepth ZarazRuleType = "scrollDepth" + ZarazElementVisibility ZarazRuleType = "elementVisibility" + ZarazClientEval ZarazRuleType = "clientEval" +) + +type ZarazSelectorType string + +const ( + ZarazXPath ZarazSelectorType = "xpath" + ZarazCSS ZarazSelectorType = "css" +) + +type ZarazRuleSettings struct { + Type ZarazSelectorType `json:"type,omitempty"` + Selector string `json:"selector,omitempty"` + WaitForTags int `json:"waitForTags,omitempty"` + Interval int `json:"interval,omitempty"` + Limit int `json:"limit,omitempty"` + Validate *bool `json:"validate,omitempty"` + Variable string `json:"variable,omitempty"` + Match string `json:"match,omitempty"` + Positions string `json:"positions,omitempty"` + Op ZarazLoadRuleOp `json:"op,omitempty"` + Value string `json:"value,omitempty"` +} + +type ZarazTriggerRule struct { + Id string `json:"id"` + Match string `json:"match,omitempty"` + Op ZarazLoadRuleOp `json:"op,omitempty"` + Value string `json:"value,omitempty"` + Action ZarazRuleType `json:"action"` + Settings ZarazRuleSettings `json:"settings"` +} + +type ZarazTrigger struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + LoadRules []ZarazTriggerRule `json:"loadRules"` + ExcludeRules []ZarazTriggerRule `json:"excludeRules"` + ClientRules []any `json:"clientRules,omitempty"` // what is this? + System ZarazTriggerSystem `json:"system,omitempty"` +} + +type ZarazVariableType string + +const ( + ZarazVarString ZarazVariableType = "string" + ZarazVarSecret ZarazVariableType = "secret" + ZarazVarWorker ZarazVariableType = "worker" +) + +type ZarazVariable struct { + Name string `json:"name"` + Type ZarazVariableType `json:"type"` + Value interface{} `json:"value"` +} + +type ZarazButtonTextTranslations struct { + AcceptAll map[string]string `json:"accept_all"` + RejectAll map[string]string `json:"reject_all"` + ConfirmMyChoices map[string]string `json:"confirm_my_choices"` +} + +type ZarazPurpose struct { + Name string `json:"name"` + Description string `json:"description"` +} + +type ZarazPurposeWithTranslations struct { + Name map[string]string `json:"name"` + Description map[string]string `json:"description"` + Order int `json:"order"` +} + +type ZarazConsent struct { + Enabled *bool `json:"enabled"` + ButtonTextTranslations ZarazButtonTextTranslations `json:"buttonTextTranslations,omitempty"` + CompanyEmail string `json:"companyEmail,omitempty"` + CompanyName string `json:"companyName,omitempty"` + CompanyStreetAddress string `json:"companyStreetAddress,omitempty"` + ConsentModalIntroHTML string `json:"consentModalIntroHTML,omitempty"` + ConsentModalIntroHTMLWithTranslations map[string]string `json:"consentModalIntroHTMLWithTranslations,omitempty"` + CookieName string `json:"cookieName,omitempty"` + CustomCSS string `json:"customCSS,omitempty"` + CustomIntroDisclaimerDismissed *bool `json:"customIntroDisclaimerDismissed,omitempty"` + DefaultLanguage string `json:"defaultLanguage,omitempty"` + HideModal *bool `json:"hideModal,omitempty"` + Purposes map[string]ZarazPurpose `json:"purposes,omitempty"` + PurposesWithTranslations map[string]ZarazPurposeWithTranslations `json:"purposesWithTranslations,omitempty"` +} + +type ZarazConfigResponse struct { + Result ZarazConfig `json:"result"` + Response +} + +type ZarazWorkflowResponse struct { + Result string `json:"result"` + Response +} + +type ZarazPublishResponse struct { + Result string `json:"result"` + Response +} + +type UpdateZarazConfigParams struct { + DebugKey string `json:"debugKey"` + Tools map[string]ZarazTool `json:"tools"` + Triggers map[string]ZarazTrigger `json:"triggers"` + ZarazVersion int64 `json:"zarazVersion"` + Consent ZarazConsent `json:"consent,omitempty"` + DataLayer *bool `json:"dataLayer,omitempty"` + Dlp []any `json:"dlp,omitempty"` + HistoryChange *bool `json:"historyChange,omitempty"` + Settings ZarazConfigSettings `json:"settings,omitempty"` + Variables map[string]ZarazVariable `json:"variables,omitempty"` +} + +type UpdateZarazWorkflowParams struct { + Workflow string `json:"workflow"` +} + +type PublishZarazConfigParams struct { + Description string `json:"description"` +} + +type ZarazHistoryRecord struct { + ID int64 `json:"id,omitempty"` + UserID string `json:"userId,omitempty"` + Description string `json:"description,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type ZarazConfigHistoryListResponse struct { + Result []ZarazHistoryRecord `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +type ListZarazConfigHistoryParams struct { + ResultInfo +} + +type GetZarazConfigsByIdResponse = map[string]interface{} + +// listZarazConfigHistoryDefaultPageSize represents the default per_page size of the API. +var listZarazConfigHistoryDefaultPageSize int = 100 + +func (api *API) GetZarazConfig(ctx context.Context, rc *ResourceContainer) (ZarazConfigResponse, error) { + if rc.Identifier == "" { + return ZarazConfigResponse{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/config", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZarazConfigResponse{}, err + } + + var recordResp ZarazConfigResponse + err = json.Unmarshal(res, &recordResp) + if err != nil { + return ZarazConfigResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return recordResp, nil +} + +func (api *API) UpdateZarazConfig(ctx context.Context, rc *ResourceContainer, params UpdateZarazConfigParams) (ZarazConfigResponse, error) { + if rc.Identifier == "" { + return ZarazConfigResponse{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/config", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params) + if err != nil { + return ZarazConfigResponse{}, err + } + + var updateResp ZarazConfigResponse + err = json.Unmarshal(res, &updateResp) + if err != nil { + return ZarazConfigResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return updateResp, nil +} + +func (api *API) GetZarazWorkflow(ctx context.Context, rc *ResourceContainer) (ZarazWorkflowResponse, error) { + if rc.Identifier == "" { + return ZarazWorkflowResponse{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/workflow", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZarazWorkflowResponse{}, err + } + + var response ZarazWorkflowResponse + err = json.Unmarshal(res, &response) + if err != nil { + return ZarazWorkflowResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +func (api *API) UpdateZarazWorkflow(ctx context.Context, rc *ResourceContainer, params UpdateZarazWorkflowParams) (ZarazWorkflowResponse, error) { + if rc.Identifier == "" { + return ZarazWorkflowResponse{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/workflow", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, params.Workflow) + if err != nil { + return ZarazWorkflowResponse{}, err + } + + var response ZarazWorkflowResponse + err = json.Unmarshal(res, &response) + if err != nil { + return ZarazWorkflowResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +func (api *API) PublishZarazConfig(ctx context.Context, rc *ResourceContainer, params PublishZarazConfigParams) (ZarazPublishResponse, error) { + if rc.Identifier == "" { + return ZarazPublishResponse{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/publish", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, params.Description) + if err != nil { + return ZarazPublishResponse{}, err + } + + var response ZarazPublishResponse + err = json.Unmarshal(res, &response) + if err != nil { + return ZarazPublishResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +func (api *API) ListZarazConfigHistory(ctx context.Context, rc *ResourceContainer, params ListZarazConfigHistoryParams) ([]ZarazHistoryRecord, *ResultInfo, error) { + if rc.Identifier == "" { + return nil, nil, ErrMissingZoneID + } + + autoPaginate := true + if params.PerPage >= 1 || params.Page >= 1 { + autoPaginate = false + } + + if params.PerPage < 1 { + params.PerPage = listZarazConfigHistoryDefaultPageSize + } + + if params.Page < 1 { + params.Page = 1 + } + + var records []ZarazHistoryRecord + var lastResultInfo ResultInfo + + for { + uri := buildURI(fmt.Sprintf("/zones/%s/settings/zaraz/v2/history", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []ZarazHistoryRecord{}, &ResultInfo{}, err + } + var listResponse ZarazConfigHistoryListResponse + err = json.Unmarshal(res, &listResponse) + if err != nil { + return []ZarazHistoryRecord{}, &ResultInfo{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + records = append(records, listResponse.Result...) + lastResultInfo = listResponse.ResultInfo + params.ResultInfo = listResponse.ResultInfo.Next() + if params.ResultInfo.Done() || !autoPaginate { + break + } + } + return records, &lastResultInfo, nil +} + +func (api *API) GetDefaultZarazConfig(ctx context.Context, rc *ResourceContainer) (ZarazConfigResponse, error) { + if rc.Identifier == "" { + return ZarazConfigResponse{}, ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/default", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZarazConfigResponse{}, err + } + + var recordResp ZarazConfigResponse + err = json.Unmarshal(res, &recordResp) + if err != nil { + return ZarazConfigResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return recordResp, nil +} + +func (api *API) ExportZarazConfig(ctx context.Context, rc *ResourceContainer) error { + if rc.Identifier == "" { + return ErrMissingZoneID + } + + uri := fmt.Sprintf("/zones/%s/settings/zaraz/v2/export", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return err + } + + var recordResp ZarazConfig + err = json.Unmarshal(res, &recordResp) + if err != nil { + return fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return nil +} diff --git a/pkg/cloudflare-go/zaraz_test.go b/pkg/cloudflare-go/zaraz_test.go new file mode 100644 index 000000000..0087c9334 --- /dev/null +++ b/pkg/cloudflare-go/zaraz_test.go @@ -0,0 +1,996 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var trueValue bool = true +var falseValue bool = false + +var expectedConfig ZarazConfig = ZarazConfig{ + DebugKey: "cheese", + ZarazVersion: 44, + DataLayer: &trueValue, + Dlp: []any{}, + HistoryChange: &trueValue, + Settings: ZarazConfigSettings{ + AutoInjectScript: &trueValue, + }, + Tools: map[string]ZarazTool{ + "PBQr": { + BlockingTriggers: []string{}, + Enabled: &trueValue, + DefaultFields: map[string]any{}, + Name: "Custom HTML", + Actions: map[string]ZarazAction{ + "7ccae28d-5e00-4f0b-a491-519ecde998c8": { + ActionType: "event", + BlockingTriggers: []string{}, + Data: map[string]any{ + "__zaraz_setting_name": "pageview1", + "htmlCode": "", + }, + FiringTriggers: []string{"Pageview"}, + }, + }, + Type: ZarazToolComponent, + DefaultPurpose: "rJJC", + Component: "html", + Permissions: []string{"execute_unsafe_scripts"}, + Settings: map[string]any{}, + }, + }, + Triggers: map[string]ZarazTrigger{ + "Pageview": { + Name: "Pageview", + Description: "All page loads", + LoadRules: []ZarazTriggerRule{{Match: "{{ client.__zarazTrack }}", Op: "EQUALS", Value: "Pageview"}}, + ExcludeRules: []ZarazTriggerRule{}, + ClientRules: []any{}, + System: ZarazPageload, + }, + "TFOl": { + Name: "test", + Description: "", + LoadRules: []ZarazTriggerRule{{Id: "Kqsc", Match: "test", Op: "CONTAINS", Value: "test"}, {Id: "EDnV", Action: ZarazClickListener, Settings: ZarazRuleSettings{Selector: "test", Type: ZarazCSS}}}, + ExcludeRules: []ZarazTriggerRule{}, + }, + }, + Variables: map[string]ZarazVariable{ + "jwIx": { + Name: "test", + Type: ZarazVarString, + Value: "sss", + }, + "pAuL": { + Name: "test-worker-var", + Type: ZarazVarWorker, + Value: map[string]interface{}{ + "escapedWorkerName": "worker-var-example", + "mutableId": "m.zpt3q__WyW-61WM2qwgGoBl4Nxg-sfBsaMhu9NayjwU", + "workerTag": "68aba570db9d4ec5b159624e2f7ad8bf", + }, + }, + }, + Consent: ZarazConsent{ + Enabled: &trueValue, + ButtonTextTranslations: ZarazButtonTextTranslations{ + AcceptAll: map[string]string{"en": "Accept ALL"}, + ConfirmMyChoices: map[string]string{"en": "YES!"}, + RejectAll: map[string]string{"en": "Reject ALL"}, + }, + CompanyEmail: "email@example.com", + ConsentModalIntroHTMLWithTranslations: map[string]string{"en": "Lorem ipsum dolar set Amet?"}, + CookieName: "zaraz-consent", + CustomCSS: ".test {\n color: red;\n}", + CustomIntroDisclaimerDismissed: &trueValue, + DefaultLanguage: "en", + HideModal: &falseValue, + PurposesWithTranslations: map[string]ZarazPurposeWithTranslations{ + "rJJC": { + Description: map[string]string{"en": "Blah blah"}, + Name: map[string]string{"en": "Analytics"}, + Order: 0, + }, + }, + }, +} + +func TestGetZarazConfig(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "errors": [], + "messages": [], + "success": true, + "result": { + "consent": { + "buttonTextTranslations": { + "accept_all": { + "en": "Accept ALL" + }, + "confirm_my_choices": { + "en": "YES!" + }, + "reject_all": { + "en": "Reject ALL" + } + }, + "companyEmail": "email@example.com", + "consentModalIntroHTMLWithTranslations": { + "en": "Lorem ipsum dolar set Amet?" + }, + "cookieName": "zaraz-consent", + "customCSS": ".test {\n color: red;\n}", + "customIntroDisclaimerDismissed": true, + "defaultLanguage": "en", + "enabled": true, + "hideModal": false, + "purposesWithTranslations": { + "rJJC": { + "description": { + "en": "Blah blah" + }, + "name": { + "en": "Analytics" + }, + "order": 0 + } + } + }, + "dataLayer": true, + "debugKey": "cheese", + "dlp": [], + "historyChange": true, + "invalidKey": "cheese", + "settings": { + "autoInjectScript": true + }, + "tools": { + "PBQr": { + "blockingTriggers": [], + "component": "html", + "defaultFields": {}, + "defaultPurpose": "rJJC", + "enabled": true, + "mode": { + "cloud": false, + "ignoreSPA": true, + "light": false, + "sample": false, + "segment": { + "end": 100, + "start": 0 + }, + "trigger": "pageload" + }, + "name": "Custom HTML", + "actions": { + "7ccae28d-5e00-4f0b-a491-519ecde998c8": { + "actionType": "event", + "blockingTriggers": [], + "data": { + "__zaraz_setting_name": "pageview1", + "htmlCode": "" + }, + "firingTriggers": [ + "Pageview" + ] + } + }, + "permissions": [ + "execute_unsafe_scripts" + ], + "settings": {}, + "type": "component" + } + }, + "triggers": { + "Pageview": { + "clientRules": [], + "description": "All page loads", + "excludeRules": [], + "loadRules": [ + { + "match": "{{ client.__zarazTrack }}", + "op": "EQUALS", + "value": "Pageview" + } + ], + "name": "Pageview", + "system": "pageload" + }, + "TFOl": { + "description": "", + "excludeRules": [], + "loadRules": [ + { + "id": "Kqsc", + "match": "test", + "op": "CONTAINS", + "value": "test" + }, + { + "action": "clickListener", + "id": "EDnV", + "settings": { + "selector": "test", + "type": "css", + "waitForTags": 0 + } + } + ], + "name": "test" + } + }, + "variables": { + "jwIx": { + "name": "test", + "type": "string", + "value": "sss" + }, + "pAuL": { + "name": "test-worker-var", + "type": "worker", + "value": { + "escapedWorkerName": "worker-var-example", + "mutableId": "m.zpt3q__WyW-61WM2qwgGoBl4Nxg-sfBsaMhu9NayjwU", + "workerTag": "68aba570db9d4ec5b159624e2f7ad8bf" + } + } + }, + "zarazVersion": 44 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/config", handler) + + actual, err := client.GetZarazConfig(context.Background(), ZoneIdentifier(testZoneID)) + require.NoError(t, err) + + assert.Equal(t, expectedConfig, actual.Result) +} + +func TestUpdateZarazConfig(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "errors": [], + "messages": [], + "success": true, + "result": { + "consent": { + "buttonTextTranslations": { + "accept_all": { + "en": "Accept ALL" + }, + "confirm_my_choices": { + "en": "YES!" + }, + "reject_all": { + "en": "Reject ALL" + } + }, + "companyEmail": "email@example.com", + "consentModalIntroHTMLWithTranslations": { + "en": "Lorem ipsum dolar set Amet?" + }, + "cookieName": "zaraz-consent", + "customCSS": ".test {\n color: red;\n}", + "customIntroDisclaimerDismissed": true, + "defaultLanguage": "en", + "enabled": true, + "hideModal": false, + "purposesWithTranslations": { + "rJJC": { + "description": { + "en": "Blah blah" + }, + "name": { + "en": "Analytics" + }, + "order": 0 + } + } + }, + "dataLayer": true, + "debugKey": "butter", + "dlp": [], + "historyChange": true, + "invalidKey": "cheese", + "settings": { + "autoInjectScript": true + }, + "tools": { + "PBQr": { + "blockingTriggers": [], + "component": "html", + "defaultFields": {}, + "defaultPurpose": "rJJC", + "enabled": true, + "mode": { + "cloud": false, + "ignoreSPA": true, + "light": false, + "sample": false, + "segment": { + "end": 100, + "start": 0 + }, + "trigger": "pageload" + }, + "name": "Custom HTML", + "actions": { + "7ccae28d-5e00-4f0b-a491-519ecde998c8": { + "actionType": "event", + "blockingTriggers": [], + "data": { + "__zaraz_setting_name": "pageview1", + "htmlCode": "" + }, + "firingTriggers": [ + "Pageview" + ] + } + }, + "permissions": [ + "execute_unsafe_scripts" + ], + "settings": {}, + "type": "component" + } + }, + "triggers": { + "Pageview": { + "clientRules": [], + "description": "All page loads", + "excludeRules": [], + "loadRules": [ + { + "match": "{{ client.__zarazTrack }}", + "op": "EQUALS", + "value": "Pageview" + } + ], + "name": "Pageview", + "system": "pageload" + }, + "TFOl": { + "description": "", + "excludeRules": [], + "loadRules": [ + { + "id": "Kqsc", + "match": "test", + "op": "CONTAINS", + "value": "test" + }, + { + "action": "clickListener", + "id": "EDnV", + "settings": { + "selector": "test", + "type": "css", + "waitForTags": 0 + } + } + ], + "name": "test" + } + }, + "variables": { + "jwIx": { + "name": "test", + "type": "string", + "value": "sss" + }, + "pAuL": { + "name": "test-worker-var", + "type": "worker", + "value": { + "escapedWorkerName": "worker-var-example", + "mutableId": "m.zpt3q__WyW-61WM2qwgGoBl4Nxg-sfBsaMhu9NayjwU", + "workerTag": "68aba570db9d4ec5b159624e2f7ad8bf" + } + } + }, + "zarazVersion": 44 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/config", handler) + payload := UpdateZarazConfigParams{ + DebugKey: "cheese", + ZarazVersion: 44, + DataLayer: &trueValue, + Dlp: []any{}, + HistoryChange: &trueValue, + Settings: ZarazConfigSettings{ + AutoInjectScript: &trueValue, + }, + Tools: map[string]ZarazTool{ + "PBQr": { + BlockingTriggers: []string{}, + Enabled: &trueValue, + DefaultFields: map[string]any{}, + Name: "Custom HTML", + Actions: map[string]ZarazAction{ + "7ccae28d-5e00-4f0b-a491-519ecde998c8": { + ActionType: "event", + BlockingTriggers: []string{}, + Data: map[string]any{ + "__zaraz_setting_name": "pageview1", + "htmlCode": "", + }, + FiringTriggers: []string{"Pageview"}, + }, + }, + Type: ZarazToolComponent, + DefaultPurpose: "rJJC", + Component: "html", + Permissions: []string{"execute_unsafe_scripts"}, + Settings: map[string]any{}, + }, + }, + Triggers: map[string]ZarazTrigger{ + "Pageview": { + Name: "Pageview", + Description: "All page loads", + LoadRules: []ZarazTriggerRule{{Match: "{{ client.__zarazTrack }}", Op: "EQUALS", Value: "Pageview"}}, + ExcludeRules: []ZarazTriggerRule{}, + ClientRules: []any{}, + System: ZarazPageload, + }, + "TFOl": { + Name: "test", + Description: "", + LoadRules: []ZarazTriggerRule{{Id: "Kqsc", Match: "test", Op: "CONTAINS", Value: "test"}, {Id: "EDnV", Action: ZarazClickListener, Settings: ZarazRuleSettings{Selector: "test", Type: ZarazCSS}}}, + ExcludeRules: []ZarazTriggerRule{}, + }, + }, + Variables: map[string]ZarazVariable{ + "jwIx": { + Name: "test", + Type: ZarazVarString, + Value: "sss", + }, + "pAuL": { + Name: "test-worker-var", + Type: ZarazVarWorker, + Value: map[string]interface{}{ + "escapedWorkerName": "worker-var-example", + "mutableId": "m.zpt3q__WyW-61WM2qwgGoBl4Nxg-sfBsaMhu9NayjwU", + "workerTag": "68aba570db9d4ec5b159624e2f7ad8bf", + }, + }, + }, + Consent: ZarazConsent{ + Enabled: &trueValue, + ButtonTextTranslations: ZarazButtonTextTranslations{ + AcceptAll: map[string]string{"en": "Accept ALL"}, + ConfirmMyChoices: map[string]string{"en": "YES!"}, + RejectAll: map[string]string{"en": "Reject ALL"}, + }, + CompanyEmail: "email@example.com", + ConsentModalIntroHTMLWithTranslations: map[string]string{"en": "Lorem ipsum dolar set Amet?"}, + CookieName: "zaraz-consent", + CustomCSS: ".test {\n color: red;\n}", + CustomIntroDisclaimerDismissed: &trueValue, + DefaultLanguage: "en", + HideModal: &falseValue, + PurposesWithTranslations: map[string]ZarazPurposeWithTranslations{ + "rJJC": { + Description: map[string]string{"en": "Blah blah"}, + Name: map[string]string{"en": "Analytics"}, + Order: 0, + }, + }, + }, + } + payload.DebugKey = "butter" // Updating config + modifiedConfig := expectedConfig + modifiedConfig.DebugKey = "butter" // Updating config + expected := ZarazConfigResponse{ + Result: modifiedConfig, + } + + actual, err := client.UpdateZarazConfig(context.Background(), ZoneIdentifier(testZoneID), payload) + require.NoError(t, err) + + assert.Equal(t, expected.Result, actual.Result) +} + +func TestUpdateDeprecatedZarazConfig(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprint(w, `{ + "errors": [], + "messages": [], + "success": true, + "result": { + "consent": { + "buttonTextTranslations": { + "accept_all": { + "en": "Accept ALL" + }, + "confirm_my_choices": { + "en": "YES!" + }, + "reject_all": { + "en": "Reject ALL" + } + }, + "companyEmail": "email@example.com", + "consentModalIntroHTMLWithTranslations": { + "en": "Lorem ipsum dolar set Amet?" + }, + "cookieName": "zaraz-consent", + "customCSS": ".test {\n color: red;\n}", + "customIntroDisclaimerDismissed": true, + "defaultLanguage": "en", + "enabled": true, + "hideModal": false, + "purposesWithTranslations": { + "rJJC": { + "description": { + "en": "Blah blah" + }, + "name": { + "en": "Analytics" + }, + "order": 0 + } + } + }, + "dataLayer": true, + "debugKey": "butter", + "dlp": [], + "historyChange": true, + "invalidKey": "cheese", + "settings": { + "autoInjectScript": true + }, + "tools": { + "PBQr": { + "blockingTriggers": [], + "component": "html", + "defaultFields": {}, + "defaultPurpose": "rJJC", + "enabled": true, + "mode": { + "cloud": false, + "ignoreSPA": true, + "light": false, + "sample": false, + "segment": { + "end": 100, + "start": 0 + }, + "trigger": "pageload" + }, + "name": "Custom HTML", + "actions": { + "7ccae28d-5e00-4f0b-a491-519ecde998c8": { + "actionType": "event", + "blockingTriggers": [], + "data": { + "__zaraz_setting_name": "pageview1", + "htmlCode": "" + }, + "firingTriggers": [ + "Pageview" + ] + } + }, + "permissions": [ + "execute_unsafe_scripts" + ], + "settings": {}, + "type": "component" + } + }, + "triggers": { + "Pageview": { + "clientRules": [], + "description": "All page loads", + "excludeRules": [], + "loadRules": [ + { + "match": "{{ client.__zarazTrack }}", + "op": "EQUALS", + "value": "Pageview" + } + ], + "name": "Pageview", + "system": "pageload" + }, + "TFOl": { + "description": "", + "excludeRules": [], + "loadRules": [ + { + "id": "Kqsc", + "match": "test", + "op": "CONTAINS", + "value": "test" + }, + { + "action": "clickListener", + "id": "EDnV", + "settings": { + "selector": "test", + "type": "css", + "waitForTags": 0 + } + } + ], + "name": "test" + } + }, + "variables": { + "jwIx": { + "name": "test", + "type": "string", + "value": "sss" + }, + "pAuL": { + "name": "test-worker-var", + "type": "worker", + "value": { + "escapedWorkerName": "worker-var-example", + "mutableId": "m.zpt3q__WyW-61WM2qwgGoBl4Nxg-sfBsaMhu9NayjwU", + "workerTag": "68aba570db9d4ec5b159624e2f7ad8bf" + } + } + }, + "zarazVersion": 44 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/config", handler) + payload := UpdateZarazConfigParams{ + DebugKey: "cheese", + ZarazVersion: 44, + DataLayer: &trueValue, + Dlp: []any{}, + HistoryChange: &trueValue, + Settings: ZarazConfigSettings{ + AutoInjectScript: &trueValue, + }, + Tools: map[string]ZarazTool{ + "PBQr": { + BlockingTriggers: []string{}, + Enabled: &trueValue, + DefaultFields: map[string]any{}, + Name: "Custom HTML", + Actions: map[string]ZarazAction{ + "7ccae28d-5e00-4f0b-a491-519ecde998c8": { + ActionType: "event", + BlockingTriggers: []string{}, + Data: map[string]any{ + "__zaraz_setting_name": "pageview1", + "htmlCode": "", + }, + FiringTriggers: []string{"Pageview"}, + }, + }, + Type: ZarazToolComponent, + DefaultPurpose: "rJJC", + Component: "html", + Permissions: []string{"execute_unsafe_scripts"}, + Settings: map[string]any{}, + }, + }, + Triggers: map[string]ZarazTrigger{ + "Pageview": { + Name: "Pageview", + Description: "All page loads", + LoadRules: []ZarazTriggerRule{{Match: "{{ client.__zarazTrack }}", Op: "EQUALS", Value: "Pageview"}}, + ExcludeRules: []ZarazTriggerRule{}, + ClientRules: []any{}, + System: ZarazPageload, + }, + "TFOl": { + Name: "test", + Description: "", + LoadRules: []ZarazTriggerRule{{Id: "Kqsc", Match: "test", Op: "CONTAINS", Value: "test"}, {Id: "EDnV", Action: ZarazClickListener, Settings: ZarazRuleSettings{Selector: "test", Type: ZarazCSS}}}, + ExcludeRules: []ZarazTriggerRule{}, + }, + }, + Variables: map[string]ZarazVariable{ + "jwIx": { + Name: "test", + Type: ZarazVarString, + Value: "sss", + }, + "pAuL": { + Name: "test-worker-var", + Type: ZarazVarWorker, + Value: map[string]interface{}{ + "escapedWorkerName": "worker-var-example", + "mutableId": "m.zpt3q__WyW-61WM2qwgGoBl4Nxg-sfBsaMhu9NayjwU", + "workerTag": "68aba570db9d4ec5b159624e2f7ad8bf", + }, + }, + }, + Consent: ZarazConsent{ + Enabled: &trueValue, + ButtonTextTranslations: ZarazButtonTextTranslations{ + AcceptAll: map[string]string{"en": "Accept ALL"}, + ConfirmMyChoices: map[string]string{"en": "YES!"}, + RejectAll: map[string]string{"en": "Reject ALL"}, + }, + CompanyEmail: "email@example.com", + ConsentModalIntroHTMLWithTranslations: map[string]string{"en": "Lorem ipsum dolar set Amet?"}, + CookieName: "zaraz-consent", + CustomCSS: ".test {\n color: red;\n}", + CustomIntroDisclaimerDismissed: &trueValue, + DefaultLanguage: "en", + HideModal: &falseValue, + PurposesWithTranslations: map[string]ZarazPurposeWithTranslations{ + "rJJC": { + Description: map[string]string{"en": "Blah blah"}, + Name: map[string]string{"en": "Analytics"}, + Order: 0, + }, + }, + }, + } + payload.DebugKey = "butter" // Updating config + modifiedConfig := expectedConfig + modifiedConfig.DebugKey = "butter" // Updating config + expected := ZarazConfigResponse{ + Result: modifiedConfig, + } + + actual, err := client.UpdateZarazConfig(context.Background(), ZoneIdentifier(testZoneID), payload) + require.NoError(t, err) + + assert.Equal(t, expected.Result, actual.Result) +} + +func TestGetZarazWorkflow(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "errors": [], + "messages": [], + "success": true, + "result": "realtime" + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/workflow", handler) + want := ZarazWorkflowResponse{ + Result: "realtime", + Response: Response{ + Success: true, + Messages: []ResponseInfo{}, + Errors: []ResponseInfo{}, + }, + } + + actual, err := client.GetZarazWorkflow(context.Background(), ZoneIdentifier(testZoneID)) + + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestUpdateZarazWorkflow(t *testing.T) { + setup() + defer teardown() + + payload := UpdateZarazWorkflowParams{ + Workflow: "realtime", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + body, _ := io.ReadAll(r.Body) + bodyString := string(body) + assert.Equal(t, fmt.Sprintf("\"%s\"", payload.Workflow), bodyString) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "errors": [], + "messages": [], + "success": true, + "result": "realtime" + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/workflow", handler) + want := ZarazWorkflowResponse{ + Result: "realtime", + Response: Response{ + Success: true, + Messages: []ResponseInfo{}, + Errors: []ResponseInfo{}, + }, + } + + actual, err := client.UpdateZarazWorkflow(context.Background(), ZoneIdentifier(testZoneID), payload) + + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestPublishZarazConfig(t *testing.T) { + setup() + defer teardown() + + payload := PublishZarazConfigParams{ + Description: "test description", + } + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + body, _ := io.ReadAll(r.Body) + bodyString := string(body) + assert.Equal(t, fmt.Sprintf("\"%s\"", payload.Description), bodyString) + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{ + "errors": [], + "messages": [], + "success": true, + "result": "Config has been published successfully" + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/publish", handler) + want := ZarazPublishResponse{ + Result: "Config has been published successfully", + Response: Response{ + Success: true, + Messages: []ResponseInfo{}, + Errors: []ResponseInfo{}, + }, + } + + actual, err := client.PublishZarazConfig(context.Background(), ZoneIdentifier(testZoneID), payload) + + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestGetZarazConfigHistoryList(t *testing.T) { + setup() + defer teardown() + + 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, `{ + "result": [ + { + "createdAt": "2023-01-01T05:20:00Z", + "description": "test 1", + "id": 1005736, + "updatedAt": "2023-01-01T05:20:00Z", + "userId": "9ceddf6f117afe04c64716c83468d3a4" + }, + { + "createdAt": "2023-01-01T05:20:00Z", + "description": "test 2", + "id": 1005735, + "updatedAt": "2023-01-01T05:20:00Z", + "userId": "9ceddf6f117afe04c64716c83468d3a4" + } + ], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 2, + "count": 2, + "total_count": 8 + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/history", handler) + createdAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00Z") + updatedAt, _ := time.Parse(time.RFC3339, "2023-01-01T05:20:00Z") + want := []ZarazHistoryRecord{ + { + CreatedAt: &createdAt, + Description: "test 1", + ID: 1005736, + UpdatedAt: &updatedAt, + UserID: "9ceddf6f117afe04c64716c83468d3a4", + }, + { + CreatedAt: &createdAt, + Description: "test 2", + ID: 1005735, + UpdatedAt: &updatedAt, + UserID: "9ceddf6f117afe04c64716c83468d3a4", + }, + } + + actual, _, err := client.ListZarazConfigHistory(context.Background(), ZoneIdentifier(testZoneID), ListZarazConfigHistoryParams{ + ResultInfo: ResultInfo{ + PerPage: 2, + }, + }) + + require.NoError(t, err) + + assert.Equal(t, want, actual) +} + +func TestGetDefaultZarazConfig(t *testing.T) { + setup() + defer teardown() + + 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", "text/plain") + fmt.Fprint(w, `{ + "someTestKeyThatRepsTheConfig": "test" + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/default", handler) + + _, err := client.GetDefaultZarazConfig(context.Background(), ZoneIdentifier(testZoneID)) + require.NoError(t, err) +} + +func TestExportZarazConfig(t *testing.T) { + setup() + defer teardown() + + 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", "text/plain") + fmt.Fprint(w, `{ + "someTestKeyThatRepsTheConfig": "test" + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/settings/zaraz/v2/export", handler) + + err := client.ExportZarazConfig(context.Background(), ZoneIdentifier(testZoneID)) + require.NoError(t, err) +} diff --git a/pkg/cloudflare-go/zone.go b/pkg/cloudflare-go/zone.go new file mode 100644 index 000000000..de92880a1 --- /dev/null +++ b/pkg/cloudflare-go/zone.go @@ -0,0 +1,1077 @@ +package cloudflare + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "sync" + "time" + + "github.com/goccy/go-json" + + "golang.org/x/net/idna" +) + +var ( + // ErrMissingSettingName is for when setting name is required but missing. + ErrMissingSettingName = errors.New("zone setting name required but missing") +) + +// Owner describes the resource owner. +type Owner struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + OwnerType string `json:"type"` +} + +// Zone describes a Cloudflare zone. +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` + // DevMode contains the time in seconds until development expires (if + // positive) or since it expired (if negative). It will be 0 if never used. + DevMode int `json:"development_mode"` + OriginalNS []string `json:"original_name_servers"` + OriginalRegistrar string `json:"original_registrar"` + OriginalDNSHost string `json:"original_dnshost"` + CreatedOn time.Time `json:"created_on"` + ModifiedOn time.Time `json:"modified_on"` + NameServers []string `json:"name_servers"` + Owner Owner `json:"owner"` + Permissions []string `json:"permissions"` + Plan ZonePlan `json:"plan"` + PlanPending ZonePlan `json:"plan_pending,omitempty"` + Status string `json:"status"` + Paused bool `json:"paused"` + Type string `json:"type"` + Host struct { + Name string + Website string + } `json:"host"` + VanityNS []string `json:"vanity_name_servers"` + Betas []string `json:"betas"` + DeactReason string `json:"deactivation_reason"` + Meta ZoneMeta `json:"meta"` + Account Account `json:"account"` + VerificationKey string `json:"verification_key"` +} + +// ZoneMeta describes metadata about a zone. +type ZoneMeta struct { + // custom_certificate_quota is broken - sometimes it's a string, sometimes a number! + // CustCertQuota int `json:"custom_certificate_quota"` + PageRuleQuota int `json:"page_rule_quota"` + WildcardProxiable bool `json:"wildcard_proxiable"` + PhishingDetected bool `json:"phishing_detected"` +} + +// ZonePlan contains the plan information for a zone. +type ZonePlan struct { + ZonePlanCommon + LegacyID string `json:"legacy_id"` + IsSubscribed bool `json:"is_subscribed"` + CanSubscribe bool `json:"can_subscribe"` + LegacyDiscount bool `json:"legacy_discount"` + ExternallyManaged bool `json:"externally_managed"` +} + +// ZoneRatePlan contains the plan information for a zone. +type ZoneRatePlan struct { + ZonePlanCommon + Components []zoneRatePlanComponents `json:"components,omitempty"` +} + +// ZonePlanCommon contains fields used by various Plan endpoints. +type ZonePlanCommon struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Price int `json:"price,omitempty"` + Currency string `json:"currency,omitempty"` + Frequency string `json:"frequency,omitempty"` +} + +type zoneRatePlanComponents struct { + Name string `json:"name"` + Default int `json:"Default"` + UnitPrice int `json:"unit_price"` +} + +// ZoneID contains only the zone ID. +type ZoneID struct { + ID string `json:"id"` +} + +// ZoneResponse represents the response from the Zone endpoint containing a single zone. +type ZoneResponse struct { + Response + Result Zone `json:"result"` +} + +// ZonesResponse represents the response from the Zone endpoint containing an array of zones. +type ZonesResponse struct { + Response + Result []Zone `json:"result"` + ResultInfo `json:"result_info"` +} + +// ZoneIDResponse represents the response from the Zone endpoint, containing only a zone ID. +type ZoneIDResponse struct { + Response + Result ZoneID `json:"result"` +} + +// AvailableZoneRatePlansResponse represents the response from the Available Rate Plans endpoint. +type AvailableZoneRatePlansResponse struct { + Response + Result []ZoneRatePlan `json:"result"` + ResultInfo `json:"result_info"` +} + +// AvailableZonePlansResponse represents the response from the Available Plans endpoint. +type AvailableZonePlansResponse struct { + Response + Result []ZonePlan `json:"result"` + ResultInfo +} + +// ZoneRatePlanResponse represents the response from the Plan Details endpoint. +type ZoneRatePlanResponse struct { + Response + Result ZoneRatePlan `json:"result"` +} + +// ZoneSetting contains settings for a zone. +type ZoneSetting struct { + ID string `json:"id"` + Editable bool `json:"editable"` + ModifiedOn string `json:"modified_on,omitempty"` + Value interface{} `json:"value"` + TimeRemaining int `json:"time_remaining"` +} + +// ZoneSettingResponse represents the response from the Zone Setting endpoint. +type ZoneSettingResponse struct { + Response + Result []ZoneSetting `json:"result"` +} + +// ZoneSettingSingleResponse represents the response from the Zone Setting endpoint for the specified setting. +type ZoneSettingSingleResponse struct { + Response + Result ZoneSetting `json:"result"` +} + +// ZoneSSLSetting contains ssl setting for a zone. +type ZoneSSLSetting struct { + ID string `json:"id"` + Editable bool `json:"editable"` + ModifiedOn string `json:"modified_on"` + Value string `json:"value"` + CertificateStatus string `json:"certificate_status"` +} + +// ZoneSSLSettingResponse represents the response from the Zone SSL Setting +// endpoint. +type ZoneSSLSettingResponse struct { + Response + Result ZoneSSLSetting `json:"result"` +} + +// ZoneAnalyticsData contains totals and timeseries analytics data for a zone. +type ZoneAnalyticsData struct { + Totals ZoneAnalytics `json:"totals"` + Timeseries []ZoneAnalytics `json:"timeseries"` +} + +// zoneAnalyticsDataResponse represents the response from the Zone Analytics Dashboard endpoint. +type zoneAnalyticsDataResponse struct { + Response + Result ZoneAnalyticsData `json:"result"` +} + +// ZoneAnalyticsColocation contains analytics data by datacenter. +type ZoneAnalyticsColocation struct { + ColocationID string `json:"colo_id"` + Timeseries []ZoneAnalytics `json:"timeseries"` +} + +// zoneAnalyticsColocationResponse represents the response from the Zone Analytics By Co-location endpoint. +type zoneAnalyticsColocationResponse struct { + Response + Result []ZoneAnalyticsColocation `json:"result"` +} + +// ZoneAnalytics contains analytics data for a zone. +type ZoneAnalytics struct { + Since time.Time `json:"since"` + Until time.Time `json:"until"` + Requests struct { + All int `json:"all"` + Cached int `json:"cached"` + Uncached int `json:"uncached"` + ContentType map[string]int `json:"content_type"` + Country map[string]int `json:"country"` + SSL struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + } `json:"ssl"` + HTTPStatus map[string]int `json:"http_status"` + } `json:"requests"` + Bandwidth struct { + All int `json:"all"` + Cached int `json:"cached"` + Uncached int `json:"uncached"` + ContentType map[string]int `json:"content_type"` + Country map[string]int `json:"country"` + SSL struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + } `json:"ssl"` + } `json:"bandwidth"` + Threats struct { + All int `json:"all"` + Country map[string]int `json:"country"` + Type map[string]int `json:"type"` + } `json:"threats"` + Pageviews struct { + All int `json:"all"` + SearchEngines map[string]int `json:"search_engines"` + } `json:"pageviews"` + Uniques struct { + All int `json:"all"` + } +} + +// ZoneAnalyticsOptions represents the optional parameters in Zone Analytics +// endpoint requests. +type ZoneAnalyticsOptions struct { + Since *time.Time + Until *time.Time + Continuous *bool +} + +// PurgeCacheRequest represents the request format made to the purge endpoint. +type PurgeCacheRequest struct { + Everything bool `json:"purge_everything,omitempty"` + // Purge by filepath (exact match). Limit of 30 + Files []string `json:"files,omitempty"` + // Purge by Tag (Enterprise only): + // https://support.cloudflare.com/hc/en-us/articles/206596608-How-to-Purge-Cache-Using-Cache-Tags-Enterprise-only- + Tags []string `json:"tags,omitempty"` + // Purge by hostname - e.g. "assets.example.com" + Hosts []string `json:"hosts,omitempty"` + // Purge by prefix - e.g. "example.com/css" + Prefixes []string `json:"prefixes,omitempty"` +} + +// PurgeCacheResponse represents the response from the purge endpoint. +type PurgeCacheResponse struct { + Response + Result struct { + ID string `json:"id"` + } `json:"result"` +} + +// newZone describes a new zone. +type newZone struct { + Name string `json:"name"` + JumpStart bool `json:"jump_start"` + Type string `json:"type"` + // We use a pointer to get a nil type when the field is empty. + // This allows us to completely omit this with json.Marshal(). + Account *Account `json:"organization,omitempty"` +} + +// FallbackOrigin describes a fallback origin. +type FallbackOrigin struct { + Value string `json:"value"` + ID string `json:"id,omitempty"` +} + +// FallbackOriginResponse represents the response from the fallback_origin endpoint. +type FallbackOriginResponse struct { + Response + Result FallbackOrigin `json:"result"` +} + +// zoneSubscriptionRatePlanPayload is used to build the JSON payload for +// setting a particular rate plan on an existing zone. +type zoneSubscriptionRatePlanPayload struct { + RatePlan struct { + ID string `json:"id"` + } `json:"rate_plan"` +} + +type GetZoneSettingParams struct { + Name string `json:"-"` + PathPrefix string `json:"-"` +} + +type UpdateZoneSettingParams struct { + Name string `json:"-"` + PathPrefix string `json:"-"` + Value interface{} `json:"value"` +} + +// CreateZone creates a zone on an account. +// +// Setting jumpstart to true will attempt to automatically scan for existing +// DNS records. Setting this to false will create the zone with no DNS records. +// +// If account is non-empty, it must have at least the ID field populated. +// This will add the new zone to the specified multi-user account. +// +// API reference: https://api.cloudflare.com/#zone-create-a-zone +func (api *API) CreateZone(ctx context.Context, name string, jumpstart bool, account Account, zoneType string) (Zone, error) { + var newzone newZone + newzone.Name = name + newzone.JumpStart = jumpstart + if account.ID != "" { + newzone.Account = &account + } + + if zoneType == "partial" { + newzone.Type = "partial" + } else { + newzone.Type = "full" + } + + res, err := api.makeRequestContext(ctx, http.MethodPost, "/zones", newzone) + if err != nil { + return Zone{}, err + } + + var r ZoneResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Zone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ZoneActivationCheck initiates another zone activation check for newly-created zones. +// +// API reference: https://api.cloudflare.com/#zone-initiate-another-zone-activation-check +func (api *API) ZoneActivationCheck(ctx context.Context, zoneID string) (Response, error) { + res, err := api.makeRequestContext(ctx, http.MethodPut, "/zones/"+zoneID+"/activation_check", nil) + if err != nil { + return Response{}, err + } + var r Response + err = json.Unmarshal(res, &r) + if err != nil { + return Response{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// ListZones lists zones on an account. Optionally takes a list of zone names +// to filter against. +// +// API reference: https://api.cloudflare.com/#zone-list-zones +func (api *API) ListZones(ctx context.Context, z ...string) ([]Zone, error) { + var zones []Zone + if len(z) > 0 { + var ( + v = url.Values{} + r ZonesResponse + ) + for _, zone := range z { + v.Set("name", normalizeZoneName(zone)) + res, err := api.makeRequestContext(ctx, http.MethodGet, "/zones?"+v.Encode(), nil) + if err != nil { + return []Zone{}, err + } + err = json.Unmarshal(res, &r) + if err != nil { + return []Zone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + if !r.Success { + // TODO: Provide an actual error message instead of always returning nil + return []Zone{}, err + } + zones = append(zones, r.Result...) + } + } else { + res, err := api.ListZonesContext(ctx) + if err != nil { + return nil, err + } + + zones = res.Result + } + + return zones, nil +} + +const listZonesPerPage = 50 + +// listZonesFetch fetches one page of zones. +// This is placed as a separate function to prevent any possibility of unintended capturing. +func (api *API) listZonesFetch(ctx context.Context, wg *sync.WaitGroup, errc chan error, + path string, pageSize int, buf []Zone) { + defer wg.Done() + + // recordError sends the error to errc in a non-blocking manner + recordError := func(err error) { + select { + case errc <- err: + default: + } + } + + res, err := api.makeRequestContext(ctx, http.MethodGet, path, nil) + if err != nil { + recordError(err) + return + } + + var r ZonesResponse + err = json.Unmarshal(res, &r) + if err != nil { + recordError(err) + return + } + + if len(r.Result) != pageSize { + recordError(errors.New(errResultInfo)) + return + } + + copy(buf, r.Result) +} + +// ListZonesContext lists all zones on an account automatically handling the +// pagination. Optionally takes a list of ReqOptions. +func (api *API) ListZonesContext(ctx context.Context, opts ...ReqOption) (r ZonesResponse, err error) { + opt := reqOption{ + params: url.Values{}, + } + for _, of := range opts { + of(&opt) + } + + if opt.params.Get("page") != "" || opt.params.Get("per_page") != "" { + return ZonesResponse{}, errors.New(errManualPagination) + } + + opt.params.Add("per_page", strconv.Itoa(listZonesPerPage)) + + res, err := api.makeRequestContext(ctx, http.MethodGet, "/zones?"+opt.params.Encode(), nil) + if err != nil { + return ZonesResponse{}, err + } + err = json.Unmarshal(res, &r) + if err != nil { + return ZonesResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + // avoid overhead in most common cases where the total #zones <= 50 + if r.TotalPages < 2 { + return r, nil + } + + // parameters of pagination + var ( + totalPageCount = r.TotalPages + totalCount = r.Total + + // zones is a large slice to prevent resizing during concurrent writes. + zones = make([]Zone, totalCount) + ) + + // Copy the first page into zones. + copy(zones, r.Result) + + var wg sync.WaitGroup + wg.Add(totalPageCount - 1) // all pages except the first one. + errc := make(chan error, 1) // getting the first error + + // Creating all the workers. + for pageNum := 2; pageNum <= totalPageCount; pageNum++ { + // Note: URL.Values is just a map[string], so this would override the existing 'page' + opt.params.Set("page", strconv.Itoa(pageNum)) + + // start is the first index in the zone buffer + start := listZonesPerPage * (pageNum - 1) + + pageSize := listZonesPerPage + if pageNum == totalPageCount { + // The size of the last page (which would be <= 50). + pageSize = totalCount - start + } + + go api.listZonesFetch(ctx, &wg, errc, "/zones?"+opt.params.Encode(), pageSize, zones[start:]) + } + + wg.Wait() + + select { + case err := <-errc: // if there were any errors + return ZonesResponse{}, err + default: // if there were no errors, the receive statement should block + r.Result = zones + return r, nil + } +} + +// ZoneDetails fetches information about a zone. +// +// API reference: https://api.cloudflare.com/#zone-zone-details +func (api *API) ZoneDetails(ctx context.Context, zoneID string) (Zone, error) { + res, err := api.makeRequestContext(ctx, http.MethodGet, "/zones/"+zoneID, nil) + if err != nil { + return Zone{}, err + } + var r ZoneResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Zone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ZoneOptions is a subset of Zone, for editable options. +type ZoneOptions struct { + Paused *bool `json:"paused,omitempty"` + VanityNS []string `json:"vanity_name_servers,omitempty"` + Plan *ZonePlan `json:"plan,omitempty"` + Type string `json:"type,omitempty"` +} + +// ZoneSetPaused pauses Cloudflare service for the entire zone, sending all +// traffic direct to the origin. +func (api *API) ZoneSetPaused(ctx context.Context, zoneID string, paused bool) (Zone, error) { + zoneopts := ZoneOptions{Paused: &paused} + zone, err := api.EditZone(ctx, zoneID, zoneopts) + if err != nil { + return Zone{}, err + } + + return zone, nil +} + +// ZoneSetType toggles the type for an existing zone. +// +// Valid values for `type` are "full" and "partial" +// +// API reference: https://api.cloudflare.com/#zone-edit-zone +func (api *API) ZoneSetType(ctx context.Context, zoneID string, zoneType string) (Zone, error) { + zoneopts := ZoneOptions{Type: zoneType} + zone, err := api.EditZone(ctx, zoneID, zoneopts) + if err != nil { + return Zone{}, err + } + + return zone, nil +} + +// ZoneSetVanityNS sets custom nameservers for the zone. +// These names must be within the same zone. +func (api *API) ZoneSetVanityNS(ctx context.Context, zoneID string, ns []string) (Zone, error) { + zoneopts := ZoneOptions{VanityNS: ns} + zone, err := api.EditZone(ctx, zoneID, zoneopts) + if err != nil { + return Zone{}, err + } + + return zone, nil +} + +// ZoneSetPlan sets the rate plan of an existing zone. +// +// Valid values for `planType` are "CF_FREE", "CF_PRO", "CF_BIZ" and +// "CF_ENT". +// +// API reference: https://api.cloudflare.com/#zone-subscription-create-zone-subscription +func (api *API) ZoneSetPlan(ctx context.Context, zoneID string, planType string) error { + zonePayload := zoneSubscriptionRatePlanPayload{} + zonePayload.RatePlan.ID = planType + + uri := fmt.Sprintf("/zones/%s/subscription", zoneID) + + _, err := api.makeRequestContext(ctx, http.MethodPost, uri, zonePayload) + if err != nil { + return err + } + + return nil +} + +// ZoneUpdatePlan updates the rate plan of an existing zone. +// +// Valid values for `planType` are "CF_FREE", "CF_PRO", "CF_BIZ" and +// "CF_ENT". +// +// API reference: https://api.cloudflare.com/#zone-subscription-update-zone-subscription +func (api *API) ZoneUpdatePlan(ctx context.Context, zoneID string, planType string) error { + zonePayload := zoneSubscriptionRatePlanPayload{} + zonePayload.RatePlan.ID = planType + + uri := fmt.Sprintf("/zones/%s/subscription", zoneID) + + _, err := api.makeRequestContext(ctx, http.MethodPut, uri, zonePayload) + if err != nil { + return err + } + + return nil +} + +// EditZone edits the given zone. +// +// This is usually called by ZoneSetPaused, ZoneSetType, or ZoneSetVanityNS. +// +// API reference: https://api.cloudflare.com/#zone-edit-zone-properties +func (api *API) EditZone(ctx context.Context, zoneID string, zoneOpts ZoneOptions) (Zone, error) { + res, err := api.makeRequestContext(ctx, http.MethodPatch, "/zones/"+zoneID, zoneOpts) + if err != nil { + return Zone{}, err + } + var r ZoneResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Zone{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// PurgeEverything purges the cache for the given zone. +// +// Note: this will substantially increase load on the origin server for that +// zone if there is a high cached vs. uncached request ratio. +// +// API reference: https://api.cloudflare.com/#zone-purge-all-files +func (api *API) PurgeEverything(ctx context.Context, zoneID string) (PurgeCacheResponse, error) { + uri := fmt.Sprintf("/zones/%s/purge_cache", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, PurgeCacheRequest{true, nil, nil, nil, nil}) + if err != nil { + return PurgeCacheResponse{}, err + } + var r PurgeCacheResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PurgeCacheResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// PurgeCache purges the cache using the given PurgeCacheRequest (zone/url/tag). +// +// API reference: https://api.cloudflare.com/#zone-purge-individual-files-by-url-and-cache-tags +func (api *API) PurgeCache(ctx context.Context, zoneID string, pcr PurgeCacheRequest) (PurgeCacheResponse, error) { + return api.PurgeCacheContext(ctx, zoneID, pcr) +} + +// PurgeCacheContext purges the cache using the given PurgeCacheRequest (zone/url/tag). +// +// API reference: https://api.cloudflare.com/#zone-purge-individual-files-by-url-and-cache-tags +func (api *API) PurgeCacheContext(ctx context.Context, zoneID string, pcr PurgeCacheRequest) (PurgeCacheResponse, error) { + // manually build the payload to ensure we don't escape HTML entities to + // match their keys for purging. + payload, err := json.MarshalWithOption(pcr, json.DisableHTMLEscape()) + if err != nil { + return PurgeCacheResponse{}, err + } + + uri := fmt.Sprintf("/zones/%s/purge_cache", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, payload) + if err != nil { + return PurgeCacheResponse{}, err + } + var r PurgeCacheResponse + err = json.Unmarshal(res, &r) + if err != nil { + return PurgeCacheResponse{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r, nil +} + +// DeleteZone deletes the given zone. +// +// API reference: https://api.cloudflare.com/#zone-delete-a-zone +func (api *API) DeleteZone(ctx context.Context, zoneID string) (ZoneID, error) { + res, err := api.makeRequestContext(ctx, http.MethodDelete, "/zones/"+zoneID, nil) + if err != nil { + return ZoneID{}, err + } + var r ZoneIDResponse + err = json.Unmarshal(res, &r) + if err != nil { + return ZoneID{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// AvailableZoneRatePlans returns information about all plans available to the specified zone. +// +// API reference: https://api.cloudflare.com/#zone-plan-available-plans +func (api *API) AvailableZoneRatePlans(ctx context.Context, zoneID string) ([]ZoneRatePlan, error) { + uri := fmt.Sprintf("/zones/%s/available_rate_plans", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []ZoneRatePlan{}, err + } + var r AvailableZoneRatePlansResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []ZoneRatePlan{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// AvailableZonePlans returns information about all plans available to the specified zone. +// +// API reference: https://api.cloudflare.com/#zone-rate-plan-list-available-plans +func (api *API) AvailableZonePlans(ctx context.Context, zoneID string) ([]ZonePlan, error) { + uri := fmt.Sprintf("/zones/%s/available_plans", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return []ZonePlan{}, err + } + var r AvailableZonePlansResponse + err = json.Unmarshal(res, &r) + if err != nil { + return []ZonePlan{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// encode encodes non-nil fields into URL encoded form. +func (o ZoneAnalyticsOptions) encode() string { + v := url.Values{} + if o.Since != nil { + v.Set("since", o.Since.Format(time.RFC3339)) + } + if o.Until != nil { + v.Set("until", o.Until.Format(time.RFC3339)) + } + if o.Continuous != nil { + v.Set("continuous", fmt.Sprintf("%t", *o.Continuous)) + } + return v.Encode() +} + +// ZoneAnalyticsDashboard returns zone analytics information. +// +// API reference: https://api.cloudflare.com/#zone-analytics-dashboard +func (api *API) ZoneAnalyticsDashboard(ctx context.Context, zoneID string, options ZoneAnalyticsOptions) (ZoneAnalyticsData, error) { + uri := fmt.Sprintf("/zones/%s/analytics/dashboard?%s", zoneID, options.encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneAnalyticsData{}, err + } + var r zoneAnalyticsDataResponse + err = json.Unmarshal(res, &r) + if err != nil { + return ZoneAnalyticsData{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ZoneAnalyticsByColocation returns zone analytics information by datacenter. +// +// API reference: https://api.cloudflare.com/#zone-analytics-analytics-by-co-locations +func (api *API) ZoneAnalyticsByColocation(ctx context.Context, zoneID string, options ZoneAnalyticsOptions) ([]ZoneAnalyticsColocation, error) { + uri := fmt.Sprintf("/zones/%s/analytics/colos?%s", zoneID, options.encode()) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + var r zoneAnalyticsColocationResponse + err = json.Unmarshal(res, &r) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// ZoneSettings returns all of the settings for a given zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-get-all-zone-settings +func (api *API) ZoneSettings(ctx context.Context, zoneID string) (*ZoneSettingResponse, error) { + uri := fmt.Sprintf("/zones/%s/settings", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + response := &ZoneSettingResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// UpdateZoneSettings updates the settings for a given zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-edit-zone-settings-info +func (api *API) UpdateZoneSettings(ctx context.Context, zoneID string, settings []ZoneSetting) (*ZoneSettingResponse, error) { + uri := fmt.Sprintf("/zones/%s/settings", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, struct { + Items []ZoneSetting `json:"items"` + }{settings}) + if err != nil { + return nil, err + } + + response := &ZoneSettingResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// ZoneSSLSettings returns information about SSL setting to the specified zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-get-ssl-setting +func (api *API) ZoneSSLSettings(ctx context.Context, zoneID string) (ZoneSSLSetting, error) { + uri := fmt.Sprintf("/zones/%s/settings/ssl", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneSSLSetting{}, err + } + var r ZoneSSLSettingResponse + err = json.Unmarshal(res, &r) + if err != nil { + return ZoneSSLSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateZoneSSLSettings update information about SSL setting to the specified zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-change-ssl-setting +func (api *API) UpdateZoneSSLSettings(ctx context.Context, zoneID string, sslValue string) (ZoneSSLSetting, error) { + uri := fmt.Sprintf("/zones/%s/settings/ssl", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, ZoneSSLSetting{Value: sslValue}) + if err != nil { + return ZoneSSLSetting{}, err + } + var r ZoneSSLSettingResponse + err = json.Unmarshal(res, &r) + if err != nil { + return ZoneSSLSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// FallbackOrigin returns information about the fallback origin for the specified zone. +// +// API reference: https://developers.cloudflare.com/ssl/ssl-for-saas/api-calls/#fallback-origin-configuration +func (api *API) FallbackOrigin(ctx context.Context, zoneID string) (FallbackOrigin, error) { + uri := fmt.Sprintf("/zones/%s/fallback_origin", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return FallbackOrigin{}, err + } + + var r FallbackOriginResponse + err = json.Unmarshal(res, &r) + if err != nil { + return FallbackOrigin{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +// UpdateFallbackOrigin updates the fallback origin for a given zone. +// +// API reference: https://developers.cloudflare.com/ssl/ssl-for-saas/api-calls/#4-example-patch-to-change-fallback-origin +func (api *API) UpdateFallbackOrigin(ctx context.Context, zoneID string, fbo FallbackOrigin) (*FallbackOriginResponse, error) { + uri := fmt.Sprintf("/zones/%s/fallback_origin", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, fbo) + if err != nil { + return nil, err + } + + response := &FallbackOriginResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return nil, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response, nil +} + +// normalizeZoneName tries to convert IDNs (international domain names) +// from Punycode to Unicode form. If the given zone name is not represented +// as Punycode, or converting fails (for invalid representations), it +// is returned unchanged. +// +// Because all the zone name comparison is currently done using the API service +// (except for comparison with the empty string), theoretically, we could +// remove this function from the Go library. However, there should be no harm +// calling this function other than gelable performance penalty. +// +// Note: conversion errors are silently discarded. +func normalizeZoneName(name string) string { + if n, err := idna.ToUnicode(name); err == nil { + return n + } + return name +} + +// GetZoneSetting returns information about specified setting to the specified +// zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-get-all-zone-settings +func (api *API) GetZoneSetting(ctx context.Context, rc *ResourceContainer, params GetZoneSettingParams) (ZoneSetting, error) { + if rc.Level != ZoneRouteLevel { + return ZoneSetting{}, ErrRequiredZoneLevelResourceContainer + } + + if rc.Identifier == "" { + return ZoneSetting{}, ErrMissingName + } + + pathPrefix := "settings" + if params.PathPrefix != "" { + pathPrefix = params.PathPrefix + } + + uri := fmt.Sprintf("/zones/%s/%s/%s", rc.Identifier, pathPrefix, params.Name) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneSetting{}, err + } + var r ZoneSettingSingleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return ZoneSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateZoneSetting updates the specified setting for a given zone. +// +// API reference: https://api.cloudflare.com/#zone-settings-edit-zone-settings-info +func (api *API) UpdateZoneSetting(ctx context.Context, rc *ResourceContainer, params UpdateZoneSettingParams) (ZoneSetting, error) { + if rc.Level != ZoneRouteLevel { + return ZoneSetting{}, ErrRequiredZoneLevelResourceContainer + } + + if rc.Identifier == "" { + return ZoneSetting{}, ErrMissingName + } + + pathPrefix := "settings" + if params.PathPrefix != "" { + pathPrefix = params.PathPrefix + } + + uri := fmt.Sprintf("/zones/%s/%s/%s", rc.Identifier, pathPrefix, params.Name) + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, params) + if err != nil { + return ZoneSetting{}, err + } + + response := &ZoneSettingSingleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneSetting{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// ZoneExport returns the text BIND config for the given zone +// +// API reference: https://api.cloudflare.com/#dns-records-for-a-zone-export-dns-records +func (api *API) ZoneExport(ctx context.Context, zoneID string) (string, error) { + res, err := api.makeRequestContext(ctx, http.MethodGet, "/zones/"+zoneID+"/dns_records/export", nil) + if err != nil { + return "", err + } + return string(res), nil +} + +// ZoneDNSSECResponse represents the response from the Zone DNSSEC Setting. +type ZoneDNSSECResponse struct { + Response + Result ZoneDNSSEC `json:"result"` +} + +// ZoneDNSSEC represents the response from the Zone DNSSEC Setting result. +type ZoneDNSSEC struct { + Status string `json:"status"` + Flags int `json:"flags"` + Algorithm string `json:"algorithm"` + KeyType string `json:"key_type"` + DigestType string `json:"digest_type"` + DigestAlgorithm string `json:"digest_algorithm"` + Digest string `json:"digest"` + DS string `json:"ds"` + KeyTag int `json:"key_tag"` + PublicKey string `json:"public_key"` + ModifiedOn time.Time `json:"modified_on"` +} + +// ZoneDNSSECSetting returns the DNSSEC details of a zone +// +// API reference: https://api.cloudflare.com/#dnssec-dnssec-details +func (api *API) ZoneDNSSECSetting(ctx context.Context, zoneID string) (ZoneDNSSEC, error) { + res, err := api.makeRequestContext(ctx, http.MethodGet, "/zones/"+zoneID+"/dnssec", nil) + if err != nil { + return ZoneDNSSEC{}, err + } + response := ZoneDNSSECResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneDNSSEC{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// ZoneDNSSECDeleteResponse represents the response from the Zone DNSSEC Delete request. +type ZoneDNSSECDeleteResponse struct { + Response + Result string `json:"result"` +} + +// DeleteZoneDNSSEC deletes DNSSEC for zone +// +// API reference: https://api.cloudflare.com/#dnssec-delete-dnssec-records +func (api *API) DeleteZoneDNSSEC(ctx context.Context, zoneID string) (string, error) { + res, err := api.makeRequestContext(ctx, http.MethodDelete, "/zones/"+zoneID+"/dnssec", nil) + if err != nil { + return "", err + } + response := ZoneDNSSECDeleteResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return "", fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response.Result, nil +} + +// ZoneDNSSECUpdateOptions represents the options for DNSSEC update. +type ZoneDNSSECUpdateOptions struct { + Status string `json:"status"` +} + +// UpdateZoneDNSSEC updates DNSSEC for a zone +// +// API reference: https://api.cloudflare.com/#dnssec-edit-dnssec-status +func (api *API) UpdateZoneDNSSEC(ctx context.Context, zoneID string, options ZoneDNSSECUpdateOptions) (ZoneDNSSEC, error) { + res, err := api.makeRequestContext(ctx, http.MethodPatch, "/zones/"+zoneID+"/dnssec", options) + if err != nil { + return ZoneDNSSEC{}, err + } + response := ZoneDNSSECResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneDNSSEC{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return response.Result, nil +} diff --git a/pkg/cloudflare-go/zone_cache_variants.go b/pkg/cloudflare-go/zone_cache_variants.go new file mode 100644 index 000000000..101f388d5 --- /dev/null +++ b/pkg/cloudflare-go/zone_cache_variants.go @@ -0,0 +1,89 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +type ZoneCacheVariantsValues struct { + Avif []string `json:"avif,omitempty"` + Bmp []string `json:"bmp,omitempty"` + Gif []string `json:"gif,omitempty"` + Jpeg []string `json:"jpeg,omitempty"` + Jpg []string `json:"jpg,omitempty"` + Jpg2 []string `json:"jpg2,omitempty"` + Jp2 []string `json:"jp2,omitempty"` + Png []string `json:"png,omitempty"` + Tiff []string `json:"tiff,omitempty"` + Tif []string `json:"tif,omitempty"` + Webp []string `json:"webp,omitempty"` +} + +type ZoneCacheVariants struct { + ModifiedOn time.Time `json:"modified_on"` + Value ZoneCacheVariantsValues `json:"value"` +} + +type updateZoneCacheVariantsRequest struct { + Value ZoneCacheVariantsValues `json:"value"` +} + +type zoneCacheVariantsSingleResponse struct { + Response + Result ZoneCacheVariants `json:"result"` +} + +// ZoneCacheVariants returns information about the current cache variants +// +// API reference: https://api.cloudflare.com/#zone-cache-settings-get-variants-setting +func (api *API) ZoneCacheVariants(ctx context.Context, zoneID string) (ZoneCacheVariants, error) { + uri := fmt.Sprintf("/zones/%s/cache/variants", zoneID) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneCacheVariants{}, err + } + var r zoneCacheVariantsSingleResponse + err = json.Unmarshal(res, &r) + if err != nil { + return ZoneCacheVariants{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateZoneCacheVariants updates the cache variants for a given zone. +// +// API reference: https://api.cloudflare.com/#zone-cache-settings-change-variants-setting +func (api *API) UpdateZoneCacheVariants(ctx context.Context, zoneID string, variants ZoneCacheVariantsValues) (ZoneCacheVariants, error) { + uri := fmt.Sprintf("/zones/%s/cache/variants", zoneID) + + updateReq := updateZoneCacheVariantsRequest{Value: variants} + res, err := api.makeRequestContext(ctx, http.MethodPatch, uri, updateReq) + if err != nil { + return ZoneCacheVariants{}, err + } + + response := &zoneCacheVariantsSingleResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneCacheVariants{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// DeleteZoneCacheVariants deletes cache variants for a given zone. +// +// API reference: https://api.cloudflare.com/#zone-cache-settings-delete-variants-setting +func (api *API) DeleteZoneCacheVariants(ctx context.Context, zoneID string) error { + uri := fmt.Sprintf("/zones/%s/cache/variants", zoneID) + _, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cloudflare-go/zone_cache_variants_test.go b/pkg/cloudflare-go/zone_cache_variants_test.go new file mode 100644 index 000000000..315d2d922 --- /dev/null +++ b/pkg/cloudflare-go/zone_cache_variants_test.go @@ -0,0 +1,168 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestZoneCacheVariants(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "variants", + "modified_on": "2014-01-01T05:20:00.12345Z", + "value": { + "avif": [ + "image/webp", + "image/jpeg" + ], + "bmp": [ + "image/webp", + "image/jpeg" + ] + } + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/cache/variants", handler) + + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + want := ZoneCacheVariants{ + ModifiedOn: modifiedOn, + Value: ZoneCacheVariantsValues{ + Avif: []string{ + "image/webp", + "image/jpeg", + }, + Bmp: []string{ + "image/webp", + "image/jpeg", + }, + }, + } + + actual, err := client.ZoneCacheVariants(context.Background(), testZoneID) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestZoneCacheVariantsUpdate(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if assert.NoError(t, err) { + assert.JSONEq(t, `{"value":{"avif":["image/webp","image/jpeg"],"bmp":["image/webp","image/jpeg"]}}`, string(b)) + } + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "variants", + "modified_on": "2014-01-01T05:20:00.12345Z", + "value": { + "avif": [ + "image/webp", + "image/jpeg" + ], + "bmp": [ + "image/webp", + "image/jpeg" + ] + } + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/cache/variants", handler) + + modifiedOn, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") + + want := ZoneCacheVariants{ + ModifiedOn: modifiedOn, + Value: ZoneCacheVariantsValues{ + Avif: []string{ + "image/webp", + "image/jpeg", + }, + Bmp: []string{ + "image/webp", + "image/jpeg", + }, + }, + } + + zoneCacheVariants := ZoneCacheVariantsValues{ + Avif: []string{ + "image/webp", + "image/jpeg", + }, + Bmp: []string{ + "image/webp", + "image/jpeg", + }} + + actual, err := client.UpdateZoneCacheVariants(context.Background(), testZoneID, zoneCacheVariants) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestZoneCacheVariantsDelete(t *testing.T) { + setup() + defer teardown() + + apiCalled := false + + handler := func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "variants", + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + + mux.HandleFunc("/zones/"+testZoneID+"/cache/variants", handler) + + err := client.DeleteZoneCacheVariants(context.Background(), testZoneID) + + assert.NoError(t, err) + assert.True(t, apiCalled) +} diff --git a/pkg/cloudflare-go/zone_example_test.go b/pkg/cloudflare-go/zone_example_test.go new file mode 100644 index 000000000..cbcb50413 --- /dev/null +++ b/pkg/cloudflare-go/zone_example_test.go @@ -0,0 +1,43 @@ +package cloudflare_test + +import ( + "context" + "fmt" + "log" + + cloudflare "github.com/cloudflare/cloudflare-go" +) + +func ExampleAPI_ListZones_all() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + // Fetch all zones available to this user. + zones, err := api.ListZones(context.Background()) + if err != nil { + log.Fatal(err) + } + + for _, z := range zones { + fmt.Println(z.Name) + } +} + +func ExampleAPI_ListZones_filter() { + api, err := cloudflare.New("deadbeef", "test@example.org") + if err != nil { + log.Fatal(err) + } + + // Fetch a slice of zones example.org and example.net. + zones, err := api.ListZones(context.Background(), "example.org", "example.net") + if err != nil { + log.Fatal(err) + } + + for _, z := range zones { + fmt.Println(z.Name) + } +} diff --git a/pkg/cloudflare-go/zone_hold.go b/pkg/cloudflare-go/zone_hold.go new file mode 100644 index 000000000..c7f30ccc4 --- /dev/null +++ b/pkg/cloudflare-go/zone_hold.go @@ -0,0 +1,111 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/goccy/go-json" +) + +// Retrieve whether the zone is subject to a zone hold, and metadata about the +// hold. +type ZoneHold struct { + Hold *bool `json:"hold,omitempty"` + IncludeSubdomains *bool `json:"include_subdomains,omitempty"` + HoldAfter *time.Time `json:"hold_after,omitempty"` +} + +// ZoneHoldResponse represents a response from the Zone Hold endpoint. +type ZoneHoldResponse struct { + Result ZoneHold `json:"result"` + Response + ResultInfo `json:"result_info"` +} + +// CreateZoneHoldParams represents params for the Create Zone Hold +// endpoint. +type CreateZoneHoldParams struct { + IncludeSubdomains *bool `url:"include_subdomains,omitempty"` +} + +// DeleteZoneHoldParams represents params for the Delete Zone Hold +// endpoint. +type DeleteZoneHoldParams struct { + HoldAfter *time.Time `url:"hold_after,omitempty"` +} + +type GetZoneHoldParams struct{} + +// CreateZoneHold enforces a zone hold on the zone, blocking the creation and +// activation of zone. +// +// API reference: https://developers.cloudflare.com/api/operations/zones-0-hold-post +func (api *API) CreateZoneHold(ctx context.Context, rc *ResourceContainer, params CreateZoneHoldParams) (ZoneHold, error) { + if rc.Level != ZoneRouteLevel { + return ZoneHold{}, ErrRequiredZoneLevelResourceContainer + } + + uri := buildURI(fmt.Sprintf("/zones/%s/hold", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodPost, uri, nil) + if err != nil { + return ZoneHold{}, err + } + + response := &ZoneHoldResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneHold{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// DeleteZoneHold removes enforcement of a zone hold on the zone, permanently or +// temporarily, allowing the creation and activation of zones with this hostname. +// +// API reference:https://developers.cloudflare.com/api/operations/zones-0-hold-delete +func (api *API) DeleteZoneHold(ctx context.Context, rc *ResourceContainer, params DeleteZoneHoldParams) (ZoneHold, error) { + if rc.Level != ZoneRouteLevel { + return ZoneHold{}, ErrRequiredZoneLevelResourceContainer + } + + uri := buildURI(fmt.Sprintf("/zones/%s/hold", rc.Identifier), params) + res, err := api.makeRequestContext(ctx, http.MethodDelete, uri, nil) + if err != nil { + return ZoneHold{}, err + } + + response := &ZoneHoldResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneHold{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} + +// GetZoneHold retrieves whether the zone is subject to a zone hold, and the +// metadata about the hold. +// +// API reference: https://developers.cloudflare.com/api/operations/zones-0-hold-get +func (api *API) GetZoneHold(ctx context.Context, rc *ResourceContainer, params GetZoneHoldParams) (ZoneHold, error) { + if rc.Level != ZoneRouteLevel { + return ZoneHold{}, ErrRequiredZoneLevelResourceContainer + } + + uri := fmt.Sprintf("/zones/%s/hold", rc.Identifier) + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return ZoneHold{}, err + } + + response := &ZoneHoldResponse{} + err = json.Unmarshal(res, &response) + if err != nil { + return ZoneHold{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return response.Result, nil +} diff --git a/pkg/cloudflare-go/zone_hold_test.go b/pkg/cloudflare-go/zone_hold_test.go new file mode 100644 index 000000000..6911e8060 --- /dev/null +++ b/pkg/cloudflare-go/zone_hold_test.go @@ -0,0 +1,124 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCreateZoneHold(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/hold", testZoneID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "true", r.URL.Query().Get("include_subdomains")) + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "hold": true, + "hold_after": "2023-03-20T15:12:32Z", + "include_subdomains": true + } + }`) + }) + + _, err := client.CreateZoneHold(context.Background(), AccountIdentifier(""), CreateZoneHoldParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrRequiredZoneLevelResourceContainer, err) + } + + out, err := client.CreateZoneHold(context.Background(), ZoneIdentifier(testZoneID), CreateZoneHoldParams{IncludeSubdomains: BoolPtr(true)}) + holdAfter, _ := time.Parse(time.RFC3339Nano, "2023-03-20T15:12:32Z") + want := ZoneHold{ + IncludeSubdomains: BoolPtr(true), + Hold: BoolPtr(true), + HoldAfter: &holdAfter, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, out) + } +} + +func TestDeleteZoneHold(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/hold", testZoneID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2023-03-20T15:12:32Z", r.URL.Query().Get("hold_after")) + assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "hold": false, + "hold_after": "2023-03-20T15:12:32.025799Z", + "include_subdomains": false + } + }`) + }) + + _, err := client.DeleteZoneHold(context.Background(), AccountIdentifier(""), DeleteZoneHoldParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrRequiredZoneLevelResourceContainer, err) + } + + holdAfter, _ := time.Parse(time.RFC3339, "2023-03-20T15:12:32.025799Z") + out, err := client.DeleteZoneHold(context.Background(), ZoneIdentifier(testZoneID), DeleteZoneHoldParams{HoldAfter: &holdAfter}) + want := ZoneHold{ + IncludeSubdomains: BoolPtr(false), + Hold: BoolPtr(false), + HoldAfter: &holdAfter, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, out) + } +} + +func TestGetZoneHold(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc(fmt.Sprintf("/zones/%s/hold", testZoneID), func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method) + fmt.Fprint(w, ` + { + "success": true, + "errors": [], + "messages": [], + "result": { + "hold": false, + "hold_after": "2023-03-20T15:12:32Z", + "include_subdomains": false + } + }`) + }) + + _, err := client.GetZoneHold(context.Background(), AccountIdentifier(""), GetZoneHoldParams{}) + if assert.Error(t, err) { + assert.Equal(t, ErrRequiredZoneLevelResourceContainer, err) + } + + out, err := client.GetZoneHold(context.Background(), ZoneIdentifier(testZoneID), GetZoneHoldParams{}) + holdAfter, _ := time.Parse(time.RFC3339Nano, "2023-03-20T15:12:32Z") + want := ZoneHold{ + IncludeSubdomains: BoolPtr(false), + Hold: BoolPtr(false), + HoldAfter: &holdAfter, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, out) + } +} diff --git a/pkg/cloudflare-go/zone_test.go b/pkg/cloudflare-go/zone_test.go new file mode 100644 index 000000000..d8b94cd1c --- /dev/null +++ b/pkg/cloudflare-go/zone_test.go @@ -0,0 +1,1612 @@ +package cloudflare + +import ( + "context" + "crypto/md5" //nolint:gosec + "encoding/hex" // for generating IDs + "fmt" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" +) + +// mockID returns a hex string of length 32, suitable for all kinds of IDs +// used in the Cloudflare API. +func mockID(seed string) string { + arr := md5.Sum([]byte(seed)) //nolint:gosec + return hex.EncodeToString(arr[:]) +} + +func mustParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + panic(err) + } + return t +} + +func mockZone(i int) *Zone { + zoneName := fmt.Sprintf("%d.example.com", i) + ownerName := "Test Account" + + return &Zone{ + ID: mockID(zoneName), + Name: zoneName, + DevMode: 0, + OriginalNS: []string{ + "linda.ns.cloudflare.com", + "merlin.ns.cloudflare.com", + }, + OriginalRegistrar: "cloudflare, inc. (id: 1910)", + OriginalDNSHost: "", + CreatedOn: mustParseTime("2021-07-28T05:06:20.736244Z"), + ModifiedOn: mustParseTime("2021-07-28T05:06:20.736244Z"), + NameServers: []string{ + "abby.ns.cloudflare.com", + "noel.ns.cloudflare.com", + }, + Owner: Owner{ + ID: mockID(ownerName), + Email: "", + Name: ownerName, + OwnerType: "organization", + }, + Permissions: []string{ + "#access:read", + "#analytics:read", + "#auditlogs:read", + "#billing:read", + "#dns_records:read", + "#lb:read", + "#legal:read", + "#logs:read", + "#member:read", + "#organization:read", + "#ssl:read", + "#stream:read", + "#subscription:read", + "#waf:read", + "#webhooks:read", + "#worker:read", + "#zone:read", + "#zone_settings:read", + }, + Plan: ZonePlan{ + ZonePlanCommon: ZonePlanCommon{ + ID: "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + Name: "Free Website", + Currency: "USD", + }, + IsSubscribed: false, + CanSubscribe: false, + LegacyID: "free", + LegacyDiscount: false, + ExternallyManaged: false, + }, + PlanPending: ZonePlan{ + ZonePlanCommon: ZonePlanCommon{ + ID: "", + }, + IsSubscribed: false, + CanSubscribe: false, + LegacyID: "", + LegacyDiscount: false, + ExternallyManaged: false, + }, + Status: "active", + Paused: false, + Type: "full", + Host: struct { + Name string + Website string + }{ + Name: "", + Website: "", + }, + VanityNS: nil, + Betas: nil, + DeactReason: "", + Meta: ZoneMeta{ + PageRuleQuota: 3, + WildcardProxiable: false, + PhishingDetected: false, + }, + Account: Account{ + ID: mockID(ownerName), + Name: ownerName, + }, + VerificationKey: "", + } +} + +func mockZonesResponse(total, page, start, count int) *ZonesResponse { + zones := make([]Zone, count) + for i := range zones { + zones[i] = *mockZone(start + i) + } + + return &ZonesResponse{ + Result: zones, + ResultInfo: ResultInfo{ + Page: page, + PerPage: 50, + TotalPages: (total + 49) / 50, + Count: count, + Total: total, + }, + Response: Response{ + Success: true, + Errors: []ResponseInfo{}, + Messages: []ResponseInfo{}, + }, + } +} + +func TestZoneAnalyticsDashboard(t *testing.T) { + setup() + defer teardown() + + 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, "2015-01-01T12:23:00Z", r.URL.Query().Get("since")) + assert.Equal(t, "2015-01-02T12:23:00Z", r.URL.Query().Get("until")) + assert.Equal(t, "true", r.URL.Query().Get("continuous")) + + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#zone-analytics-properties + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "totals": { + "since": "2015-01-01T12:23:00Z", + "until": "2015-01-02T12:23:00Z", + "requests": { + "all": 1234085328, + "cached": 1234085328, + "uncached": 13876154, + "content_type": { + "css": 15343, + "html": 1234213, + "javascript": 318236, + "gif": 23178, + "jpeg": 1982048 + }, + "country": { + "US": 4181364, + "AG": 37298, + "GI": 293846 + }, + "ssl": { + "encrypted": 12978361, + "unencrypted": 781263 + }, + "http_status": { + "200": 13496983, + "301": 283, + "400": 187936, + "402": 1828, + "404": 1293 + } + }, + "bandwidth": { + "all": 213867451, + "cached": 113205063, + "uncached": 113205063, + "content_type": { + "css": 237421, + "html": 1231290, + "javascript": 123245, + "gif": 1234242, + "jpeg": 784278 + }, + "country": { + "US": 123145433, + "AG": 2342483, + "GI": 984753 + }, + "ssl": { + "encrypted": 37592942, + "unencrypted": 237654192 + } + }, + "threats": { + "all": 23423873, + "country": { + "US": 123, + "CN": 523423, + "AU": 91 + }, + "type": { + "user.ban.ip": 123, + "hot.ban.unknown": 5324, + "macro.chl.captchaErr": 1341, + "macro.chl.jschlErr": 5323 + } + }, + "pageviews": { + "all": 5724723, + "search_engines": { + "googlebot": 35272, + "pingdom": 13435, + "bingbot": 5372, + "baidubot": 1345 + } + }, + "uniques": { + "all": 12343 + } + }, + "timeseries": [ + { + "since": "2015-01-01T12:23:00Z", + "until": "2015-01-02T12:23:00Z", + "requests": { + "all": 1234085328, + "cached": 1234085328, + "uncached": 13876154, + "content_type": { + "css": 15343, + "html": 1234213, + "javascript": 318236, + "gif": 23178, + "jpeg": 1982048 + }, + "country": { + "US": 4181364, + "AG": 37298, + "GI": 293846 + }, + "ssl": { + "encrypted": 12978361, + "unencrypted": 781263 + }, + "http_status": { + "200": 13496983, + "301": 283, + "400": 187936, + "402": 1828, + "404": 1293 + } + }, + "bandwidth": { + "all": 213867451, + "cached": 113205063, + "uncached": 113205063, + "content_type": { + "css": 237421, + "html": 1231290, + "javascript": 123245, + "gif": 1234242, + "jpeg": 784278 + }, + "country": { + "US": 123145433, + "AG": 2342483, + "GI": 984753 + }, + "ssl": { + "encrypted": 37592942, + "unencrypted": 237654192 + } + }, + "threats": { + "all": 23423873, + "country": { + "US": 123, + "CN": 523423, + "AU": 91 + }, + "type": { + "user.ban.ip": 123, + "hot.ban.unknown": 5324, + "macro.chl.captchaErr": 1341, + "macro.chl.jschlErr": 5323 + } + }, + "pageviews": { + "all": 5724723, + "search_engines": { + "googlebot": 35272, + "pingdom": 13435, + "bingbot": 5372, + "baidubot": 1345 + } + }, + "uniques": { + "all": 12343 + } + } + ] + }, + "query": { + "since": "2015-01-01T12:23:00Z", + "until": "2015-01-02T12:23:00Z", + "time_delta": 60 + } + }`) + } + + mux.HandleFunc("/zones/foo/analytics/dashboard", handler) + + since, _ := time.Parse(time.RFC3339, "2015-01-01T12:23:00Z") + until, _ := time.Parse(time.RFC3339, "2015-01-02T12:23:00Z") + data := ZoneAnalytics{ + Since: since, + Until: until, + Requests: struct { + All int `json:"all"` + Cached int `json:"cached"` + Uncached int `json:"uncached"` + ContentType map[string]int `json:"content_type"` + Country map[string]int `json:"country"` + SSL struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + } `json:"ssl"` + HTTPStatus map[string]int `json:"http_status"` + }{ + All: 1234085328, + Cached: 1234085328, + Uncached: 13876154, + ContentType: map[string]int{ + "css": 15343, + "html": 1234213, + "javascript": 318236, + "gif": 23178, + "jpeg": 1982048, + }, + Country: map[string]int{ + "US": 4181364, + "AG": 37298, + "GI": 293846, + }, + SSL: struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + }{ + Encrypted: 12978361, + Unencrypted: 781263, + }, + HTTPStatus: map[string]int{ + "200": 13496983, + "301": 283, + "400": 187936, + "402": 1828, + "404": 1293, + }, + }, + Bandwidth: struct { + All int `json:"all"` + Cached int `json:"cached"` + Uncached int `json:"uncached"` + ContentType map[string]int `json:"content_type"` + Country map[string]int `json:"country"` + SSL struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + } `json:"ssl"` + }{ + All: 213867451, + Cached: 113205063, + Uncached: 113205063, + ContentType: map[string]int{ + "css": 237421, + "html": 1231290, + "javascript": 123245, + "gif": 1234242, + "jpeg": 784278, + }, + Country: map[string]int{ + "US": 123145433, + "AG": 2342483, + "GI": 984753, + }, + SSL: struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + }{ + Encrypted: 37592942, + Unencrypted: 237654192, + }, + }, + Threats: struct { + All int `json:"all"` + Country map[string]int `json:"country"` + Type map[string]int `json:"type"` + }{ + All: 23423873, + Country: map[string]int{ + "US": 123, + "CN": 523423, + "AU": 91, + }, + Type: map[string]int{ + "user.ban.ip": 123, + "hot.ban.unknown": 5324, + "macro.chl.captchaErr": 1341, + "macro.chl.jschlErr": 5323, + }, + }, + Pageviews: struct { + All int `json:"all"` + SearchEngines map[string]int `json:"search_engines"` + }{ + All: 5724723, + SearchEngines: map[string]int{ + "googlebot": 35272, + "pingdom": 13435, + "bingbot": 5372, + "baidubot": 1345, + }, + }, + Uniques: struct { + All int `json:"all"` + }{ + All: 12343, + }, + } + want := ZoneAnalyticsData{ + Totals: data, + Timeseries: []ZoneAnalytics{data}, + } + + continuous := true + d, err := client.ZoneAnalyticsDashboard(context.Background(), "foo", ZoneAnalyticsOptions{ + Since: &since, + Until: &until, + Continuous: &continuous, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ZoneAnalyticsDashboard(context.Background(), "bar", ZoneAnalyticsOptions{}) + assert.Error(t, err) +} + +func TestZoneAnalyticsByColocation(t *testing.T) { + setup() + defer teardown() + + 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, "2015-01-01T12:23:00Z", r.URL.Query().Get("since")) + assert.Equal(t, "2015-01-02T12:23:00Z", r.URL.Query().Get("until")) + assert.Equal(t, "true", r.URL.Query().Get("continuous")) + + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#zone-analytics-analytics-by-co-locations + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": [ + { + "colo_id": "SFO", + "timeseries": [ + { + "since": "2015-01-01T12:23:00Z", + "until": "2015-01-02T12:23:00Z", + "requests": { + "all": 1234085328, + "cached": 1234085328, + "uncached": 13876154, + "content_type": { + "css": 15343, + "html": 1234213, + "javascript": 318236, + "gif": 23178, + "jpeg": 1982048 + }, + "country": { + "US": 4181364, + "AG": 37298, + "GI": 293846 + }, + "ssl": { + "encrypted": 12978361, + "unencrypted": 781263 + }, + "http_status": { + "200": 13496983, + "301": 283, + "400": 187936, + "402": 1828, + "404": 1293 + } + }, + "bandwidth": { + "all": 213867451, + "cached": 113205063, + "uncached": 113205063, + "content_type": { + "css": 237421, + "html": 1231290, + "javascript": 123245, + "gif": 1234242, + "jpeg": 784278 + }, + "country": { + "US": 123145433, + "AG": 2342483, + "GI": 984753 + }, + "ssl": { + "encrypted": 37592942, + "unencrypted": 237654192 + } + }, + "threats": { + "all": 23423873, + "country": { + "US": 123, + "CN": 523423, + "AU": 91 + }, + "type": { + "user.ban.ip": 123, + "hot.ban.unknown": 5324, + "macro.chl.captchaErr": 1341, + "macro.chl.jschlErr": 5323 + } + }, + "pageviews": { + "all": 5724723, + "search_engines": { + "googlebot": 35272, + "pingdom": 13435, + "bingbot": 5372, + "baidubot": 1345 + } + }, + "uniques": { + "all": 12343 + } + } + ] + } + ], + "query": { + "since": "2015-01-01T12:23:00Z", + "until": "2015-01-02T12:23:00Z", + "time_delta": 60 + } + }`) + } + + mux.HandleFunc("/zones/foo/analytics/colos", handler) + + since, _ := time.Parse(time.RFC3339, "2015-01-01T12:23:00Z") + until, _ := time.Parse(time.RFC3339, "2015-01-02T12:23:00Z") + data := ZoneAnalytics{ + Since: since, + Until: until, + Requests: struct { + All int `json:"all"` + Cached int `json:"cached"` + Uncached int `json:"uncached"` + ContentType map[string]int `json:"content_type"` + Country map[string]int `json:"country"` + SSL struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + } `json:"ssl"` + HTTPStatus map[string]int `json:"http_status"` + }{ + All: 1234085328, + Cached: 1234085328, + Uncached: 13876154, + ContentType: map[string]int{ + "css": 15343, + "html": 1234213, + "javascript": 318236, + "gif": 23178, + "jpeg": 1982048, + }, + Country: map[string]int{ + "US": 4181364, + "AG": 37298, + "GI": 293846, + }, + SSL: struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + }{ + Encrypted: 12978361, + Unencrypted: 781263, + }, + HTTPStatus: map[string]int{ + "200": 13496983, + "301": 283, + "400": 187936, + "402": 1828, + "404": 1293, + }, + }, + Bandwidth: struct { + All int `json:"all"` + Cached int `json:"cached"` + Uncached int `json:"uncached"` + ContentType map[string]int `json:"content_type"` + Country map[string]int `json:"country"` + SSL struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + } `json:"ssl"` + }{ + All: 213867451, + Cached: 113205063, + Uncached: 113205063, + ContentType: map[string]int{ + "css": 237421, + "html": 1231290, + "javascript": 123245, + "gif": 1234242, + "jpeg": 784278, + }, + Country: map[string]int{ + "US": 123145433, + "AG": 2342483, + "GI": 984753, + }, + SSL: struct { + Encrypted int `json:"encrypted"` + Unencrypted int `json:"unencrypted"` + }{ + Encrypted: 37592942, + Unencrypted: 237654192, + }, + }, + Threats: struct { + All int `json:"all"` + Country map[string]int `json:"country"` + Type map[string]int `json:"type"` + }{ + All: 23423873, + Country: map[string]int{ + "US": 123, + "CN": 523423, + "AU": 91, + }, + Type: map[string]int{ + "user.ban.ip": 123, + "hot.ban.unknown": 5324, + "macro.chl.captchaErr": 1341, + "macro.chl.jschlErr": 5323, + }, + }, + Pageviews: struct { + All int `json:"all"` + SearchEngines map[string]int `json:"search_engines"` + }{ + All: 5724723, + SearchEngines: map[string]int{ + "googlebot": 35272, + "pingdom": 13435, + "bingbot": 5372, + "baidubot": 1345, + }, + }, + Uniques: struct { + All int `json:"all"` + }{ + All: 12343, + }, + } + want := []ZoneAnalyticsColocation{ + { + ColocationID: "SFO", + Timeseries: []ZoneAnalytics{data}, + }, + } + + continuous := true + d, err := client.ZoneAnalyticsByColocation(context.Background(), "foo", ZoneAnalyticsOptions{ + Since: &since, + Until: &until, + Continuous: &continuous, + }) + if assert.NoError(t, err) { + assert.Equal(t, want, d) + } + + _, err = client.ZoneAnalyticsDashboard(context.Background(), "bar", ZoneAnalyticsOptions{}) + assert.Error(t, err) +} + +func TestWithPagination(t *testing.T) { + opt := reqOption{ + params: url.Values{}, + } + popts := PaginationOptions{ + Page: 45, + PerPage: 500, + } + of := WithPagination(popts) + of(&opt) + + tests := []struct { + name string + expected string + }{ + {"page", "45"}, + {"per_page", "500"}, + } + + for _, tt := range tests { + if got := opt.params.Get(tt.name); got != tt.expected { + t.Errorf("expected param %s to be %s, got %s", tt.name, tt.expected, got) + } + } +} + +func TestZoneFilters(t *testing.T) { + opt := reqOption{ + params: url.Values{}, + } + of := WithZoneFilters("example.org", "", "") + of(&opt) + + if got := opt.params.Get("name"); got != "example.org" { + t.Errorf("expected param %s to be %s, got %s", "name", "example.org", got) + } +} + +var createdAndModifiedOn, _ = time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z") +var expectedFullZoneSetup = Zone{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "example.com", + DevMode: 7200, + OriginalNS: []string{ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com", + }, + OriginalRegistrar: "GoDaddy", + OriginalDNSHost: "NameCheap", + CreatedOn: createdAndModifiedOn, + ModifiedOn: createdAndModifiedOn, + Owner: Owner{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + OwnerType: "user", + }, + Account: Account{ + ID: "01a7362d577a6c3019a474fd6f485823", + Name: "Demo Account", + }, + Permissions: []string{"#zone:read", "#zone:edit"}, + Plan: ZonePlan{ + ZonePlanCommon: ZonePlanCommon{ + ID: "e592fd9519420ba7405e1307bff33214", + Name: "Pro Plan", + Price: 20, + Currency: "USD", + Frequency: "monthly", + }, + LegacyID: "pro", + IsSubscribed: true, + CanSubscribe: true, + }, + PlanPending: ZonePlan{ + ZonePlanCommon: ZonePlanCommon{ + ID: "e592fd9519420ba7405e1307bff33214", + Name: "Pro Plan", + Price: 20, + Currency: "USD", + Frequency: "monthly", + }, + LegacyID: "pro", + IsSubscribed: true, + CanSubscribe: true, + }, + Status: "active", + Paused: false, + Type: "full", + NameServers: []string{"tony.ns.cloudflare.com", "woz.ns.cloudflare.com"}, +} +var expectedPartialZoneSetup = Zone{ + ID: "023e105f4ecef8ad9ca31a8372d0c353", + Name: "example.com", + DevMode: 7200, + OriginalNS: []string{ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com", + }, + OriginalRegistrar: "GoDaddy", + OriginalDNSHost: "NameCheap", + CreatedOn: createdAndModifiedOn, + ModifiedOn: createdAndModifiedOn, + Owner: Owner{ + ID: "7c5dae5552338874e5053f2534d2767a", + Email: "user@example.com", + OwnerType: "user", + }, + Account: Account{ + ID: "01a7362d577a6c3019a474fd6f485823", + Name: "Demo Account", + }, + Permissions: []string{"#zone:read", "#zone:edit"}, + Plan: ZonePlan{ + ZonePlanCommon: ZonePlanCommon{ + ID: "e592fd9519420ba7405e1307bff33214", + Name: "Pro Plan", + Price: 20, + Currency: "USD", + Frequency: "monthly", + }, + LegacyID: "pro", + IsSubscribed: true, + CanSubscribe: true, + }, + PlanPending: ZonePlan{ + ZonePlanCommon: ZonePlanCommon{ + ID: "e592fd9519420ba7405e1307bff33214", + Name: "Pro Plan", + Price: 20, + Currency: "USD", + Frequency: "monthly", + }, + LegacyID: "pro", + IsSubscribed: true, + CanSubscribe: true, + }, + Status: "active", + Paused: false, + Type: "partial", + NameServers: []string{"tony.ns.cloudflare.com", "woz.ns.cloudflare.com"}, +} + +func TestCreateZoneFullSetup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "example.com", + "development_mode": 7200, + "original_name_servers": [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + "original_registrar": "GoDaddy", + "original_dnshost": "NameCheap", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "activated_on": "2014-01-02T00:01:00.12345Z", + "owner": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "type": "user" + }, + "account": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Demo Account" + }, + "permissions": [ + "#zone:read", + "#zone:edit" + ], + "plan": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "plan_pending": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "status": "active", + "paused": false, + "type": "full", + "name_servers": [ + "tony.ns.cloudflare.com", + "woz.ns.cloudflare.com" + ] + } + } + `) + } + + mux.HandleFunc("/zones", handler) + + actual, err := client.CreateZone(context.Background(), "example.com", false, Account{ID: "01a7362d577a6c3019a474fd6f485823"}, "full") + + if assert.NoError(t, err) { + assert.Equal(t, expectedFullZoneSetup, actual) + } +} + +func TestCreateZonePartialSetup(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method) + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "023e105f4ecef8ad9ca31a8372d0c353", + "name": "example.com", + "development_mode": 7200, + "original_name_servers": [ + "ns1.originaldnshost.com", + "ns2.originaldnshost.com" + ], + "original_registrar": "GoDaddy", + "original_dnshost": "NameCheap", + "created_on": "2014-01-01T05:20:00.12345Z", + "modified_on": "2014-01-01T05:20:00.12345Z", + "activated_on": "2014-01-02T00:01:00.12345Z", + "owner": { + "id": "7c5dae5552338874e5053f2534d2767a", + "email": "user@example.com", + "type": "user" + }, + "account": { + "id": "01a7362d577a6c3019a474fd6f485823", + "name": "Demo Account" + }, + "permissions": [ + "#zone:read", + "#zone:edit" + ], + "plan": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "plan_pending": { + "id": "e592fd9519420ba7405e1307bff33214", + "name": "Pro Plan", + "price": 20, + "currency": "USD", + "frequency": "monthly", + "legacy_id": "pro", + "is_subscribed": true, + "can_subscribe": true + }, + "status": "active", + "paused": false, + "type": "partial", + "name_servers": [ + "tony.ns.cloudflare.com", + "woz.ns.cloudflare.com" + ] + } + } + `) + } + + mux.HandleFunc("/zones", handler) + + actual, err := client.CreateZone(context.Background(), "example.com", false, Account{ID: "01a7362d577a6c3019a474fd6f485823"}, "partial") + + if assert.NoError(t, err) { + assert.Equal(t, expectedPartialZoneSetup, actual) + } +} + +func TestFallbackOrigin_FallbackOrigin(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/fallback_origin", 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.Fprintf(w, `{ +"success": true, +"errors": [], +"messages": [], +"result": { + "id": "fallback_origin", + "value": "app.example.com", + "editable": true + } +}`) + }) + + fallbackOrigin, err := client.FallbackOrigin(context.Background(), "foo") + + want := FallbackOrigin{ + ID: "fallback_origin", + Value: "app.example.com", + } + + if assert.NoError(t, err) { + assert.Equal(t, want, fallbackOrigin) + } +} + +func TestFallbackOrigin_UpdateFallbackOrigin(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/zones/foo/fallback_origin", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, ` +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "id": "fallback_origin", + "value": "app.example.com", + "editable": true + } +}`) + }) + + response, err := client.UpdateFallbackOrigin(context.Background(), "foo", FallbackOrigin{Value: "app.example.com"}) + + want := &FallbackOriginResponse{ + Result: FallbackOrigin{ + ID: "fallback_origin", + Value: "app.example.com", + }, + Response: Response{Success: true, Errors: []ResponseInfo{}, Messages: []ResponseInfo{}}, + } + + if assert.NoError(t, err) { + assert.Equal(t, want, response) + } +} + +func Test_normalizeZoneName(t *testing.T) { + tests := []struct { + name string + zone string + expected string + }{ + { + name: "unicode stays unicode", + zone: "ünì¢øðe.tld", + expected: "ünì¢øðe.tld", + }, { + name: "valid punycode is normalized to unicode", + zone: "xn--ne-7ca90ava1cya.tld", + expected: "ünì¢øðe.tld", + }, { + name: "valid punycode in second label", + zone: "example.xn--j6w193g", + expected: "example.香港", + }, { + name: "invalid punycode is returned without change", + zone: "xn-invalid.xn-invalid-tld", + expected: "xn-invalid.xn-invalid-tld", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := normalizeZoneName(tt.zone) + assert.Equal(t, tt.expected, actual) + }) + } +} + +func TestZonePartialHasVerificationKey(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#zone-zone-details (plus an undocumented field verification_key from curl to API) + fmt.Fprintf(w, `{ + "result": { + "id": "foo", + "name": "bar", + "status": "active", + "paused": false, + "type": "partial", + "development_mode": 0, + "verification_key": "foo-bar", + "original_name_servers": ["a","b","c","d"], + "original_registrar": null, + "original_dnshost": null, + "modified_on": "2019-09-04T15:11:43.409805Z", + "created_on": "2018-12-06T14:33:38.410126Z", + "activated_on": "2018-12-06T14:34:39.274528Z", + "meta": { + "step": 4, + "wildcard_proxiable": true, + "custom_certificate_quota": 1, + "page_rule_quota": 100, + "phishing_detected": false, + "multiple_railguns_allowed": false + }, + "owner": { + "id": "bbbbbbbbbbbbbbbbbbbbbbbb", + "type": "organization", + "name": "OrgName" + }, + "account": { + "id": "aaaaaaaaaaaaaaaaaaaaaaaa", + "name": "AccountName" + }, + "permissions": [ + "#access:edit", + "#access:read", + "#analytics:read", + "#app:edit", + "#auditlogs:read", + "#billing:read", + "#cache_purge:edit", + "#dns_records:edit", + "#dns_records:read", + "#lb:edit", + "#lb:read", + "#legal:read", + "#logs:edit", + "#logs:read", + "#member:read", + "#organization:edit", + "#organization:read", + "#ssl:edit", + "#ssl:read", + "#stream:edit", + "#stream:read", + "#subscription:edit", + "#subscription:read", + "#waf:edit", + "#waf:read", + "#webhooks:edit", + "#webhooks:read", + "#worker:edit", + "#worker:read", + "#zone:edit", + "#zone:read", + "#zone_settings:edit", + "#zone_settings:read" + ], + "plan": { + "id": "94f3b7b768b0458b56d2cac4fe5ec0f9", + "name": "Enterprise Website", + "price": 0, + "currency": "USD", + "frequency": "monthly", + "is_subscribed": true, + "can_subscribe": true, + "legacy_id": "enterprise", + "legacy_discount": false, + "externally_managed": true + } + }, + "success": true, + "errors": [], + "messages": [] +}`) + } + + mux.HandleFunc("/zones/foo", handler) + + z, err := client.ZoneDetails(context.Background(), "foo") + if assert.NoError(t, err) { + assert.NotEmpty(t, z.VerificationKey) + assert.Equal(t, z.VerificationKey, "foo-bar") + } +} + +func TestZoneDNSSECSetting(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#dnssec-properties + fmt.Fprintf(w, `{ + "result": { + "status": "active", + "flags": 257, + "algorithm": "13", + "key_type": "ECDSAP256SHA256", + "digest_type": "2", + "digest_algorithm": "SHA256", + "digest": "48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45", + "ds": "example.com. 3600 IN DS 16953 13 2 48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45", + "key_tag": 42, + "public_key": "oXiGYrSTO+LSCJ3mohc8EP+CzF9KxBj8/ydXJ22pKuZP3VAC3/Md/k7xZfz470CoRyZJ6gV6vml07IC3d8xqhA==", + "modified_on": "2014-01-01T05:20:00Z" + } + }`) + } + + mux.HandleFunc("/zones/foo/dnssec", handler) + + z, err := client.ZoneDNSSECSetting(context.Background(), "foo") + if assert.NoError(t, err) { + assert.Equal(t, z.Status, "active") + assert.Equal(t, z.Flags, 257) + assert.Equal(t, z.Algorithm, "13") + assert.Equal(t, z.KeyType, "ECDSAP256SHA256") + assert.Equal(t, z.DigestType, "2") + assert.Equal(t, z.DigestAlgorithm, "SHA256") + assert.Equal(t, z.Digest, "48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45") + assert.Equal(t, z.DS, "example.com. 3600 IN DS 16953 13 2 48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45") + assert.Equal(t, z.KeyTag, 42) + assert.Equal(t, z.PublicKey, "oXiGYrSTO+LSCJ3mohc8EP+CzF9KxBj8/ydXJ22pKuZP3VAC3/Md/k7xZfz470CoRyZJ6gV6vml07IC3d8xqhA==") + time, _ := time.Parse("2006-01-02T15:04:05Z", "2014-01-01T05:20:00Z") + assert.Equal(t, z.ModifiedOn, time) + } +} + +func TestDeleteZoneDNSSEC(t *testing.T) { + setup() + defer teardown() + + 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") + // JSON data from: https://api.cloudflare.com/#dnssec-properties + fmt.Fprintf(w, `{ + "result": "foo" + }`) + } + + mux.HandleFunc("/zones/foo/dnssec", handler) + + z, err := client.DeleteZoneDNSSEC(context.Background(), "foo") + if assert.NoError(t, err) { + assert.Equal(t, z, "foo") + } +} + +func TestUpdateZoneDNSSEC(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#dnssec-properties + fmt.Fprintf(w, `{ + "result": { + "status": "active", + "flags": 257, + "algorithm": "13", + "key_type": "ECDSAP256SHA256", + "digest_type": "2", + "digest_algorithm": "SHA256", + "digest": "48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45", + "ds": "example.com. 3600 IN DS 16953 13 2 48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45", + "key_tag": 42, + "public_key": "oXiGYrSTO+LSCJ3mohc8EP+CzF9KxBj8/ydXJ22pKuZP3VAC3/Md/k7xZfz470CoRyZJ6gV6vml07IC3d8xqhA==", + "modified_on": "2014-01-01T05:20:00Z" + } + }`) + } + + mux.HandleFunc("/zones/foo/dnssec", handler) + + z, err := client.UpdateZoneDNSSEC(context.Background(), "foo", ZoneDNSSECUpdateOptions{ + Status: "active", + }) + if assert.NoError(t, err) { + assert.Equal(t, z.Status, "active") + assert.Equal(t, z.Flags, 257) + assert.Equal(t, z.Algorithm, "13") + assert.Equal(t, z.KeyType, "ECDSAP256SHA256") + assert.Equal(t, z.DigestType, "2") + assert.Equal(t, z.DigestAlgorithm, "SHA256") + assert.Equal(t, z.Digest, "48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45") + assert.Equal(t, z.DS, "example.com. 3600 IN DS 16953 13 2 48E939042E82C22542CB377B580DFDC52A361CEFDC72E7F9107E2B6BD9306A45") + assert.Equal(t, z.KeyTag, 42) + assert.Equal(t, z.PublicKey, "oXiGYrSTO+LSCJ3mohc8EP+CzF9KxBj8/ydXJ22pKuZP3VAC3/Md/k7xZfz470CoRyZJ6gV6vml07IC3d8xqhA==") + time, _ := time.Parse("2006-01-02T15:04:05Z", "2014-01-01T05:20:00Z") + assert.Equal(t, z.ModifiedOn, time) + } +} + +func TestZoneSetType(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "type": "partial", + "verification_key": "000000000-000000000", + "modified_on": "2014-01-01T05:20:00Z" + } + }`) + } + + mux.HandleFunc("/zones/foo", handler) + + z, err := client.ZoneSetType(context.Background(), "foo", "partial") + if assert.NoError(t, err) { + assert.Equal(t, z.Type, "partial") + assert.Equal(t, z.VerificationKey, "000000000-000000000") + time, _ := time.Parse("2006-01-02T15:04:05Z", "2014-01-01T05:20:00Z") + assert.Equal(t, z.ModifiedOn, time) + } +} + +func parsePage(t *testing.T, total int, s string) (int, bool) { + if s == "" { + return 1, true + } + + page, err := strconv.Atoi(s) + if !assert.NoError(t, err) { + return 0, false + } + + if !assert.LessOrEqual(t, page, total) || !assert.GreaterOrEqual(t, page, 1) { + return 0, false + } + + return page, true +} + +func TestListZones(t *testing.T) { + setup() + defer teardown() + + const ( + total = 392 + totalPage = (total + 49) / 50 + ) + + handler := func(w http.ResponseWriter, r *http.Request) { + switch { + case !assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method): + return + case !assert.Equal(t, "50", r.URL.Query().Get("per_page")): + return + } + + page, ok := parsePage(t, totalPage, r.URL.Query().Get("page")) + if !ok { + return + } + + start := (page - 1) * 50 + + count := 50 + if page == totalPage { + count = total - start + } + + w.Header().Set("content-type", "application/json") + err := json.NewEncoder(w).Encode(mockZonesResponse(total, page, start, count)) + assert.NoError(t, err) + } + + mux.HandleFunc("/zones", handler) + + zones, err := client.ListZones(context.Background()) + if !assert.NoError(t, err) || !assert.Equal(t, total, len(zones)) { + return + } + + for i, zone := range zones { + assert.Equal(t, *mockZone(i), zone) + } +} + +func TestListZonesFailingPages(t *testing.T) { + setup() + defer teardown() + + const ( + total = 1489 + totalPage = (total + 49) / 50 + ) + + // the pages to reject + isReject := func(i int) bool { return i == 4 || i == 7 } + + handler := func(w http.ResponseWriter, r *http.Request) { + switch { + case !assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method): + return + case !assert.Equal(t, "50", r.URL.Query().Get("per_page")): + return + } + + page, ok := parsePage(t, totalPage, r.URL.Query().Get("page")) + switch { + case !ok: + return + case isReject(page): + return + } + + start := (page - 1) * 50 + + count := 50 + if page == totalPage { + count = total - start + } + + w.Header().Set("content-type", "application/json") + err := json.NewEncoder(w).Encode(mockZonesResponse(total, page, start, count)) + assert.NoError(t, err) + } + + mux.HandleFunc("/zones", handler) + + _, err := client.ListZones(context.Background()) + assert.Error(t, err) +} + +func TestListZonesContextManualPagination1(t *testing.T) { + _, err := client.ListZonesContext(context.Background(), WithPagination(PaginationOptions{Page: 2})) + assert.EqualError(t, err, errManualPagination) +} + +func TestListZonesContextManualPagination2(t *testing.T) { + _, err := client.ListZonesContext(context.Background(), WithPagination(PaginationOptions{PerPage: 30})) + assert.EqualError(t, err, errManualPagination) +} + +func TestUpdateZoneSSLSettings(t *testing.T) { + setup() + defer teardown() + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + // JSON data from: https://api.cloudflare.com/#zone-settings-properties + _, _ = fmt.Fprintf(w, `{ + "result": { + "id": "ssl", + "value": "off", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/foo/settings/ssl", handler) + s, err := client.UpdateZoneSSLSettings(context.Background(), "foo", "off") + if assert.NoError(t, err) { + assert.Equal(t, s.ID, "ssl") + assert.Equal(t, s.Value, "off") + assert.Equal(t, s.Editable, true) + assert.Equal(t, s.ModifiedOn, "2014-01-01T05:20:00.12345Z") + } +} + +func TestGetZoneSetting(t *testing.T) { + setup() + defer teardown() + 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.Fprintf(w, `{ + "result": { + "id": "ssl", + "value": "off", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/foo/settings/ssl", handler) + s, err := client.GetZoneSetting(context.Background(), ZoneIdentifier("foo"), GetZoneSettingParams{Name: "ssl"}) + if assert.NoError(t, err) { + assert.Equal(t, s.ID, "ssl") + assert.Equal(t, s.Value, "off") + assert.Equal(t, s.Editable, true) + assert.Equal(t, s.ModifiedOn, "2014-01-01T05:20:00.12345Z") + } +} + +func TestGetZoneSettingWithCustomPathPrefix(t *testing.T) { + setup() + defer teardown() + 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.Fprintf(w, `{ + "result": { + "id": "ssl", + "value": "off", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/foo/my_custom_path/ssl", handler) + s, err := client.GetZoneSetting(context.Background(), ZoneIdentifier("foo"), GetZoneSettingParams{Name: "ssl", PathPrefix: "my_custom_path"}) + if assert.NoError(t, err) { + assert.Equal(t, s.ID, "ssl") + assert.Equal(t, s.Value, "off") + assert.Equal(t, s.Editable, true) + assert.Equal(t, s.ModifiedOn, "2014-01-01T05:20:00.12345Z") + } +} + +func TestUpdateZoneSetting(t *testing.T) { + setup() + defer teardown() + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "result": { + "id": "ssl", + "value": "off", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/foo/settings/ssl", handler) + s, err := client.UpdateZoneSetting(context.Background(), ZoneIdentifier("foo"), UpdateZoneSettingParams{Name: "ssl", Value: "off"}) + if assert.NoError(t, err) { + assert.Equal(t, s.ID, "ssl") + assert.Equal(t, s.Value, "off") + assert.Equal(t, s.Editable, true) + assert.Equal(t, s.ModifiedOn, "2014-01-01T05:20:00.12345Z") + } +} + +func TestUpdateZoneSettingWithCustomPathPrefix(t *testing.T) { + setup() + defer teardown() + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method, "Expected method 'PATCH', got %s", r.Method) + w.Header().Set("content-type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "result": { + "id": "ssl", + "value": "off", + "editable": true, + "modified_on": "2014-01-01T05:20:00.12345Z" + } + }`) + } + mux.HandleFunc("/zones/foo/my_custom_path/ssl", handler) + s, err := client.UpdateZoneSetting(context.Background(), ZoneIdentifier("foo"), UpdateZoneSettingParams{Name: "ssl", PathPrefix: "my_custom_path", Value: "off"}) + if assert.NoError(t, err) { + assert.Equal(t, s.ID, "ssl") + assert.Equal(t, s.Value, "off") + assert.Equal(t, s.Editable, true) + assert.Equal(t, s.ModifiedOn, "2014-01-01T05:20:00.12345Z") + } +} diff --git a/pkg/cloudflare-go/zones.go b/pkg/cloudflare-go/zones.go new file mode 100644 index 000000000..82ddd6c25 --- /dev/null +++ b/pkg/cloudflare-go/zones.go @@ -0,0 +1,144 @@ +package cloudflare + +import ( + "context" + "fmt" + + "github.com/goccy/go-json" +) + +const defaultZonesPerPage = 100 + +type ZonesService service + +type ZoneCreateParams struct { + Name string `json:"name"` + JumpStart bool `json:"jump_start"` + Type string `json:"type"` + Account *Account `json:"organization,omitempty"` +} + +type ZoneListParams struct { + Match string `url:"match,omitempty"` + Name string `url:"name,omitempty"` + AccountName string `url:"account.name,omitempty"` + Status string `url:"status,omitempty"` + AccountID string `url:"account.id,omitempty"` + Direction string `url:"direction,omitempty"` + + ResultInfo // Rename `ResultInfo` in the next major version. +} + +type ZoneUpdateParams struct { + ID string + Paused *bool `json:"paused"` + VanityNameServers []string `json:"vanity_name_servers,omitempty"` + Plan ZonePlan `json:"plan,omitempty"` + Type string `json:"type,omitempty"` +} + +// New creates a new zone. +// +// API reference: https://api.cloudflare.com/#zone-zone-details +func (s *ZonesService) New(ctx context.Context, zone *ZoneCreateParams) (Zone, error) { + res, err := s.client.post(ctx, "/zones", zone) + if err != nil { + return Zone{}, err + } + + var r ZoneResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Zone{}, fmt.Errorf("failed to unmarshal zone JSON data: %w", err) + } + + return r.Result, nil +} + +// Get fetches a single zone. +// +// API reference: https://api.cloudflare.com/#zone-zone-details +func (s *ZonesService) Get(ctx context.Context, rc *ResourceContainer) (Zone, error) { + uri := fmt.Sprintf("/zones/%s", rc.Identifier) + res, err := s.client.get(ctx, uri, nil) + if err != nil { + return Zone{}, fmt.Errorf("failed to fetch zones: %w", err) + } + + var r ZoneResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Zone{}, fmt.Errorf("failed to unmarshal zone JSON data: %w", err) + } + + return r.Result, nil +} + +// List returns all zones that match the provided `ZoneParams` struct. +// +// Pagination is automatically handled unless `params.Page` is supplied. +// +// API reference: https://api.cloudflare.com/#zone-list-zones +func (s *ZonesService) List(ctx context.Context, params *ZoneListParams) ([]Zone, *ResultInfo, error) { + res, _ := s.client.get(ctx, buildURI("/zones", params), nil) + + var r ZonesResponse + err := json.Unmarshal(res, &r) + if err != nil { + return []Zone{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal zone JSON data: %w", err) + } + + if params.Page < 1 && params.PerPage < 1 { + var zones []Zone + params.PerPage = defaultZonesPerPage + params.Page = 1 + for !params.ResultInfo.Done() { + res, _ := s.client.get(ctx, buildURI("/zones", params), nil) + + var zResponse ZonesResponse + err := json.Unmarshal(res, &zResponse) + if err != nil { + return []Zone{}, &ResultInfo{}, fmt.Errorf("failed to unmarshal zone JSON data: %w", err) + } + + zones = append(zones, zResponse.Result...) + + params.ResultInfo = zResponse.ResultInfo.Next() + } + r.Result = zones + } + + return r.Result, &r.ResultInfo, nil +} + +// Update modifies an existing zone. +// +// API reference: https://api.cloudflare.com/#zone-edit-zone +func (s *ZonesService) Update(ctx context.Context, params *ZoneUpdateParams) ([]Zone, error) { + uri := fmt.Sprintf("/zones/%s", params.ID) + res, _ := s.client.patch(ctx, uri, params) + + var r ZonesResponse + err := json.Unmarshal(res, &r) + if err != nil { + return []Zone{}, fmt.Errorf("failed to unmarshal zone JSON data: %w", err) + } + + return r.Result, nil +} + +// Delete deletes a zone based on ID. +// +// API reference: https://api.cloudflare.com/#zone-delete-zone +func (s *ZonesService) Delete(ctx context.Context, rc *ResourceContainer) error { + uri := fmt.Sprintf("/zones/%s", rc.Identifier) + res, _ := s.client.delete(ctx, uri, nil) + + var r ZoneResponse + err := json.Unmarshal(res, &r) + if err != nil { + return fmt.Errorf("failed to unmarshal zone JSON data: %w", err) + } + + return nil +} diff --git a/pkg/cloudflare-go/zt_risk_behaviors.go b/pkg/cloudflare-go/zt_risk_behaviors.go new file mode 100644 index 000000000..370e3c3ed --- /dev/null +++ b/pkg/cloudflare-go/zt_risk_behaviors.go @@ -0,0 +1,126 @@ +package cloudflare + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/goccy/go-json" +) + +// Behavior represents a single zt risk behavior config. +type Behavior struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + RiskLevel RiskLevel `json:"risk_level"` + Enabled *bool `json:"enabled"` +} + +// Wrapper used to have full-fidelity repro of json structure. +type Behaviors struct { + Behaviors map[string]Behavior `json:"behaviors"` +} + +// BehaviorResponse represents the response from the zt risk scoring endpoint +// and contains risk behaviors for an account. +type BehaviorResponse struct { + Success bool `json:"success"` + Result Behaviors `json:"result"` + Errors []string `json:"errors"` + Messages []string `json:"messages"` +} + +// Behaviors returns all zero trust risk scoring behaviors for the provided account +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-zt-risk-score-get-behaviors +func (api *API) Behaviors(ctx context.Context, accountID string) (Behaviors, error) { + uri := fmt.Sprintf("/accounts/%s/zt_risk_scoring/behaviors", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return Behaviors{}, err + } + + var r BehaviorResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Behaviors{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + return r.Result, nil +} + +// UpdateBehaviors returns all zero trust risk scoring behaviors for the provided account +// NOTE: description/name updates are no-ops, risk_level [low medium high] and enabled [true/false] results in modifications +// +// API reference: https://developers.cloudflare.com/api/operations/dlp-zt-risk-score-put-behaviors +func (api *API) UpdateBehaviors(ctx context.Context, accountID string, behaviors Behaviors) (Behaviors, error) { + uri := fmt.Sprintf("/accounts/%s/zt_risk_scoring/behaviors", accountID) + + res, err := api.makeRequestContext(ctx, http.MethodPut, uri, behaviors) + if err != nil { + return Behaviors{}, err + } + + var r BehaviorResponse + err = json.Unmarshal(res, &r) + if err != nil { + return Behaviors{}, fmt.Errorf("%s: %w", errUnmarshalError, err) + } + + return r.Result, nil +} + +type RiskLevel int + +const ( + _ RiskLevel = iota + Low + Medium + High +) + +func (p RiskLevel) MarshalJSON() ([]byte, error) { + return json.Marshal(p.String()) +} + +func (p RiskLevel) String() string { + return [...]string{"low", "medium", "high"}[p-1] +} + +func (p *RiskLevel) UnmarshalJSON(data []byte) error { + var ( + s string + err error + ) + err = json.Unmarshal(data, &s) + if err != nil { + return err + } + v, err := RiskLevelFromString(s) + if err != nil { + return err + } + *p = *v + return nil +} + +func RiskLevelFromString(s string) (*RiskLevel, error) { + s = strings.ToLower(s) + var v RiskLevel + switch s { + case "low": + v = Low + case "medium": + v = Medium + case "high": + v = High + default: + return nil, fmt.Errorf("unknown variant for risk level: %s", s) + } + return &v, nil +} + +func (p RiskLevel) IntoRef() *RiskLevel { + return &p +} diff --git a/pkg/cloudflare-go/zt_risk_behaviors_test.go b/pkg/cloudflare-go/zt_risk_behaviors_test.go new file mode 100644 index 000000000..2ed6cfa26 --- /dev/null +++ b/pkg/cloudflare-go/zt_risk_behaviors_test.go @@ -0,0 +1,159 @@ +package cloudflare + +import ( + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + expectedBehaviors = Behaviors{ + Behaviors: map[string]Behavior{ + "high_dlp": { + Name: "High Number of DLP Policies Triggered", + Description: "User has triggered an active DLP profile in a Gateway policy fifteen times or more within one minute.", + RiskLevel: Low, + Enabled: BoolPtr(true), + }, + "imp_travel": { + Name: "Impossible Travel", + Description: "A user had a successful Access application log in from two locations that they could not have traveled to in that period of time.", + RiskLevel: High, + Enabled: BoolPtr(false), + }, + }, + } + + updateBehaviors = Behaviors{ + Behaviors: map[string]Behavior{ + "high_dlp": { + RiskLevel: Low, + Enabled: BoolPtr(true), + }, + "imp_travel": { + RiskLevel: High, + Enabled: BoolPtr(false), + }, + }, + } +) + +func TestBehaviors(t *testing.T) { + setup() + defer teardown() + + 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.Fprintf(w, `{ + "result": { + "behaviors": { + "high_dlp": { + "name": "High Number of DLP Policies Triggered", + "description": "User has triggered an active DLP profile in a Gateway policy fifteen times or more within one minute.", + "risk_level": "low", + "enabled":true + }, + "imp_travel": { + "name": "Impossible Travel", + "description": "A user had a successful Access application log in from two locations that they could not have traveled to in that period of time.", + "risk_level": "high", + "enabled": false + } + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/zt_risk_scoring/behaviors", handler) + want := expectedBehaviors + + actual, err := client.Behaviors(context.Background(), "01a7362d577a6c3019a474fd6f485823") + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestUpdateBehaviors(t *testing.T) { + setup() + defer teardown() + + handler := func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method, "Expected method 'PUT', got %s", r.Method) + b, err := io.ReadAll(r.Body) + defer r.Body.Close() + + if assert.NoError(t, err) { + assert.JSONEq(t, `{ + "behaviors": { + "high_dlp": { + "risk_level": "low", + "enabled":true + }, + "imp_travel": { + "risk_level": "high", + "enabled": false + } + } + }`, string(b), "JSON payload not equal") + } + + w.Header().Set("content-type", "application/json") + fmt.Fprintf(w, `{ + "result": { + "behaviors": { + "high_dlp": { + "name": "High Number of DLP Policies Triggered", + "description": "User has triggered an active DLP profile in a Gateway policy fifteen times or more within one minute.", + "risk_level": "low", + "enabled":true + }, + "imp_travel": { + "name": "Impossible Travel", + "description": "A user had a successful Access application log in from two locations that they could not have traveled to in that period of time.", + "risk_level": "high", + "enabled": false + } + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + } + + mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/zt_risk_scoring/behaviors", handler) + + want := expectedBehaviors + actual, err := client.UpdateBehaviors(context.Background(), "01a7362d577a6c3019a474fd6f485823", updateBehaviors) + + if assert.NoError(t, err) { + assert.Equal(t, want, actual) + } +} + +func TestRiskLevelFromString(t *testing.T) { + got, _ := RiskLevelFromString("high") + want := High + + if *got != want { + t.Errorf("got %#v, wanted %#v", *got, want) + } +} + +func TestStringFromRiskLevel(t *testing.T) { + got := fmt.Sprint(High) + want := "high" + + if got != want { + t.Errorf("got %#v, wanted %#v", got, want) + } +} diff --git a/providers/cloudflare/cloudflareProvider.go b/providers/cloudflare/cloudflareProvider.go index 40d6acc9d..62745b40a 100644 --- a/providers/cloudflare/cloudflareProvider.go +++ b/providers/cloudflare/cloudflareProvider.go @@ -77,10 +77,12 @@ func init() { type cloudflareProvider struct { ipConversions []transform.IPConversion ignoredLabels []string - manageRedirects bool + manageRedirects bool // Old "Page Rule"-style redirects. manageWorkers bool accountID string cfClient *cloudflare.API + // + manageSingleRedirects bool // New "Single Redirects"-style redirects. sync.Mutex // Protects all access to the following fields: domainIndex map[string]string // Cache of zone name to zone ID. @@ -167,7 +169,7 @@ func (c *cloudflareProvider) GetZoneRecords(domain string, meta map[string]strin // } // } - if c.manageRedirects { + if c.manageRedirects { // if old prs, err := c.getPageRules(domainID, domain) if err != nil { return nil, err @@ -175,6 +177,19 @@ func (c *cloudflareProvider) GetZoneRecords(domain string, meta map[string]strin records = append(records, prs...) } + if c.manageSingleRedirects { // if new xor old + // Download the list of Single Redirects. + // For each one, generate a CLOUDFLAREAPI_SINGLE_REDIRECT record + // Append these records to `records` + prs, err := c.getSingleRedirects(domainID, domain) + if err != nil { + return nil, err + } + //printer.Printf("DEBUG: Single Redirects") + //fmt.Fprintf(os.Stdout, "DEBUG: Single Redirects") + records = append(records, prs...) + } + if c.manageWorkers { wrs, err := c.getWorkerRoutes(domainID, domain) if err != nil { @@ -265,8 +280,9 @@ func (c *cloudflareProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, case diff2.DELETE: deleteRec := inst.Old[0] deleteRecType := deleteRec.Type - deleteRecOrig := deleteRec.Original - corrs = c.mkDeleteCorrection(deleteRecType, deleteRecOrig, domainID, msg) + //deleteRecOrig := deleteRec.Original + //corrs = c.mkDeleteCorrection(deleteRecType, deleteRecOrig, domainID, msg) + corrs = c.mkDeleteCorrection(deleteRecType, deleteRec, domainID, msg) // DS records must always have a corresponding NS record. // Therefore, we remove DS records before any NS records. addToFront = (deleteRecType == "DS") @@ -317,13 +333,20 @@ func (c *cloudflareProvider) mkCreateCorrection(newrec *models.RecordConfig, dom case "PAGE_RULE": return []*models.Correction{{ Msg: msg, - F: func() error { return c.createPageRule(domainID, newrec.GetTargetField()) }, + F: func() error { return c.createPageRule(domainID, *newrec.CloudflareRedirect) }, }} case "WORKER_ROUTE": return []*models.Correction{{ Msg: msg, F: func() error { return c.createWorkerRoute(domainID, newrec.GetTargetField()) }, }} + case "CLOUDFLAREAPI_SINGLE_REDIRECT": + return []*models.Correction{{ + Msg: msg, + F: func() error { + return c.createSingleRedirect(domainID, *newrec.CloudflareRedirect) + }, + }} default: return c.createRecDiff2(newrec, domainID, msg) } @@ -337,6 +360,9 @@ func (c *cloudflareProvider) mkChangeCorrection(oldrec, newrec *models.RecordCon idTxt = oldrec.Original.(cloudflare.PageRule).ID case "WORKER_ROUTE": idTxt = oldrec.Original.(cloudflare.WorkerRoute).ID + case "CLOUDFLAREAPI_SINGLE_REDIRECT": + //idTxt = oldrec.Original.(cloudflare.RulesetRule).ID + idTxt = oldrec.CloudflareRedirect.SRRRulesetID default: idTxt = oldrec.Original.(cloudflare.DNSRecord).ID } @@ -347,7 +373,14 @@ func (c *cloudflareProvider) mkChangeCorrection(oldrec, newrec *models.RecordCon return []*models.Correction{{ Msg: msg, F: func() error { - return c.updatePageRule(idTxt, domainID, newrec.GetTargetField()) + return c.updatePageRule(idTxt, domainID, *newrec.CloudflareRedirect) + }, + }} + case "CLOUDFLAREAPI_SINGLE_REDIRECT": + return []*models.Correction{{ + Msg: msg, + F: func() error { + return c.updateSingleRedirect(domainID, oldrec, newrec) }, }} case "WORKER_ROUTE": @@ -368,16 +401,18 @@ func (c *cloudflareProvider) mkChangeCorrection(oldrec, newrec *models.RecordCon } } -func (c *cloudflareProvider) mkDeleteCorrection(recType string, origRec any, domainID string, msg string) []*models.Correction { +func (c *cloudflareProvider) mkDeleteCorrection(recType string, origRec *models.RecordConfig, domainID string, msg string) []*models.Correction { var idTxt string switch recType { case "PAGE_RULE": - idTxt = origRec.(cloudflare.PageRule).ID + idTxt = origRec.Original.(cloudflare.PageRule).ID case "WORKER_ROUTE": - idTxt = origRec.(cloudflare.WorkerRoute).ID + idTxt = origRec.Original.(cloudflare.WorkerRoute).ID + case "CLOUDFLAREAPI_SINGLE_REDIRECT": + idTxt = origRec.Original.(cloudflare.RulesetRule).ID default: - idTxt = origRec.(cloudflare.DNSRecord).ID + idTxt = origRec.Original.(cloudflare.DNSRecord).ID } msg = msg + color.RedString(" id=%v", idTxt) @@ -386,11 +421,18 @@ func (c *cloudflareProvider) mkDeleteCorrection(recType string, origRec any, dom F: func() error { switch recType { case "PAGE_RULE": - return c.deletePageRule(origRec.(cloudflare.PageRule).ID, domainID) + return c.deletePageRule(origRec.Original.(cloudflare.PageRule).ID, domainID) case "WORKER_ROUTE": - return c.deleteWorkerRoute(origRec.(cloudflare.WorkerRoute).ID, domainID) + return c.deleteWorkerRoute(origRec.Original.(cloudflare.WorkerRoute).ID, domainID) + case "CLOUDFLAREAPI_SINGLE_REDIRECT": + //o := origRec.Original.(cloudflare.Ruleset) + //printer.Printf("DEBUG: DELETE %+v\n", o) + // printer.Printf("DEBUG: DELETE ID = %+v\n", o.ID) + // printer.Printf("DEBUG: DELETE ACTION %+v\n", o.ActionParameters) + // printer.Printf("DEBUG: DELETE FROMVALUE %+v\n", o.ActionParameters.FromValue) + return c.deleteSingleRedirects(domainID, *origRec.CloudflareRedirect) default: - return c.deleteDNSRecord(origRec.(cloudflare.DNSRecord), domainID) + return c.deleteDNSRecord(origRec.Original.(cloudflare.DNSRecord), domainID) } }, } @@ -517,21 +559,71 @@ func (c *cloudflareProvider) preprocessConfig(dc *models.DomainConfig) error { // CF_REDIRECT record types. Encode target as $FROM,$TO,$PRIO,$CODE if rec.Type == "CF_REDIRECT" || rec.Type == "CF_TEMP_REDIRECT" { - if !c.manageRedirects { - return fmt.Errorf("you must add 'manage_redirects: true' metadata to cloudflare provider to use CF_REDIRECT records") - } - parts := strings.Split(rec.GetTargetField(), ",") - if len(parts) != 2 { - return fmt.Errorf("invalid data specified for cloudflare redirect record") + if !c.manageRedirects && !c.manageSingleRedirects { + return fmt.Errorf("you must add 'manage_single_redirects: true' metadata to cloudflare provider to use CF_REDIRECT/CF_TEMP_REDIRECT records") } + // parts := strings.Split(rec.GetTargetField(), ",") + // if len(parts) != 2 { + // return fmt.Errorf("invalid data specified for cloudflare redirect record") + // } code := 301 if rec.Type == "CF_TEMP_REDIRECT" { code = 302 } - rec.SetTarget(fmt.Sprintf("%s,%d,%d", rec.GetTargetField(), currentPrPrio, code)) - currentPrPrio++ - rec.TTL = 1 - rec.Type = "PAGE_RULE" + + if c.manageRedirects && !c.manageSingleRedirects { + // Old-Style only. Convert this record to PAGE_RULE. + //printer.Printf("DEBUG: prepro() target=%q\n", rec.GetTargetField()) + sr, err := newCfsrFromUserInput(rec.GetTargetField(), code, currentPrPrio) + if err != nil { + return err + } + fixPageRule(rec, sr) + currentPrPrio++ + } else if !c.manageRedirects && c.manageSingleRedirects { + // New-Style only. Convert this record to a CLOUDFLAREAPI_SINGLE_REDIRECT. + sr, err := newCfsrFromUserInput(rec.GetTargetField(), code, currentPrPrio) + if err != nil { + return err + } + err = fixSingleRedirect(rec, sr) + if err != nil { + return err + } + } else { + // Both! Convert this record to PAGE_RULE and append an additional CLOUDFLAREAPI_SINGLE_REDIRECT. + + target := rec.GetTargetField() + + // make a copy: + newRec, err := rec.Copy() + if err != nil { + return err + } + + // The copy becomes the CF SingleRedirect + sr, err := newCfsrFromUserInput(target, code, currentPrPrio) + if err != nil { + return err + } + err = fixSingleRedirect(newRec, sr) + if err != nil { + return err + } + + // Append the copy to the end of the list. + dc.Records = append(dc.Records, newRec) + + // The original becomes the PAGE_RULE: + sr, err = newCfsrFromUserInput(target, code, currentPrPrio) + if err != nil { + return err + } + fixPageRule(rec, sr) + currentPrPrio++ + + } + } // CF_WORKER_ROUTE record types. Encode target as $PATTERN,$SCRIPT @@ -570,6 +662,23 @@ func (c *cloudflareProvider) preprocessConfig(dc *models.DomainConfig) error { return nil } +func fixPageRule(rc *models.RecordConfig, sr *models.CloudflareSingleRedirectConfig) { + rc.Type = "PAGE_RULE" + rc.TTL = 1 + rc.SetTarget(sr.PRDisplay) + rc.CloudflareRedirect = sr +} + +func fixSingleRedirect(rc *models.RecordConfig, sr *models.CloudflareSingleRedirectConfig) error { + rc.Type = "CLOUDFLAREAPI_SINGLE_REDIRECT" + rc.TTL = 1 + rc.SetTarget(sr.SRDisplay) + rc.CloudflareRedirect = sr + + err := addNewStyleFields(sr) + return err +} + func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { api := &cloudflareProvider{} // check api keys from creds json file @@ -610,13 +719,16 @@ func newCloudflare(m map[string]string, metadata json.RawMessage) (providers.DNS parsedMeta := &struct { IPConversions string `json:"ip_conversions"` IgnoredLabels []string `json:"ignored_labels"` - ManageRedirects bool `json:"manage_redirects"` + ManageRedirects bool `json:"manage_redirects"` // Old-style PAGE_RULE-based redirects ManageWorkers bool `json:"manage_workers"` + // + ManageSingleRedirects bool `json:"manage_single_redirects"` // New-style Dynamic "Single Redirects" }{} err := json.Unmarshal([]byte(metadata), parsedMeta) if err != nil { return nil, err } + api.manageSingleRedirects = parsedMeta.ManageSingleRedirects api.manageRedirects = parsedMeta.ManageRedirects api.manageWorkers = parsedMeta.ManageWorkers // ignored_labels: diff --git a/providers/cloudflare/rest.go b/providers/cloudflare/rest.go index e0f9d6aaa..5d650f450 100644 --- a/providers/cloudflare/rest.go +++ b/providers/cloudflare/rest.go @@ -3,7 +3,6 @@ package cloudflare import ( "context" "fmt" - "strconv" "strings" "golang.org/x/net/idna" @@ -276,6 +275,143 @@ func (c *cloudflareProvider) getUniversalSSL(domainID string) (bool, error) { return result.Enabled, err } +func (c *cloudflareProvider) getSingleRedirects(id string, domain string) ([]*models.RecordConfig, error) { + rules, err := c.cfClient.GetEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(id), "http_request_dynamic_redirect") + if err != nil { + return nil, fmt.Errorf("failed fetching redirect rule list cloudflare: %s", err) + } + //var rulelist []cloudflare.RulesetRule + //rulelist = rules.Rules + //rulelist := rules.Rules + + //printer.Printf("DEBUG: rules %+v\n", rules) + recs := []*models.RecordConfig{} + for _, pr := range rules.Rules { + //printer.Printf("DEBUG: %+v\n", pr) + + var thisPr = pr + r := &models.RecordConfig{ + Type: "CLOUDFLAREAPI_SINGLE_REDIRECT", + Original: thisPr, + TTL: 1, + } + r.SetLabel("@", domain) + + // Extract the valuables from the rule, use it to make the sr: + srMatcher := pr.Expression + srReplacement := pr.ActionParameters.FromValue.TargetURL.Expression + code := int(pr.ActionParameters.FromValue.StatusCode) + sr := newCfsrFromAPIData(srMatcher, srReplacement, code) + //sr.SRRRuleList = rulelist + //printer.Printf("DEBUG: DESCRIPTION = %v\n", pr.Description) + sr.SRDisplay = pr.Description + // printer.Printf("DEBUG: PR = %+v\n", pr) + // printer.Printf("DEBUG: rules = %+v\n", rules) + sr.SRRRulesetID = rules.ID + sr.SRRRulesetRuleID = pr.ID //correct + + r.CloudflareRedirect = sr + r.SetTarget(pr.Description) + + recs = append(recs, r) + } + + return recs, nil +} + +func (c *cloudflareProvider) createSingleRedirect(domainID string, cfr models.CloudflareSingleRedirectConfig) error { + + //printer.Printf("DEBUG: createSingleRedir: d=%v crf=%+v\n", domainID, cfr) + // Asumption for target: + + newSingleRedirectRulesActionParameters := cloudflare.RulesetRuleActionParameters{} + newSingleRedirectRule := cloudflare.RulesetRule{} + newSingleRedirectRules := []cloudflare.RulesetRule{} + newSingleRedirectRules = append(newSingleRedirectRules, newSingleRedirectRule) + newSingleRedirect := cloudflare.UpdateEntrypointRulesetParams{} + + // Preserve query string + preserveQueryString := true + newSingleRedirectRulesActionParameters.FromValue = &cloudflare.RulesetRuleActionParametersFromValue{} + // Redirect status code + newSingleRedirectRulesActionParameters.FromValue.StatusCode = uint16(cfr.Code) + // Incoming request expression + newSingleRedirectRules[0].Expression = cfr.SRMatcher + // Redirect expression + newSingleRedirectRulesActionParameters.FromValue.TargetURL.Expression = cfr.SRReplacement + // Redirect name + newSingleRedirectRules[0].Description = cfr.SRDisplay + // Rule action, should always be redirect in this case + newSingleRedirectRules[0].Action = "redirect" + // Phase should always be http_request_dynamic_redirect + newSingleRedirect.Phase = "http_request_dynamic_redirect" + + // Assigns the values in the nested structs + newSingleRedirectRulesActionParameters.FromValue.PreserveQueryString = &preserveQueryString + newSingleRedirectRules[0].ActionParameters = &newSingleRedirectRulesActionParameters + + // Get a list of current redirects so that the new redirect get appended to it + rules, err := c.cfClient.GetEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), "http_request_dynamic_redirect") + if err != nil { + return fmt.Errorf("failed fetching redirect rule list cloudflare: %s", err) + } + newSingleRedirect.Rules = newSingleRedirectRules + newSingleRedirect.Rules = append(newSingleRedirect.Rules, rules.Rules...) + + _, err = c.cfClient.UpdateEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), newSingleRedirect) + + return err +} + +func (c *cloudflareProvider) deleteSingleRedirects(domainID string, cfr models.CloudflareSingleRedirectConfig) error { + + // This block should delete rules using the as is Cloudflare Golang lib in theory, need to debug why it isn't + // updatedRuleset := cloudflare.UpdateEntrypointRulesetParams{} + // updatedRulesetRules := []cloudflare.RulesetRule{} + + // rules, err := c.cfClient.GetEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), "http_request_dynamic_redirect") + // if err != nil { + // return fmt.Errorf("failed fetching redirect rule list cloudflare: %s", err) + // } + + // for _, rule := range rules.Rules { + // if rule.ID != cfr.SRRRulesetRuleID { + // updatedRulesetRules = append(updatedRulesetRules, rule) + // } else { + // printer.Printf("DEBUG: MATCH %v : %v\n", rule.ID, cfr.SRRRulesetRuleID) + // } + // } + // updatedRuleset.Rules = updatedRulesetRules + // _, err = c.cfClient.UpdateEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), updatedRuleset) + + // Old Code + + // rules, err := c.cfClient.GetEntrypointRuleset(context.Background(), cloudflare.ZoneIdentifier(domainID), "http_request_dynamic_redirect") + // if err != nil { + // return err + // } + //printer.Printf("DEBUG: CALLING API DeleteRulesetRule: SRRRulesetID=%v, cfr.SRRRulesetRuleID=%v\n", cfr.SRRRulesetID, cfr.SRRRulesetRuleID) + + err := c.cfClient.DeleteRulesetRule(context.Background(), cloudflare.ZoneIdentifier(domainID), cfr.SRRRulesetID, cfr.SRRRulesetRuleID) + // TODO(tlim): This is terrible. It returns an error even when it is successful. + if strings.Contains(err.Error(), `"success": true,`) { + return nil + } + + return err +} + +func (c *cloudflareProvider) updateSingleRedirect(domainID string, oldrec, newrec *models.RecordConfig) error { + // rulesetID := cfr.SRRRulesetID + // rulesetRuleID := cfr.SRRRulesetRuleID + //printer.Printf("DEBUG: UPDATE-DEL domID=%v sr=%+v\n", domainID, cfr) + if err := c.deleteSingleRedirects(domainID, *oldrec.CloudflareRedirect); err != nil { + return err + } + //printer.Printf("DEBUG: UPDATE-CREATE domID=%v sr=%+v\n", domainID, newrec.CloudflareRedirect) + return c.createSingleRedirect(domainID, *newrec.CloudflareRedirect) +} + func (c *cloudflareProvider) getPageRules(id string, domain string) ([]*models.RecordConfig, error) { rules, err := c.cfClient.ListPageRules(context.Background(), id) if err != nil { @@ -298,11 +434,20 @@ func (c *cloudflareProvider) getPageRules(id string, domain string) ([]*models.R TTL: 1, } r.SetLabel("@", domain) - r.SetTarget(fmt.Sprintf("%s,%s,%d,%d", // $FROM,$TO,$PRIO,$CODE + code := intZero(value["status_code"]) + raw := fmt.Sprintf("%s,%s,%d,%d", // $FROM,$TO,$PRIO,$CODE pr.Targets[0].Constraint.Value, value["url"], pr.Priority, - intZero(value["status_code"]))) + code) + r.SetTarget(raw) + + cr, err := newCfsrFromUserInput(raw, code, pr.Priority) + if err != nil { + return nil, err + } + r.CloudflareRedirect = cr + recs = append(recs, r) } return recs, nil @@ -312,33 +457,43 @@ func (c *cloudflareProvider) deletePageRule(recordID, domainID string) error { return c.cfClient.DeletePageRule(context.Background(), domainID, recordID) } -func (c *cloudflareProvider) updatePageRule(recordID, domainID string, target string) error { +func (c *cloudflareProvider) updatePageRule(recordID, domainID string, cfr models.CloudflareSingleRedirectConfig) error { // maybe someday? //c.apiProvider.UpdatePageRule(context.Background(), domainId, recordID, ) if err := c.deletePageRule(recordID, domainID); err != nil { return err } - return c.createPageRule(domainID, target) + return c.createPageRule(domainID, cfr) } -func (c *cloudflareProvider) createPageRule(domainID string, target string) error { +func (c *cloudflareProvider) createPageRule(domainID string, cfr models.CloudflareSingleRedirectConfig) error { + //printer.Printf("DEBUG: called createPageRule(%s, %+v)\n", domainID, cfr) // from to priority code - parts := strings.Split(target, ",") - priority, _ := strconv.Atoi(parts[2]) - code, _ := strconv.Atoi(parts[3]) + // parts := strings.Split(target, ",") + // priority, _ := strconv.Atoi(parts[2]) + // code, _ := strconv.Atoi(parts[3]) + // printer.Printf("DEBUG: pr.PageRule target = %v\n", target) + // printer.Printf("DEBUG: pr.PageRule target = %v\n", parts[0]) + // printer.Printf("DEBUG: pr.PageRule url = %v\n", parts[1]) + // printer.Printf("DEBUG: pr.PageRule code = %v\n", code) + priority := cfr.PRPriority + code := cfr.Code + matcher := cfr.PRMatcher + replacement := cfr.PRReplacement pr := cloudflare.PageRule{ Status: "active", Priority: priority, Targets: []cloudflare.PageRuleTarget{ - {Target: "url", Constraint: pageRuleConstraint{Operator: "matches", Value: parts[0]}}, + {Target: "url", Constraint: pageRuleConstraint{Operator: "matches", Value: matcher}}, }, Actions: []cloudflare.PageRuleAction{ {ID: "forwarding_url", Value: &pageRuleFwdInfo{ StatusCode: code, - URL: parts[1], + URL: replacement, }}, }, } + //printer.Printf("DEBUG: createPageRule pr=%+v\n", pr) _, err := c.cfClient.CreatePageRule(context.Background(), domainID, pr) return err } diff --git a/providers/cloudflare/singleredirect.go b/providers/cloudflare/singleredirect.go new file mode 100644 index 000000000..033ac1a9d --- /dev/null +++ b/providers/cloudflare/singleredirect.go @@ -0,0 +1,199 @@ +package cloudflare + +import ( + "fmt" + "net" + "net/url" + "strings" + + "github.com/StackExchange/dnscontrol/v4/models" +) + +func newCfsrFromUserInput(target string, code int, priority int) (*models.CloudflareSingleRedirectConfig, error) { + // target: matcher,replacement,priority,code + // target: cable.slackoverflow.com/*,https://change.cnn.com/$1,1,302 + + r := &models.CloudflareSingleRedirectConfig{} + + // Break apart the 4-part string and store into the individual fields: + parts := strings.Split(target, ",") + //printer.Printf("DEBUG: cfsrFromOldStyle: parts=%v\n", parts) + r.PRDisplay = fmt.Sprintf("%s,%d,%03d", target, priority, code) + r.PRMatcher = parts[0] + r.PRReplacement = parts[1] + r.PRPriority = priority + r.Code = code + + // Convert old-style to new-style: + if err := addNewStyleFields(r); err != nil { + return nil, err + } + return r, nil +} + +func newCfsrFromAPIData(sm, sr string, code int) *models.CloudflareSingleRedirectConfig { + r := &models.CloudflareSingleRedirectConfig{ + PRMatcher: "UNKNOWABLE", + PRReplacement: "UNKNOWABLE", + //PRPriority: 0, + Code: code, + SRMatcher: sm, + SRReplacement: sr, + } + return r +} + +// addNewStyleFields takes a PAGE_RULE-style target and populates the CFSRC. +func addNewStyleFields(sr *models.CloudflareSingleRedirectConfig) error { + + // Extract the fields we're reading from: + prMatcher := sr.PRMatcher + prReplacement := sr.PRReplacement + code := sr.Code + + // Convert old-style patterns to new-style rules: + srMatcher, srReplacement, err := makeRuleFromPattern(prMatcher, prReplacement, code != 301) + if err != nil { + return err + } + display := fmt.Sprintf(`%s,%s,%d,%03d matcher=%q replacement=%q`, + prMatcher, prReplacement, + sr.PRPriority, code, + srMatcher, srReplacement, + ) + + // Store the results in the fields we're writing to: + sr.SRMatcher = srMatcher + sr.SRReplacement = srReplacement + sr.SRDisplay = display + + return nil +} + +// makeRuleFromPattern compile old-style patterns and replacements into new-style rules and expressions. +func makeRuleFromPattern(pattern, replacement string, temporary bool) (string, string, error) { + + _ = temporary // Prevents error due to this variable not (yet) being used + + var matcher, expr string + var err error + + var host, path string + origPattern := pattern + pattern, host, path, err = normalizeURL(pattern) + _ = pattern + if err != nil { + return "", "", err + } + var rhost, rpath string + origReplacement := replacement + replacement, rhost, rpath, err = normalizeURL(replacement) + _ = rpath + if err != nil { + return "", "", err + } + + // TODO(tlim): This could be a lot faster by not repeating itself so much. + // However I want to get it working before it is optimized. + + // pattern -> matcher + + if !strings.Contains(host, `*`) && (path == `/` || path == "") { + // https://i.sstatic.net/ (No Wildcards) + matcher = fmt.Sprintf(`http.host eq "%s" and http.request.uri.path eq "%s"`, host, "/") + + } else if !strings.Contains(host, `*`) && (path == `/*`) { + // https://i.stack.imgur.com/* + matcher = fmt.Sprintf(`http.host eq "%s"`, host) + + } else if !strings.Contains(host, `*`) && !strings.Contains(path, "*") { + // https://insights.stackoverflow.com/trends + matcher = fmt.Sprintf(`http.host eq "%s" and http.request.uri.path eq "%s"`, host, path) + + } else if host[0] == '*' && strings.Count(host, `*`) == 1 && !strings.Contains(path, "*") { + // *stackoverflow.careers/ (wildcard at beginning only) + matcher = fmt.Sprintf(`( http.host eq "%s" or ends_with(http.host, ".%s") ) and http.request.uri.path eq "%s"`, host[1:], host[1:], path) + + } else if host[0] == '*' && strings.Count(host, `*`) == 1 && path == "/*" { + // *stackoverflow.careers/* (wildcard at beginning and end) + matcher = fmt.Sprintf(`http.host eq "%s" or ends_with(http.host, ".%s")`, host[1:], host[1:]) + + } else if strings.Contains(host, `*`) && path == "/*" { + // meta.*yodeya.com/* (wildcard in host) + h := simpleGlobToRegex(host) + matcher = fmt.Sprintf(`http.host matches r###"%s"###`, h) + } + + // replacement + + if !strings.Contains(replacement, `$`) { + // https://stackexchange.com/ (no substitutions) + expr = fmt.Sprintf(`"%s"`, replacement) + + } else if strings.Count(replacement, `$`) == 1 && rpath == `/$1` { + // https://i.sstatic.net/$1 ($1 at end) + expr = fmt.Sprintf(`concat("https://%s/", http.request.uri.path)`, rhost) + + } else if strings.Count(host, `*`) == 1 && strings.Count(path, `*`) == 1 && + strings.Count(replacement, `$`) == 1 && rpath == `/$2` { + // https://careers.stackoverflow.com/$2 + expr = fmt.Sprintf(`concat("https://%s/", http.request.uri.path)`, rhost) + + } + + // Not implemented + + if matcher == "" { + return "", "", fmt.Errorf("conversion not implemented for pattern: %s", origPattern) + } + if expr == "" { + return "", "", fmt.Errorf("conversion not implemented for replacemennt: %s", origReplacement) + } + + return matcher, expr, nil +} + +// normalizeURL turns foo.com into https://foo.com and replaces HTTP with HTTPS. +// It also returns an error if there is a port specified (like :8080) +func normalizeURL(s string) (string, string, string, error) { + orig := s + if strings.HasPrefix(s, `http://`) { + s = "https://" + s[7:] + } else if !strings.HasPrefix(s, `https://`) { + s = `https://` + s + } + + // Make sure it parses. + u, err := url.Parse(s) + if err != nil { + return "", "", "", err + } + + // Make sure it doesn't have a port (https://example.com:8080) + _, port, _ := net.SplitHostPort(u.Host) + if port != "" { + return "", "", "", fmt.Errorf("unimplemented port: %q", orig) + } + + return s, u.Host, u.Path, nil +} + +// simpleGlobToRegex translates very simple Glob patterns into regexp-compatible expressions. +// It only handles `.` and `*` currently. See singleredirect_test.go for supported patterns. +func simpleGlobToRegex(g string) string { + + if g == "" { + return `.*` + } + + if !strings.HasSuffix(g, "*") { + g = g + `$` + } + if !strings.HasPrefix(g, "*") { + g = `^` + g + } + + g = strings.ReplaceAll(g, `.`, `\.`) + g = strings.ReplaceAll(g, `*`, `.*`) + return g +} diff --git a/providers/cloudflare/singleredirect_test.go b/providers/cloudflare/singleredirect_test.go new file mode 100644 index 000000000..6c1ac2355 --- /dev/null +++ b/providers/cloudflare/singleredirect_test.go @@ -0,0 +1,321 @@ +package cloudflare + +import ( + "regexp" + "testing" + + "github.com/gobwas/glob" +) + +func Test_makeSingleDirectRule(t *testing.T) { + tests := []struct { + name string + // + pattern string + replace string + // + wantMatch string + wantExpr string + wantErr bool + }{ + { + name: "000", + pattern: "example.com/", + replace: "foo.com", + wantMatch: `http.host eq "example.com" and http.request.uri.path eq "/"`, + wantExpr: `"https://foo.com"`, + wantErr: false, + }, + + /* + All the test-cases I could find in dnsconfig.js + + Generated with this: + + dnscontrol print-ir --pretty |grep '"target' |grep , | sed -e 's@"target":@@g' > /tmp/list + vim /tmp/list # removed the obvious duplicates + awk < /tmp/list -v q='"' -F, '{ print "{" ; print "name: " q NR q "," ; print "pattern: " $1 q "," ; print "replace: " q $2 "," ; print "wantMatch: `FIXME`," ; print "wantExpr: `FIXME`," ; print "wantErr: false," ; print "}," }' | pbcopy + + */ + + { + name: "1", + pattern: "https://i-dev.sstatic.net/", + replace: "https://stackexchange.com/", + wantMatch: `http.host eq "i-dev.sstatic.net" and http.request.uri.path eq "/"`, + wantExpr: `"https://stackexchange.com/"`, + wantErr: false, + }, + { + name: "2", + pattern: "https://i.stack.imgur.com/*", + replace: "https://i.sstatic.net/$1", + wantMatch: `http.host eq "i.stack.imgur.com"`, + wantExpr: `concat("https://i.sstatic.net/", http.request.uri.path)`, + wantErr: false, + }, + { + name: "3", + pattern: "https://img.stack.imgur.com/*", + replace: "https://i.sstatic.net/$1", + wantMatch: `http.host eq "img.stack.imgur.com"`, + wantExpr: `concat("https://i.sstatic.net/", http.request.uri.path)`, + wantErr: false, + }, + { + name: "4", + pattern: "https://insights.stackoverflow.com/", + replace: "https://survey.stackoverflow.co", + wantMatch: `http.host eq "insights.stackoverflow.com" and http.request.uri.path eq "/"`, + wantExpr: `"https://survey.stackoverflow.co"`, + wantErr: false, + }, + { + name: "5", + pattern: "https://insights.stackoverflow.com/trends", + replace: "https://trends.stackoverflow.co", + wantMatch: `http.host eq "insights.stackoverflow.com" and http.request.uri.path eq "/trends"`, + wantExpr: `"https://trends.stackoverflow.co"`, + wantErr: false, + }, + { + name: "6", + pattern: "https://insights.stackoverflow.com/trends/", + replace: "https://trends.stackoverflow.co", + wantMatch: `http.host eq "insights.stackoverflow.com" and http.request.uri.path eq "/trends/"`, + wantExpr: `"https://trends.stackoverflow.co"`, + wantErr: false, + }, + { + name: "7", + pattern: "https://insights.stackoverflow.com/survey/2021", + replace: "https://survey.stackoverflow.co/2021", + wantMatch: `http.host eq "insights.stackoverflow.com" and http.request.uri.path eq "/survey/2021"`, + wantExpr: `"https://survey.stackoverflow.co/2021"`, + wantErr: false, + }, + // { + // name: "27", + // pattern: "*www.stackoverflow.help/*", + // replace: "https://stackoverflow.help/$1", + /// FIXME(tlim): Should "$1" should be a "$2"? See dnsconfig.js:4344 + // wantMatch: `FIXME`, + // wantExpr: `FIXME`, + // wantErr: false, + // }, + { + name: "28", + pattern: "*stackoverflow.help/support/solutions/articles/36000241656-write-an-article", + replace: "https://stackoverflow.help/en/articles/4397209-write-an-article", + wantMatch: `( http.host eq "stackoverflow.help" or ends_with(http.host, ".stackoverflow.help") ) and http.request.uri.path eq "/support/solutions/articles/36000241656-write-an-article"`, + wantExpr: `"https://stackoverflow.help/en/articles/4397209-write-an-article"`, + wantErr: false, + }, + { + name: "29", + pattern: "*stackoverflow.careers/*", + replace: "https://careers.stackoverflow.com/$2", + wantMatch: `http.host eq "stackoverflow.careers" or ends_with(http.host, ".stackoverflow.careers")`, + wantExpr: `concat("https://careers.stackoverflow.com/", http.request.uri.path)`, + wantErr: false, + }, + { + name: "31", + pattern: "stackenterprise.com/*", + replace: "https://stackoverflow.co/teams/", + wantMatch: `http.host eq "stackenterprise.com"`, + wantExpr: `"https://stackoverflow.co/teams/"`, + wantErr: false, + }, + { + name: "33", + pattern: "meta.*yodeya.com/*", + replace: "https://judaism.meta.stackexchange.com/$2", + wantMatch: `http.host matches r###"^meta\..*yodeya\.com$"###`, + wantExpr: `concat("https://judaism.meta.stackexchange.com/", http.request.uri.path)`, + wantErr: false, + }, + { + name: "34", + pattern: "chat.*yodeya.com/*", + replace: "https://chat.stackexchange.com/?tab=site\u0026host=judaism.stackexchange.com", + wantMatch: `http.host matches r###"^chat\..*yodeya\.com$"###`, + wantExpr: `"https://chat.stackexchange.com/?tab=site&host=judaism.stackexchange.com"`, + wantErr: false, + }, + { + name: "35", + pattern: "*yodeya.com/*", + replace: "https://judaism.stackexchange.com/$2", + wantMatch: `http.host eq "yodeya.com" or ends_with(http.host, ".yodeya.com")`, + wantExpr: `concat("https://judaism.stackexchange.com/", http.request.uri.path)`, + wantErr: false, + }, + { + name: "36", + pattern: "meta.*seasonedadvice.com/*", + replace: "https://cooking.meta.stackexchange.com/$2", + wantMatch: `http.host matches r###"^meta\..*seasonedadvice\.com$"###`, + wantExpr: `concat("https://cooking.meta.stackexchange.com/", http.request.uri.path)`, + wantErr: false, + }, + { + name: "70", + pattern: "collectivesonstackoverflow.co/*", + replace: "https://stackoverflow.com/collectives-on-stack-overflow", + wantMatch: `http.host eq "collectivesonstackoverflow.co"`, + wantExpr: `"https://stackoverflow.com/collectives-on-stack-overflow"`, + wantErr: false, + }, + { + name: "71", + pattern: "*collectivesonstackoverflow.co/*", + replace: "https://stackoverflow.com/collectives-on-stack-overflow", + wantMatch: `http.host eq "collectivesonstackoverflow.co" or ends_with(http.host, ".collectivesonstackoverflow.co")`, + wantExpr: `"https://stackoverflow.com/collectives-on-stack-overflow"`, + wantErr: false, + }, + { + name: "76", + pattern: "*stackexchange.ca/*", + replace: "https://stackexchange.com/$2", + wantMatch: `http.host eq "stackexchange.ca" or ends_with(http.host, ".stackexchange.ca")`, + wantExpr: `concat("https://stackexchange.com/", http.request.uri.path)`, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMatch, gotExpr, err := makeRuleFromPattern(tt.pattern, tt.replace, true) + if (err != nil) != tt.wantErr { + t.Errorf("makeSingleDirectRule() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotMatch != tt.wantMatch { + t.Errorf("makeSingleDirectRule() MATCH = %v\n want %v", gotMatch, tt.wantMatch) + } + if gotExpr != tt.wantExpr { + t.Errorf("makeSingleDirectRule() EXPR = %v\n want %v", gotExpr, tt.wantExpr) + } + //_ = gotType + }) + } +} + +func Test_normalizeURL(t *testing.T) { + tests := []struct { + name string + s string + want string + want1 string + want2 string + wantErr bool + }{ + { + s: "foo.com", + want: "https://foo.com", + want1: "foo.com", + want2: "", + }, + { + s: "http://foo.com", + want: "https://foo.com", + want1: "foo.com", + want2: "", + }, + { + s: "https://foo.com", + want: "https://foo.com", + want1: "foo.com", + want2: "", + }, + + { + s: "foo.com/bar", + want: "https://foo.com/bar", + want1: "foo.com", + want2: "/bar", + }, + { + s: "http://foo.com/bar", + want: "https://foo.com/bar", + want1: "foo.com", + want2: "/bar", + }, + { + s: "https://foo.com/bar", + want: "https://foo.com/bar", + want1: "foo.com", + want2: "/bar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1, got2, err := normalizeURL(tt.s) + if (err != nil) != tt.wantErr { + t.Errorf("normalizeURL() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("normalizeURL() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("normalizeURL() got1 = %v, want1 %v", got1, tt.want1) + } + if got2 != tt.want2 { + t.Errorf("normalizeURL() got2 = %v, want2 %v", got2, tt.want2) + } + }) + } +} + +func Test_simpleGlobToRegex(t *testing.T) { + tests := []struct { + name string + pattern string + want string + }{ + {"1", `foo`, `^foo$`}, + {"2", `fo.o`, `^fo\.o$`}, + {"3", `*foo`, `.*foo$`}, + {"4", `foo*`, `^foo.*`}, + {"5", `f.oo*`, `^f\.oo.*`}, + {"6", `f*oo*`, `^f.*oo.*`}, + } + + data := []string{ + "bar", + "foo", + "foofoo", + "ONEfooTWO", + "fo", + "frankfodog", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := simpleGlobToRegex(tt.pattern) + if got != tt.want { + t.Errorf("simpleGlobToRegex() = %v, want %v", got, tt.want) + } + + // Make sure the regex compiles and gets the same result when matching against strings in data. + for i, d := range data { + + rm, err := regexp.MatchString(got, d) + if err != nil { + t.Errorf("simpleGlobToRegex() = %003d can not compile: %v", i, err) + } + + g := glob.MustCompile(tt.pattern) + gm := g.Match(d) // true + + if gm != rm { + t.Errorf("simpleGlobToRegex() = %003d glob: %v '%v' regexp: %v '%v'", i, gm, tt.pattern, rm, got) + } + + } + }) + + } +} diff --git a/providers/huaweicloud/convert.go b/providers/huaweicloud/convert.go index 286338fea..11f2926ea 100644 --- a/providers/huaweicloud/convert.go +++ b/providers/huaweicloud/convert.go @@ -96,12 +96,12 @@ func recordsToNative(rcs models.Records, expectedKey models.RecordKey) (*model.S name := expectedKey.NameFQDN + "." key := rcs[0].Metadata[metaKey] result := &model.ShowRecordSetByZoneResp{ - Name: &name, - Type: &expectedKey.Type, - Ttl: &resultTTL, - Records: &resultVal, - Line: &line, - Weight: weight, + Name: &name, + Type: &expectedKey.Type, + Ttl: &resultTTL, + Records: &resultVal, + Line: &line, + Weight: weight, Description: &key, }