Repository: xanzy/go-gitlab
Pull request #1841 (2023-12-01 15:10:13)

Add expires_at for token rotation operations
Add options with `expires_at` attribute for all token rotation functions and implemented tests for those:

* Rotate personal access tokens
* Rotate project access tokens
* Rotate group access tokens

The `expires_at` attribute was [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/416795) in GitLab 16.6.

As the rotate* functions did not accept options so far, this is a breaking-change for these three functions.


Patch:
diff --git a/examples/personal_access_tokens.go b/examples/personal_access_tokens.go
index 5706dda..aa49b15 100644
--- a/examples/personal_access_tokens.go
+++ b/examples/personal_access_tokens.go
@@ -20,6 +20,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"log"
+	"time"
 
 	"github.com/xanzy/go-gitlab"
 )
@@ -68,7 +69,11 @@ func patRotateExample() {
 		log.Fatal(err)
 	}
 
-	newPersonalAccessToken, _, err := git.PersonalAccessTokens.RotatePersonalAccessToken(12345)
+	expiry := gitlab.ISOTime(time.Date(2023, time.August, 15, 0, 0, 0, 0, time.UTC))
+	opts := &gitlab.RotatePersonalAccessTokenOptions{
+		ExpiresAt: &expiry,
+	}
+	newPersonalAccessToken, _, err := git.PersonalAccessTokens.RotatePersonalAccessToken(12345, opts)
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/group_access_tokens.go b/group_access_tokens.go
index 15f303a..ccbe47b 100644
--- a/group_access_tokens.go
+++ b/group_access_tokens.go
@@ -145,18 +145,27 @@ func (s *GroupAccessTokensService) CreateGroupAccessToken(gid interface{}, opt *
 	return pat, resp, nil
 }
 
+// RotateGroupAccessTokenOptions represents the available RotateGroupAccessToken()
+// options.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/group_access_tokens.html#rotate-a-group-access-token
+type RotateGroupAccessTokenOptions struct {
+	ExpiresAt *ISOTime `url:"expires_at,omitempty" json:"expires_at,omitempty"`
+}
+
 // RotateGroupAccessToken revokes a group access token and returns a new group
-// access token that expires in one week.
+// access token that expires in one week per default.
 //
 // GitLab API docs:
 // https://docs.gitlab.com/ee/api/group_access_tokens.html#rotate-a-group-access-token
-func (s *GroupAccessTokensService) RotateGroupAccessToken(gid interface{}, id int, options ...RequestOptionFunc) (*GroupAccessToken, *Response, error) {
+func (s *GroupAccessTokensService) RotateGroupAccessToken(gid interface{}, id int, opt *RotateGroupAccessTokenOptions, options ...RequestOptionFunc) (*GroupAccessToken, *Response, error) {
 	groups, err := parseID(gid)
 	if err != nil {
 		return nil, nil, err
 	}
 	u := fmt.Sprintf("groups/%s/access_tokens/%d/rotate", PathEscape(groups), id)
-	req, err := s.client.NewRequest(http.MethodPost, u, nil, options)
+	req, err := s.client.NewRequest(http.MethodPost, u, opt, options)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/personal_access_tokens.go b/personal_access_tokens.go
index 63d294e..ba32bd8 100644
--- a/personal_access_tokens.go
+++ b/personal_access_tokens.go
@@ -120,14 +120,24 @@ func (s *PersonalAccessTokensService) GetSinglePersonalAccessToken(options ...Re
 	return pat, resp, nil
 }
 
+// RotatePersonalAccessTokenOptions represents the available RotatePersonalAccessToken()
+// options.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/personal_access_tokens.html#rotate-a-personal-access-token
+type RotatePersonalAccessTokenOptions struct {
+	ExpiresAt *ISOTime `url:"expires_at,omitempty" json:"expires_at,omitempty"`
+}
+
 // RotatePersonalAccessToken revokes a token and returns a new token that
-// expires in one week.
+// expires in one week per default.
 //
 // GitLab API docs:
 // https://docs.gitlab.com/ee/api/personal_access_tokens.html#rotate-a-personal-access-token
-func (s *PersonalAccessTokensService) RotatePersonalAccessToken(token int, options ...RequestOptionFunc) (*PersonalAccessToken, *Response, error) {
+func (s *PersonalAccessTokensService) RotatePersonalAccessToken(token int, opt *RotatePersonalAccessTokenOptions, options ...RequestOptionFunc) (*PersonalAccessToken, *Response, error) {
 	u := fmt.Sprintf("personal_access_tokens/%d/rotate", token)
-	req, err := s.client.NewRequest(http.MethodPost, u, nil, options)
+
+	req, err := s.client.NewRequest(http.MethodPost, u, opt, options)
 	if err != nil {
 		return nil, nil, err
 	}
diff --git a/project_access_tokens.go b/project_access_tokens.go
index 780e59a..2d6057e 100644
--- a/project_access_tokens.go
+++ b/project_access_tokens.go
@@ -146,18 +146,27 @@ func (s *ProjectAccessTokensService) CreateProjectAccessToken(pid interface{}, o
 	return pat, resp, nil
 }
 
+// RotateProjectAccessTokenOptions represents the available RotateProjectAccessToken()
+// options.
+//
+// GitLab API docs:
+// https://docs.gitlab.com/ee/api/project_access_tokens.html#rotate-a-project-access-token
+type RotateProjectAccessTokenOptions struct {
+	ExpiresAt *ISOTime `url:"expires_at,omitempty" json:"expires_at,omitempty"`
+}
+
 // RotateProjectAccessToken revokes a project access token and returns a new
-// project access token that expires in one week.
+// project access token that expires in one week per default.
 //
 // GitLab API docs:
 // https://docs.gitlab.com/ee/api/project_access_tokens.html#rotate-a-project-access-token
-func (s *ProjectAccessTokensService) RotateProjectAccessToken(pid interface{}, id int, options ...RequestOptionFunc) (*ProjectAccessToken, *Response, error) {
+func (s *ProjectAccessTokensService) RotateProjectAccessToken(pid interface{}, id int, opt *RotateProjectAccessTokenOptions, options ...RequestOptionFunc) (*ProjectAccessToken, *Response, error) {
 	projects, err := parseID(pid)
 	if err != nil {
 		return nil, nil, err
 	}
 	u := fmt.Sprintf("projects/%s/access_tokens/%d/rotate", PathEscape(projects), id)
-	req, err := s.client.NewRequest(http.MethodPost, u, nil, options)
+	req, err := s.client.NewRequest(http.MethodPost, u, opt, options)
 	if err != nil {
 		return nil, nil, err
 	}


Test patch:
diff --git a/group_access_tokens_test.go b/group_access_tokens_test.go
index aa1d465..93a358d 100644
--- a/group_access_tokens_test.go
+++ b/group_access_tokens_test.go
@@ -146,6 +146,38 @@ func TestCreateGroupAccessToken(t *testing.T) {
 		t.Errorf("GroupAccessTokens.CreateGroupAccessToken returned %+v, want %+v", groupAccessToken, want)
 	}
 }
+func TestRotateGroupAccessToken(t *testing.T) {
+	mux, client := setup(t)
+	mux.HandleFunc("/api/v4/groups/1/access_tokens/42/rotate", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPost)
+		mustWriteHTTPResponse(t, w, "testdata/rotate_group_access_token.json")
+	})
+
+	createdAt, _ := time.Parse(time.RFC3339, "2023-08-01T15:00:00.00Z")
+	expiration := ISOTime(time.Date(2023, time.August, 15, 0, 0, 0, 0, time.UTC))
+	opts := &RotateGroupAccessTokenOptions{ExpiresAt: &expiration}
+	rotatedToken, _, err := client.GroupAccessTokens.RotateGroupAccessToken(1, 42, opts)
+	if err != nil {
+		t.Errorf("GroupAccessTokens.RotateGroupAccessToken returned error: %v", err)
+	}
+
+	want := &GroupAccessToken{
+		ID:          42,
+		UserID:      1337,
+		Name:        "Rotated Token",
+		Scopes:      []string{"api"},
+		ExpiresAt:   &expiration,
+		CreatedAt:   &createdAt,
+		Active:      true,
+		Revoked:     false,
+		Token:       "s3cr3t",
+		AccessLevel: AccessLevelValue(30),
+	}
+
+	if !reflect.DeepEqual(want, rotatedToken) {
+		t.Errorf("GroupAccessTokens.RotateGroupAccessToken returned %+v, want %+v", rotatedToken, want)
+	}
+}
 
 func TestRevokeGroupAccessToken(t *testing.T) {
 	mux, client := setup(t)
diff --git a/personal_access_tokens_test.go b/personal_access_tokens_test.go
index de92d83..36eee51 100644
--- a/personal_access_tokens_test.go
+++ b/personal_access_tokens_test.go
@@ -225,6 +225,37 @@ func TestGetSinglePersonalAccessToken(t *testing.T) {
 	}
 }
 
+func TestRotatePersonalAccessToken(t *testing.T) {
+	mux, client := setup(t)
+	mux.HandleFunc("/api/v4/personal_access_tokens/42/rotate", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPost)
+		mustWriteHTTPResponse(t, w, "testdata/rotate_personal_access_token.json")
+	})
+
+	createdAt, _ := time.Parse(time.RFC3339, "2023-08-01T15:00:00.000Z")
+	expiration := ISOTime(time.Date(2023, time.August, 15, 0, 0, 0, 0, time.UTC))
+	opts := &RotatePersonalAccessTokenOptions{ExpiresAt: &expiration}
+	rotatedToken, _, err := client.PersonalAccessTokens.RotatePersonalAccessToken(42, opts)
+	if err != nil {
+		t.Errorf("PersonalAccessTokens.RotatePersonalAccessToken returned error: %v", err)
+	}
+
+	want := &PersonalAccessToken{
+		ID:        42,
+		UserID:    1337,
+		Name:      "Rotated Token",
+		Scopes:    []string{"api"},
+		ExpiresAt: &expiration,
+		CreatedAt: &createdAt,
+		Active:    true,
+		Revoked:   false,
+		Token:     "s3cr3t",
+	}
+
+	if !reflect.DeepEqual(want, rotatedToken) {
+		t.Errorf("PersonalAccessTokens.RotatePersonalAccessTokens returned %+v, want %+v", rotatedToken, want)
+	}
+}
 func TestRevokePersonalAccessToken(t *testing.T) {
 	mux, client := setup(t)
 
diff --git a/project_access_tokens_test.go b/project_access_tokens_test.go
index efe2230..e0fc861 100644
--- a/project_access_tokens_test.go
+++ b/project_access_tokens_test.go
@@ -147,6 +147,39 @@ func TestCreateProjectAccessToken(t *testing.T) {
 	}
 }
 
+func TestRotateProjectAccessToken(t *testing.T) {
+	mux, client := setup(t)
+	mux.HandleFunc("/api/v4/projects/1/access_tokens/42/rotate", func(w http.ResponseWriter, r *http.Request) {
+		testMethod(t, r, http.MethodPost)
+		mustWriteHTTPResponse(t, w, "testdata/rotate_project_access_token.json")
+	})
+
+	createdAt, _ := time.Parse(time.RFC3339, "2023-08-01T15:00:00.000Z")
+	expiration := ISOTime(time.Date(2023, time.August, 15, 0, 0, 0, 0, time.UTC))
+	opts := &RotateProjectAccessTokenOptions{ExpiresAt: &expiration}
+	rotatedToken, _, err := client.ProjectAccessTokens.RotateProjectAccessToken(1, 42, opts)
+	if err != nil {
+		t.Errorf("ProjectAccessTokens.RotateProjectAccessToken returned error: %v", err)
+	}
+
+	want := &ProjectAccessToken{
+		ID:          42,
+		UserID:      1337,
+		Name:        "Rotated Token",
+		Scopes:      []string{"api"},
+		ExpiresAt:   &expiration,
+		CreatedAt:   &createdAt,
+		Active:      true,
+		Revoked:     false,
+		AccessLevel: AccessLevelValue(30),
+		Token:       "s3cr3t",
+	}
+
+	if !reflect.DeepEqual(want, rotatedToken) {
+		t.Errorf("ProjectAccessTokens.RotateProjectAccessTokens returned %+v, want %+v", rotatedToken, want)
+	}
+}
+
 func TestRevokeProjectAccessToken(t *testing.T) {
 	mux, client := setup(t)
 
diff --git a/testdata/rotate_group_access_token.json b/testdata/rotate_group_access_token.json
new file mode 100644
index 0000000..b8dd89c
--- /dev/null
+++ b/testdata/rotate_group_access_token.json
@@ -0,0 +1,13 @@
+{
+  "id": 42,
+  "name": "Rotated Token",
+  "revoked": false,
+  "created_at": "2023-08-01T15:00:00.000Z",
+  "scopes": ["api"],
+  "user_id": 1337,
+  "last_used_at": null,
+  "active": true,
+  "expires_at": "2023-08-15",
+  "access_level": 30,
+  "token": "s3cr3t"
+}
diff --git a/testdata/rotate_personal_access_token.json b/testdata/rotate_personal_access_token.json
new file mode 100644
index 0000000..2d7b273
--- /dev/null
+++ b/testdata/rotate_personal_access_token.json
@@ -0,0 +1,12 @@
+{
+  "id": 42,
+  "name": "Rotated Token",
+  "revoked": false,
+  "created_at": "2023-08-01T15:00:00.000Z",
+  "scopes": ["api"],
+  "user_id": 1337,
+  "last_used_at": null,
+  "active": true,
+  "expires_at": "2023-08-15",
+  "token": "s3cr3t"
+}
diff --git a/testdata/rotate_project_access_token.json b/testdata/rotate_project_access_token.json
new file mode 100644
index 0000000..b8dd89c
--- /dev/null
+++ b/testdata/rotate_project_access_token.json
@@ -0,0 +1,13 @@
+{
+  "id": 42,
+  "name": "Rotated Token",
+  "revoked": false,
+  "created_at": "2023-08-01T15:00:00.000Z",
+  "scopes": ["api"],
+  "user_id": 1337,
+  "last_used_at": null,
+  "active": true,
+  "expires_at": "2023-08-15",
+  "access_level": 30,
+  "token": "s3cr3t"
+}