Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions models/unittest/mock_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package unittest

import (
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"os"
"slices"
"strings"
"testing"

"code.gitea.io/gitea/modules/log"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// MockServerOptions configures optional behavior for NewMockWebServer.
type MockServerOptions struct {
// ExtraRoutes registers additional handlers on the server's ServeMux before the
// default fixture handler. More specific patterns take precedence over the catch-all.
ExtraRoutes func(mux *http.ServeMux)
// LivePathTrimPrefix removes a path prefix before forwarding to the live server.
// This is needed when the client adds a prefix (e.g. go-github adds "/api/v3")
// that is not part of the live server's URL structure.
LivePathTrimPrefix string
}

// NewMockWebServer creates a mock HTTP server that either records responses from a live
// service or replays previously recorded responses from fixture files.
//
// - liveMode=true: proxies requests to liveServerBaseURL and saves responses as fixture files
// - liveMode=false: serves responses from previously saved fixture files
//
// Fixture files use a simple format: HTTP headers (one per line), an empty line, then the
// response body. The liveServerBaseURL in responses is replaced with the mock server URL.
//
// The convention for activating live mode is to check for an environment variable containing
// an API token for the service, e.g.:
//
// token := os.Getenv("GITEA_TOKEN")
// server := NewMockWebServer(t, "https://gitea.com", fixturePath, token != "")
func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveMode bool, opts ...MockServerOptions) *httptest.Server {
t.Helper()
mockServerBaseURL := ""
ignoredHeaders := []string{"cf-ray", "server", "date", "report-to", "nel", "x-request-id", "set-cookie"}

var options MockServerOptions
if len(opts) > 0 {
options = opts[0]
}

fixtureHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := NormalizedFullPath(r.URL)
log.Info("Mock HTTP Server: %s %s", r.Method, path)

fixturePath := fmt.Sprintf("%s/%s_%s", testDataDir, r.Method, url.QueryEscape(path))
if strings.Contains(r.URL.Path, ".git/") {
fixturePath = fmt.Sprintf("%s/%s_%s", testDataDir, r.Method, url.QueryEscape(r.URL.Path))
}

if liveMode {
require.NoError(t, os.MkdirAll(testDataDir, 0o755))

liveURL := fmt.Sprintf("%s%s", liveServerBaseURL, strings.TrimPrefix(path, options.LivePathTrimPrefix))
request, err := http.NewRequest(r.Method, liveURL, r.Body)
require.NoError(t, err, "constructing an HTTP request to %s failed", liveURL)
for headerName, headerValues := range r.Header {
if !strings.EqualFold(headerName, "accept-encoding") {
for _, headerValue := range headerValues {
request.Header.Add(headerName, headerValue)
}
}
}

response, err := http.DefaultClient.Do(request)
require.NoError(t, err, "HTTP request to %s failed", liveURL)
defer response.Body.Close()
assert.Less(t, response.StatusCode, 400, "unexpected status code for %s", liveURL)

fixture, err := os.Create(fixturePath)
require.NoError(t, err, "failed to open the fixture file %s for writing", fixturePath)
defer fixture.Close()

for _, headerName := range slices.Sorted(maps.Keys(response.Header)) {
for _, headerValue := range response.Header[headerName] {
if !slices.Contains(ignoredHeaders, strings.ToLower(headerName)) {
_, err := fmt.Fprintf(fixture, "%s: %s\n", headerName, headerValue)
require.NoError(t, err)
}
}
}
_, err = fixture.WriteString("\n")
require.NoError(t, err)

_, err = io.Copy(fixture, response.Body)
require.NoError(t, err, "writing response body for %s failed", liveURL)

require.NoError(t, fixture.Sync())
}

fixture, err := os.ReadFile(fixturePath)
require.NoError(t, err, "missing fixture: %s", fixturePath)

stringFixture := strings.ReplaceAll(string(fixture), liveServerBaseURL, mockServerBaseURL)

headerSection, responseBody, _ := strings.Cut(stringFixture, "\n\n")
for line := range strings.SplitSeq(headerSection, "\n") {
if header, value, ok := strings.Cut(line, ": "); ok {
if !strings.EqualFold(header, "Content-Length") {
w.Header().Set(header, value)
}
}
}
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(responseBody))
require.NoError(t, err)
})

mux := http.NewServeMux()
if options.ExtraRoutes != nil {
options.ExtraRoutes(mux)
}
mux.Handle("/", fixtureHandler)

server := httptest.NewServer(mux)
mockServerBaseURL = server.URL
t.Cleanup(server.Close)
return server
}

// NormalizedFullPath returns the URL path including query parameters.
func NormalizedFullPath(u *url.URL) string {
if u.RawQuery == "" {
return u.EscapedPath()
}
return u.EscapedPath() + "?" + u.RawQuery
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<ticketing-milestone type="array">
<ticketing-milestone>
<id type="integer">1</id>
<identifier>milestone1</identifier>
<name>Milestone1</name>
<deadline type="date">2021-09-16</deadline>
<description></description>
<status>active</status>
</ticketing-milestone>
<ticketing-milestone>
<id type="integer">2</id>
<identifier>milestone2</identifier>
<name>Milestone2</name>
<deadline type="date">2021-09-17</deadline>
<description></description>
<status>closed</status>
</ticketing-milestone>
</ticketing-milestone>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<repository>
<name>test</name>
<description>Repository Description</description>
<permalink>test</permalink>
<clone-url>git@codebasehq.com:gitea-test/gitea-test/test.git</clone-url>
<source></source>
</repository>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<commits type="array">
<commit>
<ref>f32b0a9dfd09a60f616f29158f772cedd89942d2</ref>
</commit>
</commits>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<commits type="array">
<commit>
<ref>1287f206b888d4d13540e0a8e1c07458f5420059</ref>
</commit>
</commits>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<merge-request>
<id type="integer">100</id>
<source-ref>readme-mr</source-ref>
<target-ref>master</target-ref>
<subject>Readme Change</subject>
<status>new</status>
<user-id type="integer">43</user-id>
<created-at type="datetime">2021-09-26T20:25:47+00:00</created-at>
<updated-at type="datetime">2021-09-26T20:25:47+00:00</updated-at>
<comments type="array">
<comment>
<content>Merge Request comment</content>
<id type="integer">300</id>
<user-id type="integer">43</user-id>
<action nil="true"></action>
<created-at type="datetime">2021-09-26T20:25:47+00:00</created-at>
</comment>
</comments>
</merge-request>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<merge-requests type="array">
<merge-request>
<id type="integer">100</id>
</merge-request>
</merge-requests>
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<tickets type="array">
<ticket>
<ticket-id type="integer">2</ticket-id>
<summary>Open Ticket</summary>
<ticket-type>Feature</ticket-type>
<reporter-id type="integer">43</reporter-id>
<reporter>gitea-test-43</reporter>
<type>
<name>Feature</name>
</type>
<status>
<treat-as-closed type="boolean">false</treat-as-closed>
</status>
<milestone>
<name></name>
</milestone>
<updated-at type="datetime">2021-09-26T19:19:34+00:00</updated-at>
<created-at type="datetime">2021-09-26T19:19:14+00:00</created-at>
</ticket>
<ticket>
<ticket-id type="integer">1</ticket-id>
<summary>Closed Ticket</summary>
<ticket-type>Bug</ticket-type>
<reporter-id type="integer">43</reporter-id>
<reporter>gitea-test-43</reporter>
<type>
<name>Bug</name>
</type>
<status>
<treat-as-closed type="boolean">true</treat-as-closed>
</status>
<milestone>
<name>Milestone1</name>
</milestone>
<updated-at type="datetime">2021-09-26T19:18:55+00:00</updated-at>
<created-at type="datetime">2021-09-26T19:18:33+00:00</created-at>
</ticket>
</tickets>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<ticket-notes type="array">
<ticket-note>
<content>Closed Ticket Message</content>
<created-at type="datetime">2021-09-26T19:18:33+00:00</created-at>
<updated-at type="datetime">2021-09-26T19:18:33+00:00</updated-at>
<id type="integer">200</id>
<user-id type="integer">43</user-id>
</ticket-note>
</ticket-notes>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<ticket-notes type="array">
<ticket-note>
<content>Open Ticket Message</content>
<created-at type="datetime">2021-09-26T19:19:14+00:00</created-at>
<updated-at type="datetime">2021-09-26T19:19:14+00:00</updated-at>
<id type="integer">100</id>
<user-id type="integer">43</user-id>
</ticket-note>
<ticket-note>
<content>open comment</content>
<created-at type="datetime">2021-09-26T19:19:34+00:00</created-at>
<updated-at type="datetime">2021-09-26T19:19:34+00:00</updated-at>
<id type="integer">101</id>
<user-id type="integer">43</user-id>
</ticket-note>
</ticket-notes>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<ticketing-types type="array">
<ticketing-type>
<id type="integer">1</id>
<name>Bug</name>
</ticketing-type>
<ticketing-type>
<id type="integer">2</id>
<name>Feature</name>
</ticketing-type>
<ticketing-type>
<id type="integer">3</id>
<name>Enhancement</name>
</ticketing-type>
<ticketing-type>
<id type="integer">4</id>
<name>Task</name>
</ticketing-type>
</ticketing-types>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Content-Type: application/xml; charset=utf-8

<?xml version="1.0" encoding="UTF-8"?>
<users type="array">
<user>
<email-address>gitea-codebase@smack.email</email-address>
<id type="integer">43</id>
<last-name>Test</last-name>
<first-name>Gitea</first-name>
<username>gitea-test-43</username>
</user>
</users>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
Cache-Control: no-cache
Content-Security-Policy: default-src 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
Vary: Accept-Encoding, Accept, X-Requested-With
X-Accepted-Github-Permissions: allows_permissionless_access=true
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-Github-Api-Version-Selected: 2022-11-28
X-Github-Media-Type: github.v3; format=json
X-Github-Request-Id: C4F6:A93E1:6A2E9E:5B1BA1:69AF24E6
X-Ratelimit-Limit: 5000
X-Ratelimit-Remaining: 4937
X-Ratelimit-Reset: 1773089427
X-Ratelimit-Resource: core
X-Ratelimit-Used: 63
X-Xss-Protection: 0

{"resources":{"core":{"limit":5000,"used":63,"remaining":4937,"reset":1773089427},"search":{"limit":30,"used":0,"remaining":30,"reset":1773085986},"graphql":{"limit":5000,"used":40,"remaining":4960,"reset":1773087278},"integration_manifest":{"limit":5000,"used":0,"remaining":5000,"reset":1773089526},"source_import":{"limit":100,"used":0,"remaining":100,"reset":1773085986},"code_scanning_upload":{"limit":5000,"used":63,"remaining":4937,"reset":1773089427},"code_scanning_autofix":{"limit":10,"used":0,"remaining":10,"reset":1773085986},"actions_runner_registration":{"limit":10000,"used":0,"remaining":10000,"reset":1773089526},"scim":{"limit":15000,"used":0,"remaining":15000,"reset":1773089526},"dependency_snapshots":{"limit":100,"used":0,"remaining":100,"reset":1773085986},"dependency_sbom":{"limit":100,"used":0,"remaining":100,"reset":1773085986},"audit_log":{"limit":1750,"used":0,"remaining":1750,"reset":1773089526},"audit_log_streaming":{"limit":15,"used":0,"remaining":15,"reset":1773089526},"code_search":{"limit":10,"used":0,"remaining":10,"reset":1773085986}},"rate":{"limit":5000,"used":63,"remaining":4937,"reset":1773089427}}
Loading
Loading