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]>
339 lines
9.8 KiB
Go
339 lines
9.8 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/git/models/auth"
|
|
"github.com/hanzoai/git/models/unittest"
|
|
"github.com/hanzoai/git/modules/graceful"
|
|
"github.com/hanzoai/git/modules/json"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/testlogger"
|
|
"github.com/hanzoai/git/modules/util"
|
|
"github.com/hanzoai/git/modules/web"
|
|
"github.com/hanzoai/git/modules/web/middleware"
|
|
"github.com/hanzoai/git/routers"
|
|
git_context "github.com/hanzoai/git/services/context"
|
|
"github.com/hanzoai/git/tests"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
var testWebRoutes *web.Router
|
|
|
|
func testMain(m *testing.M) int {
|
|
defer log.GetManager().Close()
|
|
|
|
managerCtx, cancel := context.WithCancel(context.Background())
|
|
graceful.InitManager(managerCtx)
|
|
defer cancel()
|
|
|
|
err := tests.InitIntegrationTest()
|
|
if err != nil {
|
|
return testlogger.MainErrorf("InitTest error: %v", err)
|
|
}
|
|
testWebRoutes = routers.NormalRoutes()
|
|
|
|
err = unittest.InitFixtures(
|
|
unittest.FixturesOptions{
|
|
Dir: filepath.Join(setting.GetGitTestSourceRoot(), "models/fixtures/"),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return testlogger.MainErrorf("InitFixtures: %v", err)
|
|
}
|
|
|
|
// FIXME: the console logger is deleted by mistake, so if there is any `log.Fatal`, developers won't see any error message.
|
|
// Instead, "No tests were found", last nonsense log is "According to the configuration, subsequent logs will not be printed to the console"
|
|
exitCode := m.Run()
|
|
|
|
if err = util.RemoveAll(setting.Indexer.IssuePath); err != nil {
|
|
log.Error("Failed to remove indexer path: %v", err)
|
|
}
|
|
if err = util.RemoveAll(setting.Indexer.RepoPath); err != nil {
|
|
log.Error("Failed to remove indexer path: %v", err)
|
|
}
|
|
return exitCode
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
// -test.list must skip InitIntegrationTest, which requires a database.
|
|
flag.Parse()
|
|
if flag.Lookup("test.list").Value.String() != "" {
|
|
os.Exit(m.Run())
|
|
}
|
|
os.Exit(testMain(m))
|
|
}
|
|
|
|
type TestSession struct {
|
|
jar http.CookieJar
|
|
}
|
|
|
|
func (s *TestSession) GetRawCookie(name string) *http.Cookie {
|
|
baseURL, err := url.Parse(setting.AppURL)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for _, c := range s.jar.Cookies(baseURL) {
|
|
if c.Name == name {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *TestSession) GetSiteCookie(name string) string {
|
|
c := s.GetRawCookie(name)
|
|
if c != nil {
|
|
v, _ := url.QueryUnescape(c.Value)
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *TestSession) GetCookieFlashMessage() *middleware.Flash {
|
|
cookie := s.GetSiteCookie(git_context.CookieNameFlash)
|
|
return middleware.ParseCookieFlashMessage(cookie)
|
|
}
|
|
|
|
func (s *TestSession) MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
if s == nil {
|
|
return MakeRequest(t, rw, expectedStatus)
|
|
}
|
|
req := rw.Request
|
|
baseURL, err := url.Parse(setting.AppURL)
|
|
assert.NoError(t, err)
|
|
for _, c := range s.jar.Cookies(baseURL) {
|
|
req.AddCookie(c)
|
|
}
|
|
resp := MakeRequest(t, rw, expectedStatus)
|
|
|
|
ch := http.Header{}
|
|
ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
|
|
cr := http.Request{Header: ch}
|
|
s.jar.SetCookies(baseURL, cr.Cookies())
|
|
|
|
return resp
|
|
}
|
|
|
|
const userPassword = "password"
|
|
|
|
func emptyTestSession(t testing.TB) *TestSession {
|
|
t.Helper()
|
|
jar, err := cookiejar.New(nil)
|
|
assert.NoError(t, err)
|
|
|
|
return &TestSession{jar: jar}
|
|
}
|
|
|
|
func getUserToken(t testing.TB, userName string, scope ...auth.AccessTokenScope) string {
|
|
return getTokenForLoggedInUser(t, loginUser(t, userName), scope...)
|
|
}
|
|
|
|
func loginUser(t testing.TB, userName string) *TestSession {
|
|
t.Helper()
|
|
|
|
return loginUserWithPassword(t, userName, userPassword)
|
|
}
|
|
|
|
func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
|
|
t.Helper()
|
|
req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
|
|
"user_name": userName,
|
|
"password": password,
|
|
})
|
|
resp := MakeRequest(t, req, http.StatusSeeOther)
|
|
|
|
ch := http.Header{}
|
|
ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
|
|
cr := http.Request{Header: ch}
|
|
|
|
session := emptyTestSession(t)
|
|
|
|
baseURL, err := url.Parse(setting.AppURL)
|
|
assert.NoError(t, err)
|
|
session.jar.SetCookies(baseURL, cr.Cookies())
|
|
|
|
return session
|
|
}
|
|
|
|
// token has to be unique this counter take care of
|
|
var tokenCounter atomic.Int64
|
|
|
|
// getTokenForLoggedInUser returns a token for a logged-in user.
|
|
func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string {
|
|
t.Helper()
|
|
urlValues := url.Values{}
|
|
urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", tokenCounter.Add(1)))
|
|
for _, scope := range scopes {
|
|
urlValues.Add("scope-dummy", string(scope)) // it only needs to start with "scope-" to be accepted
|
|
}
|
|
req := NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues)
|
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
|
flashes := session.GetCookieFlashMessage()
|
|
assert.NotNil(t, flashes)
|
|
if flashes != nil {
|
|
return flashes.InfoMsg
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type RequestWrapper struct {
|
|
*http.Request
|
|
}
|
|
|
|
func (req *RequestWrapper) AddBasicAuth(username string, password ...string) *RequestWrapper {
|
|
req.Request.SetBasicAuth(username, util.OptionalArg(password, userPassword))
|
|
return req
|
|
}
|
|
|
|
func (req *RequestWrapper) AddTokenAuth(token string) *RequestWrapper {
|
|
if token == "" {
|
|
return req
|
|
}
|
|
if !strings.HasPrefix(token, "Bearer ") {
|
|
token = "Bearer " + token
|
|
}
|
|
req.Request.Header.Set("Authorization", token)
|
|
return req
|
|
}
|
|
|
|
func (req *RequestWrapper) SetHeader(name, value string) *RequestWrapper {
|
|
req.Request.Header.Set(name, value)
|
|
return req
|
|
}
|
|
|
|
func NewRequest(t testing.TB, method, urlStr string) *RequestWrapper {
|
|
t.Helper()
|
|
return NewRequestWithBody(t, method, urlStr, nil)
|
|
}
|
|
|
|
func NewRequestf(t testing.TB, method, urlFormat string, args ...any) *RequestWrapper {
|
|
t.Helper()
|
|
return NewRequest(t, method, fmt.Sprintf(urlFormat, args...))
|
|
}
|
|
|
|
func NewRequestWithValues(t testing.TB, method, urlStr string, values map[string]string) *RequestWrapper {
|
|
t.Helper()
|
|
urlValues := url.Values{}
|
|
for key, value := range values {
|
|
urlValues[key] = []string{value}
|
|
}
|
|
return NewRequestWithURLValues(t, method, urlStr, urlValues)
|
|
}
|
|
|
|
func NewRequestWithURLValues(t testing.TB, method, urlStr string, urlValues url.Values) *RequestWrapper {
|
|
t.Helper()
|
|
return NewRequestWithBody(t, method, urlStr, strings.NewReader(urlValues.Encode())).
|
|
SetHeader("Content-Type", "application/x-www-form-urlencoded")
|
|
}
|
|
|
|
func NewRequestWithJSON(t testing.TB, method, urlStr string, v any) *RequestWrapper {
|
|
t.Helper()
|
|
|
|
jsonBytes, err := json.Marshal(v)
|
|
assert.NoError(t, err)
|
|
return NewRequestWithBody(t, method, urlStr, bytes.NewBuffer(jsonBytes)).
|
|
SetHeader("Content-Type", "application/json")
|
|
}
|
|
|
|
func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *RequestWrapper {
|
|
t.Helper()
|
|
if !strings.HasPrefix(urlStr, "http:") && !strings.HasPrefix(urlStr, "https:") && !strings.HasPrefix(urlStr, "/") {
|
|
t.Fatalf("invalid url str: %s", urlStr)
|
|
}
|
|
req, err := http.NewRequest(method, urlStr, body)
|
|
assert.NoError(t, err)
|
|
if req.URL.User != nil {
|
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(req.URL.User.String())))
|
|
}
|
|
req.RequestURI = req.URL.Path
|
|
if req.URL.RawQuery != "" {
|
|
req.RequestURI += "?" + req.URL.RawQuery
|
|
}
|
|
return &RequestWrapper{req}
|
|
}
|
|
|
|
const NoExpectedStatus = 0
|
|
|
|
func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := rw.Request
|
|
recorder := httptest.NewRecorder()
|
|
if req.RemoteAddr == "" {
|
|
req.RemoteAddr = "test-mock:12345"
|
|
}
|
|
// Ensure unknown contentLength is seen as -1
|
|
if req.Body != nil && req.ContentLength == 0 {
|
|
req.ContentLength = -1
|
|
}
|
|
testWebRoutes.ServeHTTP(recorder, req)
|
|
if expectedStatus != NoExpectedStatus {
|
|
if expectedStatus != recorder.Code {
|
|
logUnexpectedResponse(t, recorder)
|
|
// don't use "require" which exits the test case and makes "wait group" wait forever
|
|
assert.Equal(t, expectedStatus, recorder.Code, "Request: %s %s", req.Method, req.URL.String())
|
|
}
|
|
}
|
|
return recorder
|
|
}
|
|
|
|
// logUnexpectedResponse logs the contents of an unexpected response.
|
|
func logUnexpectedResponse(t testing.TB, recorder *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
respBytes := recorder.Body.Bytes()
|
|
if len(respBytes) == 0 {
|
|
return
|
|
} else if len(respBytes) < 500 {
|
|
// if body is short, just log the whole thing
|
|
t.Log("Response: ", string(respBytes))
|
|
return
|
|
}
|
|
t.Log("Response length: ", len(respBytes))
|
|
|
|
// log the "flash" error message, if one exists
|
|
// we must create a new buffer, so that we don't "use up" resp.Body
|
|
htmlDoc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(respBytes))
|
|
if err != nil {
|
|
return // probably a non-HTML response
|
|
}
|
|
errMsg := htmlDoc.Find(".ui.negative.message:not(.tw-hidden)").Text()
|
|
if len(errMsg) > 0 {
|
|
t.Log("A flash error message was found:", errMsg)
|
|
}
|
|
}
|
|
|
|
// DecodeJSON decodes then response as JSON into typed variable and return it
|
|
// HINT: don't use it on existing variable (reuse existing variable):
|
|
// if the existing var already contains some values but the new input doesn't, then it leads to wrong test result in edge cases.
|
|
// For slice decoding, use: v := DecodeJSON(t, resp, []T{})
|
|
func DecodeJSON[T any](t testing.TB, resp *httptest.ResponseRecorder, v T) (ret T) {
|
|
t.Helper()
|
|
|
|
// FIXME: JSON-KEY-CASE: for testing purpose only, because many structs don't provide `json` tags, they just use capitalized field names
|
|
decoder := json.NewDecoderCaseInsensitive(resp.Body)
|
|
// don't use "require" which exits the test case and makes "wait group" wait forever
|
|
assert.NoError(t, decoder.Decode(&v))
|
|
return v
|
|
}
|