mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
622 lines
16 KiB
Go
622 lines
16 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"testing"
|
|
|
|
auth_model "github.com/hanzoai/git/models/auth"
|
|
"github.com/hanzoai/git/models/unittest"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/log"
|
|
api "github.com/hanzoai/git/modules/structs"
|
|
"github.com/hanzoai/git/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// TestAPICreateAndDeleteToken tests that token that was just created can be deleted
|
|
func TestAPICreateAndDeleteToken(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
|
|
newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
|
|
deleteAPIAccessToken(t, newAccessToken, user)
|
|
|
|
newAccessToken = createAPIAccessTokenWithoutCleanUp(t, "test-key-2", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
|
|
deleteAPIAccessToken(t, newAccessToken, user)
|
|
}
|
|
|
|
// TestAPICreateTokenScopeEscalation ensures a token-authenticated request cannot
|
|
// mint a new token with a broader scope than the authenticating token.
|
|
func TestAPICreateTokenScopeEscalation(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
|
|
// a write:user-scoped token authenticates the create requests below
|
|
writeUserToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteUser)
|
|
|
|
// requesting a broader scope ("all") than the authenticating token is rejected
|
|
req := NewRequestWithJSON(t, "POST", "/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
|
"name": "escalated",
|
|
"scopes": []string{"all"},
|
|
})
|
|
req.Request.SetBasicAuth(user.Name, writeUserToken)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
|
|
// requesting a subset scope ("read:user") is allowed
|
|
req = NewRequestWithJSON(t, "POST", "/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
|
"name": "subset",
|
|
"scopes": []string{"read:user"},
|
|
})
|
|
req.Request.SetBasicAuth(user.Name, writeUserToken)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
// password (non-token) auth may still create a token with any scope
|
|
req = NewRequestWithJSON(t, "POST", "/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
|
"name": "by-password",
|
|
"scopes": []string{"all"},
|
|
}).AddBasicAuth(user.Name)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
// a public-only token must not mint a token that drops the public-only restriction
|
|
publicOnlyToken := getUserToken(t, user.Name, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopePublicOnly)
|
|
req = NewRequestWithJSON(t, "POST", "/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
|
"name": "still-public-only",
|
|
"scopes": []string{"write:user"},
|
|
})
|
|
req.Request.SetBasicAuth(user.Name, publicOnlyToken)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
var createdToken api.AccessToken
|
|
DecodeJSON(t, resp, &createdToken)
|
|
assert.Contains(t, createdToken.Scopes, string(auth_model.AccessTokenScopePublicOnly))
|
|
|
|
// an unrestricted parent token may create a narrower public-only child: public-only is a restriction,
|
|
// not a grantable permission, so the subset check must not reject it
|
|
req = NewRequestWithJSON(t, "POST", "/v1/users/"+user.LoginName+"/tokens", map[string]any{
|
|
"name": "narrower-public-only",
|
|
"scopes": []string{"write:user", "public-only"},
|
|
})
|
|
req.Request.SetBasicAuth(user.Name, writeUserToken)
|
|
resp = MakeRequest(t, req, http.StatusCreated)
|
|
DecodeJSON(t, resp, &createdToken)
|
|
assert.Contains(t, createdToken.Scopes, string(auth_model.AccessTokenScopePublicOnly))
|
|
}
|
|
|
|
// TestAPIDeleteMissingToken ensures that error is thrown when token not found
|
|
func TestAPIDeleteMissingToken(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
|
|
req := NewRequestf(t, "DELETE", "/v1/users/user1/tokens/%d", unittest.NonexistentID).
|
|
AddBasicAuth(user.Name)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
}
|
|
|
|
// TestAPIGetTokensPermission ensures that only the admin can get tokens from other users
|
|
func TestAPIGetTokensPermission(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
// admin can get tokens for other users
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
req := NewRequest(t, "GET", "/v1/users/user2/tokens").
|
|
AddBasicAuth(user.Name)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// non-admin can get tokens for himself
|
|
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
req = NewRequest(t, "GET", "/v1/users/user2/tokens").
|
|
AddBasicAuth(user.Name)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// non-admin can't get tokens for other users
|
|
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
|
req = NewRequest(t, "GET", "/v1/users/user2/tokens").
|
|
AddBasicAuth(user.Name)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
}
|
|
|
|
// TestAPIDeleteTokensPermission ensures that only the admin can delete tokens from other users
|
|
func TestAPIDeleteTokensPermission(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
|
|
|
// admin can delete tokens for other users
|
|
createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
|
|
req := NewRequest(t, "DELETE", "/v1/users/"+user2.LoginName+"/tokens/test-key-1").
|
|
AddBasicAuth(admin.Name)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// non-admin can delete tokens for himself
|
|
createAPIAccessTokenWithoutCleanUp(t, "test-key-2", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
|
|
req = NewRequest(t, "DELETE", "/v1/users/"+user2.LoginName+"/tokens/test-key-2").
|
|
AddBasicAuth(user2.Name)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// non-admin can't delete tokens for other users
|
|
createAPIAccessTokenWithoutCleanUp(t, "test-key-3", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
|
|
req = NewRequest(t, "DELETE", "/v1/users/"+user2.LoginName+"/tokens/test-key-3").
|
|
AddBasicAuth(user4.Name)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
}
|
|
|
|
type permission struct {
|
|
category auth_model.AccessTokenScopeCategory
|
|
level auth_model.AccessTokenScopeLevel
|
|
}
|
|
|
|
type requiredScopeTestCase struct {
|
|
url string
|
|
method string
|
|
requiredPermissions []permission
|
|
}
|
|
|
|
func (c *requiredScopeTestCase) Name() string {
|
|
return fmt.Sprintf("%v %v", c.method, c.url)
|
|
}
|
|
|
|
// TestAPIDeniesPermissionBasedOnTokenScope tests that API routes forbid access
|
|
// when the correct token scope is not included.
|
|
func TestAPIDeniesPermissionBasedOnTokenScope(t *testing.T) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
// We'll assert that each endpoint, when fetched with a token with all
|
|
// scopes *except* the ones specified, a forbidden status code is returned.
|
|
//
|
|
// This is to protect against endpoints having their access check copied
|
|
// from other endpoints and not updated.
|
|
//
|
|
// Test cases are in alphabetical order by URL.
|
|
testCases := []requiredScopeTestCase{
|
|
{
|
|
"/v1/admin/emails",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/admin/users",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/admin/users",
|
|
"POST",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/admin/users/user2",
|
|
"PATCH",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/admin/users/user2/orgs",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/admin/users/user2/orgs",
|
|
"POST",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/admin/orgs",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryAdmin,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/notifications",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryNotification,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/notifications",
|
|
"PUT",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryNotification,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/org/org1/repos",
|
|
"POST",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryOrganization,
|
|
auth_model.Write,
|
|
},
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/packages/user1/type/name/1",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryPackage,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/packages/user1/type/name/1",
|
|
"DELETE",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryPackage,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1",
|
|
"PATCH",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1",
|
|
"DELETE",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/branches",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/archive/foo",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/issues",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryIssue,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/media/foo",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/raw/foo",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/teams",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/teams/team1",
|
|
"PUT",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/repos/user1/repo1/transfer",
|
|
"POST",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
// Private repo
|
|
{
|
|
"/v1/repos/user2/repo2",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
// Private repo
|
|
{
|
|
"/v1/repos/user2/repo2",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryRepository,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/user",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/user/emails",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/user/emails",
|
|
"POST",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/user/emails",
|
|
"DELETE",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/user/applications/oauth2",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/user/applications/oauth2",
|
|
"POST",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Write,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"/v1/users/search",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
// Private user
|
|
{
|
|
"/v1/users/user31",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
// Private user
|
|
{
|
|
"/v1/users/user31/gpg_keys",
|
|
"GET",
|
|
[]permission{
|
|
{
|
|
auth_model.AccessTokenScopeCategoryUser,
|
|
auth_model.Read,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// User needs to be admin so that we can verify that tokens without admin
|
|
// scopes correctly deny access.
|
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
assert.True(t, user.IsAdmin, "User needs to be admin")
|
|
|
|
for _, testCase := range testCases {
|
|
runTestCase(t, &testCase, user)
|
|
}
|
|
}
|
|
|
|
// runTestCase Helper function to run a single test case.
|
|
func runTestCase(t *testing.T, testCase *requiredScopeTestCase, user *user_model.User) {
|
|
t.Run(testCase.Name(), func(t *testing.T) {
|
|
defer tests.PrintCurrentTest(t)()
|
|
|
|
// Create a token with all scopes NOT required by the endpoint.
|
|
var unauthorizedScopes []auth_model.AccessTokenScope
|
|
for _, category := range auth_model.AllAccessTokenScopeCategories {
|
|
// For permissions, Write > Read > NoAccess. So we need to
|
|
// find the minimum required, and only grant permission up to but
|
|
// not including the minimum required.
|
|
minRequiredLevel := auth_model.Write
|
|
categoryIsRequired := false
|
|
for _, requiredPermission := range testCase.requiredPermissions {
|
|
if requiredPermission.category != category {
|
|
continue
|
|
}
|
|
categoryIsRequired = true
|
|
if requiredPermission.level < minRequiredLevel {
|
|
minRequiredLevel = requiredPermission.level
|
|
}
|
|
}
|
|
unauthorizedLevel := auth_model.Write
|
|
if categoryIsRequired {
|
|
switch minRequiredLevel {
|
|
case auth_model.Read:
|
|
unauthorizedLevel = auth_model.NoAccess
|
|
case auth_model.Write:
|
|
unauthorizedLevel = auth_model.Read
|
|
default:
|
|
assert.FailNow(t, "Invalid test case", "Unknown access token scope level: %v", minRequiredLevel)
|
|
}
|
|
}
|
|
|
|
if unauthorizedLevel == auth_model.NoAccess {
|
|
continue
|
|
}
|
|
categoryUnauthorizedScopes := auth_model.GetRequiredScopes(
|
|
unauthorizedLevel,
|
|
category)
|
|
unauthorizedScopes = append(unauthorizedScopes, categoryUnauthorizedScopes...)
|
|
}
|
|
|
|
accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-token", user, unauthorizedScopes)
|
|
defer deleteAPIAccessToken(t, accessToken, user)
|
|
|
|
// Request the endpoint. Verify that permission is denied.
|
|
req := NewRequest(t, testCase.method, testCase.url).
|
|
AddTokenAuth(accessToken.Token)
|
|
MakeRequest(t, req, http.StatusForbidden)
|
|
})
|
|
}
|
|
|
|
// createAPIAccessTokenWithoutCleanUp Create an API access token and assert that
|
|
// creation succeeded. The caller is responsible for deleting the token.
|
|
func createAPIAccessTokenWithoutCleanUp(t *testing.T, tokenName string, user *user_model.User, scopes []auth_model.AccessTokenScope) api.AccessToken {
|
|
payload := map[string]any{
|
|
"name": tokenName,
|
|
"scopes": scopes,
|
|
}
|
|
|
|
log.Debug("Requesting creation of token with scopes: %v", scopes)
|
|
req := NewRequestWithJSON(t, "POST", "/v1/users/"+user.LoginName+"/tokens", payload).
|
|
AddBasicAuth(user.Name)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
|
|
newAccessToken := DecodeJSON(t, resp, &api.AccessToken{})
|
|
unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{
|
|
ID: newAccessToken.ID,
|
|
Name: newAccessToken.Name,
|
|
Token: newAccessToken.Token,
|
|
UID: user.ID,
|
|
})
|
|
|
|
return *newAccessToken
|
|
}
|
|
|
|
// deleteAPIAccessToken deletes an API access token and assert that deletion succeeded.
|
|
func deleteAPIAccessToken(t *testing.T, accessToken api.AccessToken, user *user_model.User) {
|
|
req := NewRequestf(t, "DELETE", "/v1/users/"+user.LoginName+"/tokens/%d", accessToken.ID).
|
|
AddBasicAuth(user.Name)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID})
|
|
}
|