diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741537de3..b0c1ef28c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,35 @@ on: jobs: lint-tidy: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: + # Enforce TPT-1234: prefix on PR titles, with the following exemptions: + # - PRs labeled 'dependencies' (e.g. Dependabot PRs) + # - PRs labeled 'hotfix' (urgent fixes that may not have a ticket) + # - PRs labeled 'community-contribution' (external contributors without TPT tickets) + # - PRs labeled 'ignore-for-release' (release PRs that don't need a ticket prefix) + - name: Validate PR Title + if: github.event_name == 'pull_request' + uses: amannn/action-semantic-pull-request@v6 + with: + types: | + TPT-\d+ + requireScope: false + # Override the default header pattern to allow hyphens and digits in the type + # (e.g. "TPT-4298: Description"). The default pattern only matches word + # characters (\w) which excludes hyphens. + headerPattern: '^([\w-]+):\s?(.*)$' + headerPatternCorrespondence: type, subject + ignoreLabels: | + dependencies + hotfix + community-contribution + ignore-for-release + env: + GITHUB_TOKEN: ${{ github.token }} + - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: diff --git a/.github/workflows/clean-release-notes.yml b/.github/workflows/clean-release-notes.yml new file mode 100644 index 000000000..8d6d798bb --- /dev/null +++ b/.github/workflows/clean-release-notes.yml @@ -0,0 +1,37 @@ +name: Clean Release Notes + +on: + release: + types: [published] + +jobs: + clean-release-notes: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Remove ticket prefixes from release notes + uses: actions/github-script@v7 + with: + script: | + const release = context.payload.release; + + let body = release.body; + + if (!body) { + console.log("Release body empty, nothing to clean."); + return; + } + + // Remove ticket prefixes like "TPT-1234: " or "TPT-1234:" + body = body.replace(/TPT-\d+:\s*/g, ''); + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + body: body + }); + + console.log("Release notes cleaned."); \ No newline at end of file diff --git a/client.go b/client.go index 99e0adc08..0273d4565 100644 --- a/client.go +++ b/client.go @@ -66,6 +66,12 @@ Body: {{.Body}}`)) var envDebug = false +// redactHeadersMap is a map of headers that should be redacted in logs, +// mapping the header name to its redacted value. +var redactHeadersMap = map[string]string{ + "Authorization": "Bearer *******************************", +} + // Client is a wrapper around the Resty client type Client struct { resty *resty.Client @@ -394,6 +400,19 @@ func (c *httpClient) applyAfterResponse(resp *http.Response) error { return nil } +// nolint:unused +func redactHeaders(headers http.Header) http.Header { + redacted := headers.Clone() + + for header, redactedValue := range redactHeadersMap { + if headers.Get(header) != "" { + redacted.Set(header, redactedValue) + } + } + + return redacted +} + // nolint:unused func (c *httpClient) logRequest(req *http.Request, method, url string, bodyBuffer *bytes.Buffer) { var reqBody string @@ -408,7 +427,7 @@ func (c *httpClient) logRequest(req *http.Request, method, url string, bodyBuffe err := reqLogTemplate.Execute(&logBuf, map[string]any{ "Method": method, "URL": url, - "Headers": req.Header, + "Headers": redactHeaders(req.Header), "Body": reqBody, }) if err == nil { @@ -456,7 +475,7 @@ func (c *httpClient) logResponse(resp *http.Response) (*http.Response, error) { err := respLogTemplate.Execute(&logBuf, map[string]any{ "Status": resp.Status, - "Headers": resp.Header, + "Headers": redactHeaders(resp.Header), "Body": respBody.String(), }) if err == nil { @@ -827,10 +846,22 @@ func (c *Client) updateHostURL() { ) } +func redactLogHeaders(header http.Header) { + for h, redactedValue := range redactHeadersMap { + if header.Get(h) != "" { + header.Set(h, redactedValue) + } + } +} + func (c *Client) enableLogSanitization() *Client { c.resty.OnRequestLog(func(r *resty.RequestLog) error { - // masking authorization header - r.Header.Set("Authorization", "Bearer *******************************") + redactLogHeaders(r.Header) + return nil + }) + + c.resty.OnResponseLog(func(r *resty.ResponseLog) error { + redactLogHeaders(r.Header) return nil }) diff --git a/client_test.go b/client_test.go index 8b78b9247..f925f3678 100644 --- a/client_test.go +++ b/client_test.go @@ -17,6 +17,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/jarcoal/httpmock" "github.com/linode/linodego/internal/testutil" + "github.com/stretchr/testify/require" ) func TestClient_SetAPIVersion(t *testing.T) { @@ -703,3 +704,101 @@ func TestMonitorClient_SetAPIBasics(t *testing.T) { t.Fatal(cmp.Diff(client.resty.BaseURL, expectedHost)) } } + +func TestRedactHeaders(t *testing.T) { + tests := []struct { + name string + headers http.Header + wantVal map[string]string + }{ + { + name: "redacts authorization header", + headers: http.Header{ + "Authorization": []string{"Bearer supersecrettoken"}, + "Content-Type": []string{"application/json"}, + }, + wantVal: map[string]string{ + "Authorization": redactHeadersMap["Authorization"], + "Content-Type": "application/json", + }, + }, + { + name: "leaves non-sensitive headers unchanged", + headers: http.Header{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + }, + wantVal: map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + }, + }, + { + name: "handles empty headers", + headers: http.Header{}, + wantVal: map[string]string{}, + }, + { + name: "does not mutate original headers", + headers: http.Header{ + "Authorization": []string{"Bearer supersecrettoken"}, + }, + wantVal: map[string]string{ + "Authorization": redactHeadersMap["Authorization"], + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalAuth := tt.headers.Get("Authorization") + + result := redactHeaders(tt.headers) + + // Verify expected values in result + for key, expectedVal := range tt.wantVal { + if got := result.Get(key); got != expectedVal { + t.Errorf("redactHeaders() header %q = %q, want %q", key, got, expectedVal) + } + } + + // Verify original was not mutated + if tt.headers.Get("Authorization") != originalAuth { + t.Error("redactHeaders() mutated the original headers") + } + }) + } +} + +func TestEnableLogSanitization(t *testing.T) { + mockClient := testutil.CreateMockClient(t, NewClient) + mockClient.SetDebug(true) + + plainTextToken := "supersecrettoken" + mockClient.SetToken(plainTextToken) + + var logBuf bytes.Buffer + logger := testutil.CreateLogger() + logger.L.SetOutput(&logBuf) + mockClient.SetLogger(logger) + + httpmock.RegisterResponder("GET", "=~.*", + httpmock.NewStringResponder(200, `{}`).HeaderSet(http.Header{ + "Authorization": []string{"Bearer " + plainTextToken}, + })) + + _, err := mockClient.resty.R().Get("https://api.linode.com/v4/test") + require.NoError(t, err) + + logOutput := logBuf.String() + + // Verify token is not present in either request or response logs + if strings.Contains(logOutput, plainTextToken) { + t.Errorf("log output contains raw token %q, expected it to be redacted", plainTextToken) + } + + // Verify Authorization header still appears (as redacted value) in request log + if !strings.Contains(logOutput, "Authorization") { + t.Error("expected Authorization header to appear in request log output") + } +} diff --git a/go.mod b/go.mod index ede3191e0..bbf195601 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/google/go-querystring v1.2.0 github.com/jarcoal/httpmock v1.4.1 golang.org/x/net v0.51.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/text v0.34.0 gopkg.in/ini.v1 v1.67.1 ) diff --git a/go.sum b/go.sum index 58e2c09d5..7e6fa083a 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/instance_ips.go b/instance_ips.go index 943dbdd39..0c8fa2395 100644 --- a/instance_ips.go +++ b/instance_ips.go @@ -21,18 +21,20 @@ type InstanceIPv4Response struct { // InstanceIP represents an Instance IP with additional DNS and networking details type InstanceIP struct { - Address string `json:"address"` - Gateway string `json:"gateway"` - SubnetMask string `json:"subnet_mask"` - Prefix int `json:"prefix"` - Type InstanceIPType `json:"type"` - Public bool `json:"public"` - RDNS string `json:"rdns"` - LinodeID int `json:"linode_id"` - InterfaceID *int `json:"interface_id"` - Region string `json:"region"` - VPCNAT1To1 *InstanceIPNAT1To1 `json:"vpc_nat_1_1"` - Reserved bool `json:"reserved"` + Address string `json:"address"` + Gateway string `json:"gateway"` + SubnetMask string `json:"subnet_mask"` + Prefix int `json:"prefix"` + Type InstanceIPType `json:"type"` + Public bool `json:"public"` + RDNS string `json:"rdns"` + LinodeID int `json:"linode_id"` + InterfaceID *int `json:"interface_id"` + Region string `json:"region"` + VPCNAT1To1 *InstanceIPNAT1To1 `json:"vpc_nat_1_1"` + Reserved bool `json:"reserved"` + Tags []string `json:"tags"` + AssignedEntity *ReservedIPAssignedEntity `json:"assigned_entity"` } // VPCIP represents a private IP address in a VPC subnet with additional networking details diff --git a/k8s/go.mod b/k8s/go.mod index df1578b3d..102bab8ca 100644 --- a/k8s/go.mod +++ b/k8s/go.mod @@ -30,7 +30,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/net v0.51.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/k8s/go.sum b/k8s/go.sum index 547dee15e..855bae3b9 100644 --- a/k8s/go.sum +++ b/k8s/go.sum @@ -95,8 +95,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/network_reserved_ips.go b/network_reserved_ips.go index 777d3d16f..696b4f0e4 100644 --- a/network_reserved_ips.go +++ b/network_reserved_ips.go @@ -4,12 +4,40 @@ import ( "context" ) +// ReservedIPAssignedEntity represents the entity that a reserved IP is assigned to. +// NOTE: Reserved IP feature may not currently be available to all users. +type ReservedIPAssignedEntity struct { + ID int `json:"id"` + Label string `json:"label"` + Type string `json:"type"` + URL string `json:"url"` +} + // ReserveIPOptions represents the options for reserving an IP address // NOTE: Reserved IP feature may not currently be available to all users. type ReserveIPOptions struct { - Region string `json:"region"` + Region string `json:"region"` + Tags []string `json:"tags,omitempty"` +} + +// UpdateReservedIPOptions represents the options for updating a reserved IP address +// NOTE: Reserved IP feature may not currently be available to all users. +type UpdateReservedIPOptions struct { + Tags []string `json:"tags"` } +// ReservedIPPrice represents the pricing information for a reserved IP type. +// It is an alias of the shared baseTypePrice to keep pricing consistent across resources. +type ReservedIPPrice = baseTypePrice + +// ReservedIPRegionPrice represents region-specific pricing for a reserved IP type. +// It is an alias of the shared baseTypeRegionPrice to keep region pricing consistent across resources. +type ReservedIPRegionPrice = baseTypeRegionPrice + +// ReservedIPType represents a reserved IP type with pricing information. +// It reuses the generic baseType to avoid duplicating type/pricing structures. +type ReservedIPType = baseType[ReservedIPPrice, ReservedIPRegionPrice] + // ListReservedIPAddresses retrieves a list of reserved IP addresses // NOTE: Reserved IP feature may not currently be available to all users. func (c *Client) ListReservedIPAddresses(ctx context.Context, opts *ListOptions) ([]InstanceIP, error) { @@ -30,9 +58,22 @@ func (c *Client) ReserveIPAddress(ctx context.Context, opts ReserveIPOptions) (* return doPOSTRequest[InstanceIP](ctx, c, "networking/reserved/ips", opts) } +// UpdateReservedIPAddress updates the tags of a reserved IP address +// NOTE: Reserved IP feature may not currently be available to all users. +func (c *Client) UpdateReservedIPAddress(ctx context.Context, address string, opts UpdateReservedIPOptions) (*InstanceIP, error) { + e := formatAPIPath("networking/reserved/ips/%s", address) + return doPUTRequest[InstanceIP](ctx, c, e, opts) +} + // DeleteReservedIPAddress deletes a reserved IP address // NOTE: Reserved IP feature may not currently be available to all users. func (c *Client) DeleteReservedIPAddress(ctx context.Context, ipAddress string) error { e := formatAPIPath("networking/reserved/ips/%s", ipAddress) return doDELETERequest(ctx, c, e) } + +// ListReservedIPTypes retrieves a list of reserved IP types with pricing information +// NOTE: Reserved IP feature may not currently be available to all users. +func (c *Client) ListReservedIPTypes(ctx context.Context, opts *ListOptions) ([]ReservedIPType, error) { + return getPaginatedResults[ReservedIPType](ctx, c, "networking/reserved/ips/types", opts) +} diff --git a/tags.go b/tags.go index e23ba2168..4ab4c6add 100644 --- a/tags.go +++ b/tags.go @@ -38,10 +38,11 @@ type TagCreateOptions struct { Label string `json:"label"` Linodes []int `json:"linodes,omitempty"` // @TODO is this implemented? - LKEClusters []int `json:"lke_clusters,omitempty"` - Domains []int `json:"domains,omitempty"` - Volumes []int `json:"volumes,omitempty"` - NodeBalancers []int `json:"nodebalancers,omitempty"` + LKEClusters []int `json:"lke_clusters,omitempty"` + Domains []int `json:"domains,omitempty"` + Volumes []int `json:"volumes,omitempty"` + NodeBalancers []int `json:"nodebalancers,omitempty"` + ReservedIPv4Addresses []string `json:"reserved_ipv4_addresses,omitempty"` } // GetCreateOptions converts a Tag to TagCreateOptions for use in CreateTag @@ -92,6 +93,13 @@ func (i *TaggedObject) fixData() (*TaggedObject, error) { return nil, err } + i.Data = obj + case "reserved_ipv4_address": + obj := InstanceIP{} + if err := json.Unmarshal(i.RawData, &obj); err != nil { + return nil, err + } + i.Data = obj } diff --git a/test/go.mod b/test/go.mod index 1031950d0..e17a62c2c 100644 --- a/test/go.mod +++ b/test/go.mod @@ -9,7 +9,7 @@ require ( github.com/stretchr/testify v1.11.1 golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 golang.org/x/net v0.51.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 k8s.io/client-go v0.29.4 ) diff --git a/test/go.sum b/test/go.sum index 11b8ac054..0dd55e08c 100644 --- a/test/go.sum +++ b/test/go.sum @@ -103,8 +103,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= 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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/test/integration/fixtures/TestReservedIPAddress_ReserveWithTags.yaml b/test/integration/fixtures/TestReservedIPAddress_ReserveWithTags.yaml new file mode 100644 index 000000000..263dd62ab --- /dev/null +++ b/test/integration/fixtures/TestReservedIPAddress_ReserveWithTags.yaml @@ -0,0 +1,167 @@ +--- +version: 1 +interactions: +- request: + body: '{"region":"us-east","tags":["lb"]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips + method: POST + response: + body: '{"address": "66.175.209.100", "gateway": "66.175.209.1", "subnet_mask": "255.255.255.0", + "prefix": 24, "type": "ipv4", "public": true, "rdns": "66-175-209-100.ip.linodeusercontent.com", + "linode_id": null, "interface_id": null, "region": "us-east", "vpc_nat_1_1": + null, "reserved": true, "tags": ["lb"], "assigned_entity": null}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/66.175.209.100 + method: GET + response: + body: '{"address": "66.175.209.100", "gateway": "66.175.209.1", "subnet_mask": "255.255.255.0", + "prefix": 24, "type": "ipv4", "public": true, "rdns": "66-175-209-100.ip.linodeusercontent.com", + "linode_id": null, "interface_id": null, "region": "us-east", "vpc_nat_1_1": + null, "reserved": true, "tags": ["lb"], "assigned_entity": null}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/66.175.209.100 + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/fixtures/TestReservedIPAddress_UpdateTags.yaml b/test/integration/fixtures/TestReservedIPAddress_UpdateTags.yaml new file mode 100644 index 000000000..96ca690a5 --- /dev/null +++ b/test/integration/fixtures/TestReservedIPAddress_UpdateTags.yaml @@ -0,0 +1,224 @@ +--- +version: 1 +interactions: +- request: + body: '{"region":"us-east"}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips + method: POST + response: + body: '{"address": "66.175.209.101", "gateway": "66.175.209.1", "subnet_mask": "255.255.255.0", + "prefix": 24, "type": "ipv4", "public": true, "rdns": "66-175-209-101.ip.linodeusercontent.com", + "linode_id": null, "interface_id": null, "region": "us-east", "vpc_nat_1_1": + null, "reserved": true, "tags": [], "assigned_entity": null}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"tags":["lb"]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/66.175.209.101 + method: PUT + response: + body: '{"address": "66.175.209.101", "gateway": "66.175.209.1", "subnet_mask": "255.255.255.0", + "prefix": 24, "type": "ipv4", "public": true, "rdns": "66-175-209-101.ip.linodeusercontent.com", + "linode_id": null, "interface_id": null, "region": "us-east", "vpc_nat_1_1": + null, "reserved": true, "tags": ["lb"], "assigned_entity": null}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"tags":[]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/66.175.209.101 + method: PUT + response: + body: '{"address": "66.175.209.101", "gateway": "66.175.209.1", "subnet_mask": "255.255.255.0", + "prefix": 24, "type": "ipv4", "public": true, "rdns": "66-175-209-101.ip.linodeusercontent.com", + "linode_id": null, "interface_id": null, "region": "us-east", "vpc_nat_1_1": + null, "reserved": true, "tags": [], "assigned_entity": null}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/66.175.209.101 + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + X-Accepted-Oauth-Scopes: + - ips:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/fixtures/TestReservedIPTypes_List.yaml b/test/integration/fixtures/TestReservedIPTypes_List.yaml new file mode 100644 index 000000000..db41b688a --- /dev/null +++ b/test/integration/fixtures/TestReservedIPTypes_List.yaml @@ -0,0 +1,60 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/dev https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/reserved/ips/types?page=1 + method: GET + response: + body: '{"data": [{"id": "ipv4", "label": "IPv4 Address", "price": {"hourly": 0.005, + "monthly": 2.0}, "region_prices": [{"id": "us-east", "hourly": 0.005, "monthly": + 2.0}, {"id": "br-gru", "hourly": 0.006, "monthly": 3.0}]}], "page": 1, "pages": + 1, "results": 1}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - '*' + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/network_reserved_ips_test.go b/test/integration/network_reserved_ips_test.go index 9316fd8b8..17c66cda0 100644 --- a/test/integration/network_reserved_ips_test.go +++ b/test/integration/network_reserved_ips_test.go @@ -9,6 +9,129 @@ import ( . "github.com/linode/linodego" ) +// TestReservedIPAddress_ReserveWithTags verifies that tags can be passed when reserving +// an IP and are returned in the response. +func TestReservedIPAddress_ReserveWithTags(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestReservedIPAddress_ReserveWithTags") + defer teardown() + + resIP, err := client.ReserveIPAddress(context.Background(), linodego.ReserveIPOptions{ + Region: "us-east", + Tags: []string{"lb"}, + }) + if err != nil { + t.Fatalf("Failed to reserve IP with tags: %v", err) + } + + if resIP.Address == "" { + t.Fatal("Expected a non-empty address in the response") + } + if !resIP.Reserved { + t.Errorf("Expected Reserved=true, got false") + } + found := false + for _, tag := range resIP.Tags { + if tag == "lb" { + found = true + break + } + } + if !found { + t.Errorf("Expected tag 'lb' in response tags, got %v", resIP.Tags) + } + + defer func() { + if err := client.DeleteReservedIPAddress(context.Background(), resIP.Address); err != nil { + t.Errorf("Failed to delete reserved IP %s: %v", resIP.Address, err) + } + }() + + // Verify tags round-trip via Get + fetched, err := client.GetReservedIPAddress(context.Background(), resIP.Address) + if err != nil { + t.Fatalf("Failed to get reserved IP: %v", err) + } + foundInFetch := false + for _, tag := range fetched.Tags { + if tag == "lb" { + foundInFetch = true + break + } + } + if !foundInFetch { + t.Errorf("Expected tag 'lb' in fetched IP tags, got %v", fetched.Tags) + } +} + +// TestReservedIPAddress_UpdateTags verifies that PUT /networking/reserved/ips/{address} +// replaces tags in full. +func TestReservedIPAddress_UpdateTags(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestReservedIPAddress_UpdateTags") + defer teardown() + + // Reserve without tags first + resIP, err := client.ReserveIPAddress(context.Background(), linodego.ReserveIPOptions{ + Region: "us-east", + }) + if err != nil { + t.Fatalf("Failed to reserve IP: %v", err) + } + defer func() { + if err := client.DeleteReservedIPAddress(context.Background(), resIP.Address); err != nil { + t.Errorf("Failed to delete reserved IP %s: %v", resIP.Address, err) + } + }() + + // Set tags + updated, err := client.UpdateReservedIPAddress(context.Background(), resIP.Address, linodego.UpdateReservedIPOptions{ + Tags: []string{"lb"}, + }) + if err != nil { + t.Fatalf("Failed to update reserved IP tags: %v", err) + } + if len(updated.Tags) != 1 || updated.Tags[0] != "lb" { + t.Errorf("Expected tags=[lb] after update, got %v", updated.Tags) + } + + // Replace tags entirely (full replacement semantics) + cleared, err := client.UpdateReservedIPAddress(context.Background(), resIP.Address, linodego.UpdateReservedIPOptions{ + Tags: []string{}, + }) + if err != nil { + t.Fatalf("Failed to clear reserved IP tags: %v", err) + } + if len(cleared.Tags) != 0 { + t.Errorf("Expected empty tags after clearing, got %v", cleared.Tags) + } +} + +// TestReservedIPTypes_List verifies that GET /networking/reserved/ips/types returns +// pricing structs with all expected fields populated. +func TestReservedIPTypes_List(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestReservedIPTypes_List") + defer teardown() + + types, err := client.ListReservedIPTypes(context.Background(), nil) + if err != nil { + t.Fatalf("Failed to list reserved IP types: %v", err) + } + if len(types) == 0 { + t.Fatal("Expected at least one reserved IP type, got none") + } + + for _, rt := range types { + if rt.ID == "" { + t.Errorf("Expected non-empty ID for reserved IP type") + } + if rt.Label == "" { + t.Errorf("Expected non-empty Label for reserved IP type") + } + if rt.Price.Hourly == 0 && rt.Price.Monthly == 0 { + t.Errorf("Expected non-zero pricing for type %s", rt.ID) + } + } +} + // TestReservedIPAddresses_InsufficientPermissions tests the behavior when a user account // doesn't have the permission to use the Reserved IP feature func TestReservedIPAddresses_InsufficientPermissions(t *testing.T) { diff --git a/test/unit/fixtures/instance_ip_reserved.json b/test/unit/fixtures/instance_ip_reserved.json index a14521af4..12674727b 100644 --- a/test/unit/fixtures/instance_ip_reserved.json +++ b/test/unit/fixtures/instance_ip_reserved.json @@ -6,6 +6,12 @@ "type": "ipv4", "public": false, "linode_id": 123, - "region": "us-central" -} - \ No newline at end of file + "region": "us-central", + "reserved": true, + "assigned_entity": { + "id": 123, + "label": "my-linode", + "type": "linode", + "url": "/v4/linode/instances/123" + } +} \ No newline at end of file diff --git a/test/unit/fixtures/network_reserved_ip_types_list.json b/test/unit/fixtures/network_reserved_ip_types_list.json new file mode 100644 index 000000000..5c7828c1f --- /dev/null +++ b/test/unit/fixtures/network_reserved_ip_types_list.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "id": "ipv4_address", + "label": "IPv4 Address", + "price": { + "hourly": 0.005, + "monthly": 2.00 + }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.006, + "monthly": 3.00 + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/unit/fixtures/network_reserved_ip_update.json b/test/unit/fixtures/network_reserved_ip_update.json new file mode 100644 index 000000000..e8dc4c794 --- /dev/null +++ b/test/unit/fixtures/network_reserved_ip_update.json @@ -0,0 +1,8 @@ +{ + "address": "192.168.1.10", + "region": "us-east", + "linode_id": 12345, + "reserved": true, + "tags": ["lb", "team:infra"], + "assigned_entity": null +} diff --git a/test/unit/fixtures/network_reserved_ips.json b/test/unit/fixtures/network_reserved_ips.json index 7f111de9f..4279c23be 100644 --- a/test/unit/fixtures/network_reserved_ips.json +++ b/test/unit/fixtures/network_reserved_ips.json @@ -4,6 +4,8 @@ "linode_id": 13579, "label": "test-ip-3", "created": "2025-02-03T12:00:00", - "status": "reserved" + "status": "reserved", + "reserved": true, + "tags": ["env:staging"] } \ No newline at end of file diff --git a/test/unit/fixtures/network_reserved_ips_get.json b/test/unit/fixtures/network_reserved_ips_get.json index 7bfb54e56..ae18648dc 100644 --- a/test/unit/fixtures/network_reserved_ips_get.json +++ b/test/unit/fixtures/network_reserved_ips_get.json @@ -2,8 +2,12 @@ "address": "192.168.1.10", "region": "us-east", "linode_id": 12345, - "label": "test-ip-1", - "created": "2025-02-03T12:00:00", - "status": "reserved" -} - \ No newline at end of file + "reserved": true, + "tags": ["lb"], + "assigned_entity": { + "id": 1234, + "label": "my-linode", + "type": "linode", + "url": "/v4/linode/instances/12345" + } +} \ No newline at end of file diff --git a/test/unit/fixtures/network_reserved_ips_list.json b/test/unit/fixtures/network_reserved_ips_list.json index 79112fe65..74cac62ab 100644 --- a/test/unit/fixtures/network_reserved_ips_list.json +++ b/test/unit/fixtures/network_reserved_ips_list.json @@ -6,7 +6,9 @@ "linode_id": 12345, "label": "test-ip-1", "created": "2025-02-03T12:00:00", - "status": "reserved" + "status": "reserved", + "reserved": true, + "tags": ["lb"] }, { "address": "192.168.1.20", @@ -14,7 +16,9 @@ "linode_id": 67890, "label": "test-ip-2", "created": "2025-02-03T12:00:00", - "status": "reserved" + "status": "reserved", + "reserved": true, + "tags": [] } ], "pages": 1, diff --git a/test/unit/fixtures/tagged_objects_reserved_ip_list.json b/test/unit/fixtures/tagged_objects_reserved_ip_list.json new file mode 100644 index 000000000..841f2f5cc --- /dev/null +++ b/test/unit/fixtures/tagged_objects_reserved_ip_list.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "type": "reserved_ipv4_address", + "data": { + "address": "192.168.1.10", + "gateway": "192.0.2.1", + "interface_id": null, + "linode_id": 0, + "prefix": 24, + "public": true, + "rdns": "", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4", + "vpc_nat_1_1": null, + "reserved": true, + "tags": ["lb"] + } + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/unit/instance_ip_test.go b/test/unit/instance_ip_test.go index 27b046035..0020430eb 100644 --- a/test/unit/instance_ip_test.go +++ b/test/unit/instance_ip_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/jarcoal/httpmock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" ) @@ -126,4 +127,29 @@ func TestInstanceReservedIP_Assign(t *testing.T) { assert.NotNil(t, ip) assert.Equal(t, "203.0.113.1", ip.Address) assert.False(t, ip.Public) + assert.True(t, ip.Reserved) + + // AssignedEntity assertions + assert.NotNil(t, ip.AssignedEntity) + assert.Equal(t, 123, ip.AssignedEntity.ID) + assert.Equal(t, "my-linode", ip.AssignedEntity.Label) + assert.Equal(t, "linode", ip.AssignedEntity.Type) + assert.Equal(t, "/v4/linode/instances/123", ip.AssignedEntity.URL) +} + +func TestInstanceReservedIP_Assign_RequestBody(t *testing.T) { + client := createMockClient(t) + + opts := linodego.InstanceReserveIPOptions{ + Type: "ipv4", + Public: false, + Address: "203.0.113.1", + } + + httpmock.RegisterRegexpResponder("POST", mockRequestURL(t, "/linode/instances/123/ips"), + mockRequestBodyValidate(t, opts, nil)) + + if _, err := client.AssignInstanceReservedIP(context.Background(), 123, opts); err != nil { + t.Fatal(err) + } } diff --git a/test/unit/instance_test.go b/test/unit/instance_test.go index f10e08d6f..fb4d210dc 100644 --- a/test/unit/instance_test.go +++ b/test/unit/instance_test.go @@ -312,3 +312,20 @@ func TestInstance_Rebuild(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "linode/ubuntu22.04", instance.Image) } + +func TestCreateInstance_IPv4ReservedIPs(t *testing.T) { + client := createMockClient(t) + + createOpts := linodego.InstanceCreateOptions{ + Region: "us-east", + Type: "g6-standard-1", + IPv4: []string{"192.0.2.1"}, + } + + httpmock.RegisterRegexpResponder("POST", mockRequestURL(t, "/linode/instances"), + mockRequestBodyValidate(t, createOpts, nil)) + + if _, err := client.CreateInstance(context.Background(), createOpts); err != nil { + t.Fatal(err) + } +} diff --git a/test/unit/network_ips_test.go b/test/unit/network_ips_test.go index 11042d4bd..0cb2cb0b4 100644 --- a/test/unit/network_ips_test.go +++ b/test/unit/network_ips_test.go @@ -2,8 +2,11 @@ package unit import ( "context" + "encoding/json" + "net/http" "testing" + "github.com/jarcoal/httpmock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" ) @@ -30,6 +33,32 @@ func TestIPUpdateAddressV2(t *testing.T) { assert.True(t, updatedIP.Reserved, "Expected Reserved to be true") } +// TestIPUpdateAddressV2_BothFields: both "rdns" and "reserved" present in request body. +func TestIPUpdateAddressV2_BothFields(t *testing.T) { + client := createMockClient(t) + + rdns := "test.example.org" + opts := linodego.IPAddressUpdateOptionsV2{ + RDNS: linodego.Pointer(&rdns), + Reserved: linodego.Pointer(true), + } + + httpmock.RegisterRegexpResponder("PUT", mockRequestURL(t, "/networking/ips/192.168.1.1"), + func(req *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + assert.Equal(t, rdns, body["rdns"]) + assert.Equal(t, true, body["reserved"]) + return httpmock.NewJsonResponse(http.StatusOK, nil) + }) + + if _, err := client.UpdateIPAddressV2(context.Background(), "192.168.1.1", opts); err != nil { + t.Fatal(err) + } +} + func TestIPAllocateReserve(t *testing.T) { var base ClientBaseCase base.SetUp(t) @@ -37,9 +66,10 @@ func TestIPAllocateReserve(t *testing.T) { // Mock API response base.MockPost("networking/ips", linodego.InstanceIP{ - Address: "192.168.1.3", - Region: "us-east", - Public: true, + Address: "192.168.1.3", + Region: "us-east", + Public: true, + AssignedEntity: nil, }) ip, err := base.Client.AllocateReserveIP(context.Background(), linodego.AllocateReserveIPOptions{ @@ -54,6 +84,25 @@ func TestIPAllocateReserve(t *testing.T) { assert.Equal(t, "us-east", ip.Region, "Expected Region to match") assert.True(t, ip.Public, "Expected Public to be true") assert.Nil(t, ip.InterfaceID) + assert.Nil(t, ip.AssignedEntity) +} + +func TestIPAllocateReserve_RequestBody(t *testing.T) { + client := createMockClient(t) + + opts := linodego.AllocateReserveIPOptions{ + Type: "ipv4", + Public: true, + Reserved: true, + Region: "us-east", + } + + httpmock.RegisterRegexpResponder("POST", mockRequestURL(t, "/networking/ips"), + mockRequestBodyValidate(t, opts, nil)) + + if _, err := client.AllocateReserveIP(context.Background(), opts); err != nil { + t.Fatal(err) + } } func TestIPAssignInstances(t *testing.T) { @@ -145,3 +194,22 @@ func TestIPAddresses_Get(t *testing.T) { assert.Equal(t, 101, ip.VPCNAT1To1.SubnetID) assert.Equal(t, 111, ip.VPCNAT1To1.VPCID) } + +func TestIPAddresses_List_FilterByReserved(t *testing.T) { + fixtureData, err := fixtures.GetFixture("network_ip_addresses_list") + assert.NoError(t, err) + + client := createMockClient(t) + + httpmock.RegisterRegexpResponder("GET", mockRequestURL(t, "/networking/ips"), + func(req *http.Request) (*http.Response, error) { + filter := req.Header.Get("X-Filter") + assert.Equal(t, `{"reserved":true}`, filter, "Expected X-Filter header to filter by reserved=true") + return httpmock.NewJsonResponse(http.StatusOK, fixtureData) + }) + + _, err = client.ListIPAddresses(context.Background(), &linodego.ListOptions{ + Filter: `{"reserved":true}`, + }) + assert.NoError(t, err) +} diff --git a/test/unit/network_reserved_ips_test.go b/test/unit/network_reserved_ips_test.go index f45cc04d2..6cb76a505 100644 --- a/test/unit/network_reserved_ips_test.go +++ b/test/unit/network_reserved_ips_test.go @@ -23,12 +23,14 @@ func TestReservedIPAddresses_List(t *testing.T) { Region: "us-east", LinodeID: 12345, Reserved: true, + Tags: []string{"lb"}, }, { Address: "192.168.1.20", Region: "us-west", LinodeID: 67890, Reserved: true, + Tags: []string{}, }, }, } @@ -43,24 +45,20 @@ func TestReservedIPAddresses_List(t *testing.T) { assert.Equal(t, "192.168.1.10", reservedIPs[0].Address, "Expected first reserved IP address to match") assert.Equal(t, "us-east", reservedIPs[0].Region, "Expected region to match") assert.Equal(t, 12345, reservedIPs[0].LinodeID, "Expected Linode ID to match") + assert.True(t, reservedIPs[0].Reserved, "Expected first IP to be reserved") + assert.Equal(t, []string{"lb"}, reservedIPs[0].Tags, "Expected tags to match") } func TestReservedIPAddress_Get(t *testing.T) { + fixtureData, err := fixtures.GetFixture("network_reserved_ips_get") + assert.NoError(t, err) + var base ClientBaseCase base.SetUp(t) defer base.TearDown(t) ip := "192.168.1.10" - - // Mock response with necessary attributes - mockResponse := linodego.InstanceIP{ - Address: ip, - Region: "us-east", - LinodeID: 12345, - Reserved: true, - } - - base.MockGet("networking/reserved/ips/"+ip, mockResponse) + base.MockGet("networking/reserved/ips/"+ip, fixtureData) reservedIP, err := base.Client.GetReservedIPAddress(context.Background(), ip) @@ -69,6 +67,14 @@ func TestReservedIPAddress_Get(t *testing.T) { assert.Equal(t, ip, reservedIP.Address, "Expected reserved IP address to match") assert.Equal(t, "us-east", reservedIP.Region, "Expected region to match") assert.Equal(t, 12345, reservedIP.LinodeID, "Expected Linode ID to match") + assert.True(t, reservedIP.Reserved, "Expected IP to be reserved") + assert.Equal(t, []string{"lb"}, reservedIP.Tags, "Expected tags to match") + + assert.NotNil(t, reservedIP.AssignedEntity) + assert.Equal(t, 1234, reservedIP.AssignedEntity.ID) + assert.Equal(t, "my-linode", reservedIP.AssignedEntity.Label) + assert.Equal(t, "linode", reservedIP.AssignedEntity.Type) + assert.Equal(t, "/v4/linode/instances/12345", reservedIP.AssignedEntity.URL) } func TestIPReserveIPAddress(t *testing.T) { @@ -82,10 +88,12 @@ func TestIPReserveIPAddress(t *testing.T) { // Mock the POST request for reserving an IP mockResponse := linodego.InstanceIP{ - Address: "192.168.1.30", - Region: "us-west", - LinodeID: 13579, - Reserved: true, + Address: "192.168.1.30", + Region: "us-west", + LinodeID: 13579, + Reserved: true, + Tags: []string{"env:staging"}, + AssignedEntity: nil, } base.MockPost("networking/reserved/ips", mockResponse) @@ -97,6 +105,8 @@ func TestIPReserveIPAddress(t *testing.T) { assert.Equal(t, "192.168.1.30", reservedIP.Address, "Expected reserved IP address to match") assert.Equal(t, "us-west", reservedIP.Region, "Expected region to match") assert.Equal(t, 13579, reservedIP.LinodeID, "Expected Linode ID to match") + assert.Equal(t, []string{"env:staging"}, reservedIP.Tags, "Expected tags to match") + assert.Nil(t, reservedIP.AssignedEntity, "Expected AssignedEntity to be nil for newly reserved IP") } func TestReservedIPAddress_Delete(t *testing.T) { @@ -113,3 +123,51 @@ func TestReservedIPAddress_Delete(t *testing.T) { assert.NoError(t, err, "Expected no error when deleting reserved IP address") } + +func TestUpdateReservedIPAddress(t *testing.T) { + fixtureData, err := fixtures.GetFixture("network_reserved_ip_update") + assert.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + ip := "192.168.1.10" + base.MockPut("networking/reserved/ips/"+ip, fixtureData) + + updateOpts := linodego.UpdateReservedIPOptions{ + Tags: []string{"lb", "team:infra"}, + } + + updated, err := base.Client.UpdateReservedIPAddress(context.Background(), ip, updateOpts) + + assert.NoError(t, err, "Expected no error when updating reserved IP address") + assert.NotNil(t, updated) + assert.Equal(t, ip, updated.Address) + assert.True(t, updated.Reserved) + assert.Equal(t, []string{"lb", "team:infra"}, updated.Tags) + assert.Nil(t, updated.AssignedEntity, "Expected AssignedEntity to be nil for unassigned reserved IP") +} + +func TestListReservedIPTypes(t *testing.T) { + fixtureData, err := fixtures.GetFixture("network_reserved_ip_types_list") + assert.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockGet("networking/reserved/ips/types", fixtureData) + + types, err := base.Client.ListReservedIPTypes(context.Background(), nil) + + assert.NoError(t, err, "Expected no error when listing reserved IP types") + assert.Len(t, types, 1) + assert.Equal(t, "ipv4_address", types[0].ID) + assert.Equal(t, "IPv4 Address", types[0].Label) + assert.Equal(t, 0.005, types[0].Price.Hourly) + assert.Equal(t, 2.00, types[0].Price.Monthly) + assert.Len(t, types[0].RegionPrices, 1) + assert.Equal(t, "us-east", types[0].RegionPrices[0].ID) + assert.Equal(t, 0.006, types[0].RegionPrices[0].Hourly) +} diff --git a/test/unit/nodebalancer_test.go b/test/unit/nodebalancer_test.go index e78f7f2fb..a9453ac65 100644 --- a/test/unit/nodebalancer_test.go +++ b/test/unit/nodebalancer_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/jarcoal/httpmock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" ) @@ -152,3 +153,20 @@ func TestNodeBalancer_Delete(t *testing.T) { err := base.Client.DeleteNodeBalancer(context.Background(), 123) assert.NoError(t, err, "Expected no error when deleting NodeBalancer") } + +func TestNodeBalancer_Create_IPv4RequestBody(t *testing.T) { + client := createMockClient(t) + + opts := linodego.NodeBalancerCreateOptions{ + Label: String("Test NodeBalancer IPv4"), + Region: "us-east", + IPv4: String("192.0.2.2"), + } + + httpmock.RegisterRegexpResponder("POST", mockRequestURL(t, "/nodebalancers"), + mockRequestBodyValidate(t, opts, nil)) + + if _, err := client.CreateNodeBalancer(context.Background(), opts); err != nil { + t.Fatal(err) + } +} diff --git a/test/unit/tag_test.go b/test/unit/tag_test.go index d06cc9e25..ba4de61a2 100644 --- a/test/unit/tag_test.go +++ b/test/unit/tag_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/jarcoal/httpmock" "github.com/linode/linodego" "github.com/stretchr/testify/assert" "golang.org/x/exp/slices" @@ -117,3 +118,71 @@ func TestSortedObjects(t *testing.T) { assert.NotEmpty(t, sortedObjects.Instances, "Expected non-empty instances list in sorted objects") assert.Equal(t, "example-instance", sortedObjects.Instances[0].Label, "Expected instance label to be 'example-instance'") } + +func TestCreateTagWithReservedIPv4Addresses(t *testing.T) { + fixtureData, err := fixtures.GetFixture("tag_create") + assert.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + base.MockPost("tags", fixtureData) + + opts := linodego.TagCreateOptions{ + Label: "new-tag", + ReservedIPv4Addresses: []string{"192.168.1.10", "192.168.1.20"}, + } + + tag, err := base.Client.CreateTag(context.Background(), opts) + assert.NoError(t, err, "Expected no error when creating tag with reserved IPv4 addresses") + assert.Equal(t, "new-tag", tag.Label) +} + +func TestListTaggedObjectsWithReservedIPv4Address(t *testing.T) { + fixtureData, err := fixtures.GetFixture("tagged_objects_reserved_ip_list") + assert.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + defer base.TearDown(t) + + tagLabel := "lb" + base.MockGet(fmt.Sprintf("tags/%s", tagLabel), fixtureData) + + taggedObjects, err := base.Client.ListTaggedObjects(context.Background(), tagLabel, &linodego.ListOptions{}) + assert.NoError(t, err) + assert.Len(t, taggedObjects, 1) + + assert.Equal(t, "reserved_ipv4_address", taggedObjects[0].Type) + + ip, ok := taggedObjects[0].Data.(linodego.InstanceIP) + assert.True(t, ok, "Expected Data to be InstanceIP") + assert.Equal(t, "192.168.1.10", ip.Address) + assert.Equal(t, "192.0.2.1", ip.Gateway) + assert.Equal(t, 24, ip.Prefix) + assert.True(t, ip.Public) + assert.Equal(t, "", ip.RDNS) + assert.Equal(t, "us-east", ip.Region) + assert.Equal(t, "255.255.255.0", ip.SubnetMask) + assert.Equal(t, linodego.InstanceIPType("ipv4"), ip.Type) + assert.Nil(t, ip.VPCNAT1To1) + assert.True(t, ip.Reserved) + assert.Equal(t, []string{"lb"}, ip.Tags) +} + +func TestCreateTag_ReservedIPv4AddressesRequestBody(t *testing.T) { + client := createMockClient(t) + + opts := linodego.TagCreateOptions{ + Label: "new-tag", + ReservedIPv4Addresses: []string{"192.0.2.141"}, + } + + httpmock.RegisterRegexpResponder("POST", mockRequestURL(t, "/tags"), + mockRequestBodyValidate(t, opts, nil)) + + if _, err := client.CreateTag(context.Background(), opts); err != nil { + t.Fatal(err) + } +}