Files
silverwind 1031e34987 test: run integration tests on random ports and speed up slow tests (#39842)
Integration test processes now get their own work directory, config file
and random ports, so several runs can execute in parallel on one
machine. The slowest tests also lose fixed sleeps and needlessly
expensive setup, with their assertions kept.

- Batch PostgreSQL sequence resets, the main per-test fixture cost there
- Cheap hashing params in hash and OAuth2 fixture tests, full-cost
vectors stay
- Single fetch for negative Actions runner assertions instead of 1s
polls
- Queue flushes and backdated timestamps instead of sleeps
- In-memory SQLite test journal, tmpfs for PostgreSQL in CI
- E2E instance writes SSH keys into its work directory instead of
`~/.ssh/authorized_keys`
2026-10-18 12:56:04 +00:00

339 lines
9.8 KiB
Go

// Copyright 2017 The Gitea Authors. 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"
"gitea.dev/models/auth"
"gitea.dev/models/unittest"
"gitea.dev/modules/graceful"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/testlogger"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/modules/web/middleware"
"gitea.dev/routers"
"gitea.dev/routers/common"
gitea_context "gitea.dev/services/context"
"gitea.dev/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()
workPath, 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.GetGiteaTestSourceRoot(), "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 exitCode == 0 {
graceful.GetManager().DoGracefulShutdown() // let background tasks finish writing
<-graceful.GetManager().Done()
if err = os.RemoveAll(workPath); err != nil {
log.Error("Failed to remove work 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(gitea_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)
resp := session.MakeRequest(t, req, http.StatusOK)
return NewHTMLParser(t, resp.Body).Find("#new-access-token-value").Text()
}
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())
}
if expectedStatus != http.StatusInternalServerError {
assert.NotContains(t, recorder.Body.String(), common.PageInternalServerErrorMark, "Request: %s %s, response should not contain internal server error", 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
}