mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-26 08:12:13 -04:00
## Checklist Following the previous contribution that added admin-level management of user access tokens (particularly useful for bot/service accounts), this change exposes the created_at field in the API response when listing or retrieving access tokens. This information is needed to implement token rotation policies for these users — knowing when a token was created allows administrators to identify and revoke stale tokens. ### Tests for Go changes - I added test coverage for Go changes... - [X] in their respective `*_test.go` for unit tests. - [X] `make pr-go` before pushing ### Documentation - [X] I did not document these changes and I do not expect someone else to do it. ### Release notes - [X] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. <!--start release-notes-assistant--> ## Release notes <!--URL:https://codeberg.org/forgejo/forgejo--> - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/12620): <!--number 12620 --><!--line 0 --><!--description ZXhwb3NlIGFjY2VzcyB0b2tlbiBjcmVhdGlvbiBkYXRlIGluIEFQSSByZXNwb25zZXM=-->expose access token creation date in API responses<!--description--> <!--end release-notes-assistant--> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12620 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
332 lines
11 KiB
Go
332 lines
11 KiB
Go
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
auth_model "forgejo.org/models/auth"
|
|
"forgejo.org/models/unittest"
|
|
user_model "forgejo.org/models/user"
|
|
api "forgejo.org/modules/structs"
|
|
"forgejo.org/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAPIAdminCreateUserAccessToken(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
|
|
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
|
|
|
t.Run("Create token for another user", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "admin-created-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var newToken api.AccessToken
|
|
DecodeJSON(t, resp, &newToken)
|
|
|
|
assert.Equal(t, "admin-created-token", newToken.Name)
|
|
assert.NotEmpty(t, newToken.Token)
|
|
assert.NotEmpty(t, newToken.TokenLastEight)
|
|
assert.Contains(t, newToken.Scopes, "all")
|
|
assert.NotZero(t, newToken.Created)
|
|
|
|
// Verify the token exists in DB
|
|
unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{
|
|
ID: newToken.ID,
|
|
Name: newToken.Name,
|
|
UID: targetUser.ID,
|
|
})
|
|
})
|
|
|
|
t.Run("Create token with duplicate name", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "admin-created-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Create token without scopes", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "empty-scope-token",
|
|
Scopes: []string{},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Create token with invalid scope", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "invalid-scope-token",
|
|
Scopes: []string{"invalid-scope"},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("Create token for nonexistent user", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := "/api/v1/admin/users/nonexistentuser/tokens"
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "some-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("Non-admin cannot create token", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "unauthorized-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(normalToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
}
|
|
|
|
func TestAPIAdminListUserAccessTokens(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
|
|
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
|
|
|
// First, create a token for the target user
|
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
|
Name: "list-test-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
t.Run("List tokens for user", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequest(t, "GET", listURL).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var tokens []*api.AccessToken
|
|
DecodeJSON(t, resp, &tokens)
|
|
|
|
// user2 has at least the token we just created plus any fixture tokens
|
|
require.NotEmpty(t, tokens)
|
|
|
|
found := false
|
|
for _, tk := range tokens {
|
|
if tk.Name == "list-test-token" {
|
|
found = true
|
|
assert.NotEmpty(t, tk.TokenLastEight)
|
|
assert.NotZero(t, tk.Created)
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "should find the admin-created token in the list")
|
|
})
|
|
|
|
t.Run("Non-admin cannot list tokens", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
|
|
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequest(t, "GET", listURL).AddTokenAuth(normalToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
}
|
|
|
|
func TestAPIAdminCreateRepoSpecificToken(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
|
|
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
|
|
|
t.Run("Create repo-specific token", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "admin-repo-specific-token",
|
|
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
|
|
Repositories: []*api.RepoTargetOption{
|
|
{
|
|
Owner: "user2",
|
|
Name: "repo2",
|
|
},
|
|
},
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var newToken api.AccessToken
|
|
DecodeJSON(t, resp, &newToken)
|
|
|
|
assert.Equal(t, "admin-repo-specific-token", newToken.Name)
|
|
assert.NotEmpty(t, newToken.Token)
|
|
assert.NotEmpty(t, newToken.Repositories)
|
|
})
|
|
|
|
t.Run("Create token targeting invalid repo", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
urlStr := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", urlStr, api.CreateAccessTokenOption{
|
|
Name: "admin-invalid-repo-token",
|
|
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
|
|
Repositories: []*api.RepoTargetOption{
|
|
{
|
|
Owner: "user10000",
|
|
Name: "repo70000",
|
|
},
|
|
},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("List repo-specific token returns repositories", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
// Create a repo-specific token
|
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
|
Name: "admin-list-repo-token",
|
|
Scopes: []string{string(auth_model.AccessTokenScopeReadRepository)},
|
|
Repositories: []*api.RepoTargetOption{
|
|
{
|
|
Owner: "user2",
|
|
Name: "repo2",
|
|
},
|
|
},
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
// List tokens and verify repositories are returned
|
|
listURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req = NewRequest(t, "GET", listURL).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
|
|
var tokens []*api.AccessToken
|
|
DecodeJSON(t, resp, &tokens)
|
|
|
|
found := false
|
|
for _, tk := range tokens {
|
|
if tk.Name == "admin-list-repo-token" {
|
|
found = true
|
|
assert.NotEmpty(t, tk.Repositories, "admin-listed token should have repositories populated")
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "should find the repo-specific token in the admin list")
|
|
})
|
|
}
|
|
|
|
func TestAPIAdminDeleteUserAccessToken(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
|
|
targetUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
|
|
|
|
token := getUserToken(t, adminUser.Name, auth_model.AccessTokenScopeWriteAdmin)
|
|
|
|
t.Run("Delete token by ID", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
// Create a token first
|
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
|
Name: "delete-by-id-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var newToken api.AccessToken
|
|
DecodeJSON(t, resp, &newToken)
|
|
|
|
// Delete it
|
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, newToken.ID)
|
|
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// Verify it's gone
|
|
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: newToken.ID})
|
|
})
|
|
|
|
t.Run("Delete token by name", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
// Create a token first
|
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
|
Name: "delete-by-name-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var newToken api.AccessToken
|
|
DecodeJSON(t, resp, &newToken)
|
|
|
|
// Delete by name
|
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%s", targetUser.Name, "delete-by-name-token")
|
|
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// Verify it's gone
|
|
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: newToken.ID})
|
|
})
|
|
|
|
t.Run("Delete nonexistent token", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, 999999)
|
|
req := NewRequest(t, "DELETE", deleteURL).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("Non-admin cannot delete token", func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
// Create a token as admin
|
|
createURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens", targetUser.Name)
|
|
req := NewRequestWithJSON(t, "POST", createURL, api.CreateAccessTokenOption{
|
|
Name: "non-admin-delete-token",
|
|
Scopes: []string{"all"},
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
var newToken api.AccessToken
|
|
DecodeJSON(t, resp, &newToken)
|
|
|
|
// Try to delete as non-admin
|
|
normalToken := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteAdmin)
|
|
deleteURL := fmt.Sprintf("/api/v1/admin/users/%s/tokens/%d", targetUser.Name, newToken.ID)
|
|
req = NewRequest(t, "DELETE", deleteURL).AddTokenAuth(normalToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
}
|