Files
mopac-gitea-go/internal/fakegitea/fakegitea.go
T

716 lines
22 KiB
Go

// Package fakegitea is a stateful in-memory Gitea API fake used by the
// test suite and the smoke run. The real forge is NEVER contacted. It
// implements exactly the endpoints the gitea client uses, enforces
// "Authorization: token <key>" auth on every one of them, and records
// every request so tests can assert round-trips. Error responses
// deliberately echo the presented token back in the body: any test that
// survives that proves the client never surfaces response bodies
// (token-redaction guarantee).
package fakegitea
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"sync"
)
// Request is one recorded exchange (auth checked, body captured).
type Request struct {
Method string
Path string // path without query
Query string // raw query
Body string
}
// User is the account shape.
type User struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
}
// Repo is the stored repository (server side).
type Repo struct {
ID int64 `json:"id"`
Owner User `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description"`
Private bool `json:"private"`
DefaultBranch string `json:"default_branch"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
}
// Branch is the branch shape (Commit carries the tip sha).
type Branch struct {
Name string `json:"name"`
Commit CommitInfo `json:"commit"`
Protected bool `json:"protected"`
}
// CommitInfo is the commit projection branches carry.
type CommitInfo struct {
ID string `json:"id"`
Message string `json:"message"`
}
// StatusRecord is one stored commit status (append-only per sha).
type StatusRecord struct {
ID int64 `json:"id"`
State string `json:"state"`
Context string `json:"context"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// PullRequest is the stored PR.
type PullRequest struct {
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"` // open|closed
User User `json:"user"`
Base PRBranch `json:"base"`
Head PRBranch `json:"head"`
Mergeable bool `json:"mergeable"`
Merged bool `json:"merged"`
MergedAt string `json:"merged_at"`
HTMLURL string `json:"html_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// PRBranch is one end of a pull request.
type PRBranch struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
Repo Repo `json:"repo"`
}
type repoState struct {
repo Repo
branches map[string]string // name -> tip sha
order []string // insertion order of branch names
statuses map[string][]StatusRecord
prs map[int64]*PullRequest
nextPR int64
}
// Server is the fake Gitea.
type Server struct {
URL string
Login string // the account the token authenticates as
Token string
mu sync.Mutex
srv *httptest.Server
requests []Request
repos map[string]*repoState // "owner/name" -> state
nextRepo int64
nextStat int64
// Fail, when non-nil, is returned instead of normal handling; it is
// consumed by the first request that sees it.
Fail *FailSpec
}
// FailSpec pins the next response (error-mapping tests).
type FailSpec struct {
Status int
Body string // may contain one %s: the presented token
}
// New starts a fake on a random port authenticating as login.
func New(token, login string) *Server {
s := newServer(token, login)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handler)
s.srv = httptest.NewServer(mux)
s.URL = s.srv.URL
return s
}
// ListenAndServe runs the fake on a fixed address (the smoke run boots it
// in a container). An empty token disables auth checking.
func ListenAndServe(addr, token string) error {
s := newServer(token, "ukrrs")
mux := http.NewServeMux()
mux.HandleFunc("/", s.handler)
return http.ListenAndServe(addr, mux)
}
func newServer(token, login string) *Server {
return &Server{
Token: token,
Login: login,
repos: map[string]*repoState{},
nextRepo: 1000,
nextStat: 5000,
}
}
// Close shuts the fake down.
func (s *Server) Close() { s.srv.Close() }
// Requests returns a copy of the recorded exchanges.
func (s *Server) Requests() []Request {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Request, len(s.requests))
copy(out, s.requests)
return out
}
// AddRepo seeds a repository directly (bypassing REST) with the given
// branches (first is the default when defaultBranch is empty) and returns
// its "owner/name" key.
func (s *Server) AddRepo(owner, name, defaultBranch string, branches ...string) string {
s.mu.Lock()
defer s.mu.Unlock()
return s.addRepoLocked(owner, name, defaultBranch, branches)
}
func (s *Server) addRepoLocked(owner, name, defaultBranch string, branches []string) string {
if defaultBranch == "" && len(branches) > 0 {
defaultBranch = branches[0]
}
s.nextRepo++
st := &repoState{
repo: Repo{
ID: s.nextRepo,
Owner: User{ID: 10, Login: owner, FullName: owner},
Name: name,
FullName: owner + "/" + name,
DefaultBranch: defaultBranch,
HTMLURL: "https://git.example/" + owner + "/" + name,
CloneURL: "https://git.example/" + owner + "/" + name + ".git",
},
branches: map[string]string{},
statuses: map[string][]StatusRecord{},
prs: map[int64]*PullRequest{},
}
for _, b := range branches {
st.addBranch(b)
}
key := owner + "/" + name
s.repos[key] = st
return key
}
func (st *repoState) addBranch(name string) string {
// Deterministic distinct 40-hex shas (fake but well-formed).
sha := fmt.Sprintf("%040x", len(st.order)+1)
if _, ok := st.branches[name]; !ok {
st.order = append(st.order, name)
}
st.branches[name] = sha
return sha
}
// AddBranch seeds a branch directly and returns its (new) tip sha.
func (s *Server) AddBranch(owner, name, branch string) string {
s.mu.Lock()
defer s.mu.Unlock()
st := s.repos[owner+"/"+name]
return st.addBranch(branch)
}
// Repo returns a copy of a stored repo (for assertions).
func (s *Server) Repo(owner, name string) (Repo, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.repos[owner+"/"+name]
if !ok {
return Repo{}, false
}
return st.repo, true
}
// PullRequest returns a copy of a stored PR (for assertions).
func (s *Server) PullRequest(owner, name string, number int64) (PullRequest, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.repos[owner+"/"+name]
if !ok {
return PullRequest{}, false
}
pr, ok := st.prs[number]
if !ok {
return PullRequest{}, false
}
return *pr, true
}
// Statuses returns the raw status history of one sha (for assertions).
func (s *Server) Statuses(owner, name, sha string) []StatusRecord {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.repos[owner+"/"+name]
if !ok {
return nil
}
out := make([]StatusRecord, len(st.statuses[sha]))
copy(out, st.statuses[sha])
return out
}
func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
token := ""
if s.Token != "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "token ") {
token = strings.TrimPrefix(auth, "token ")
}
}
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
s.mu.Lock()
if s.Fail != nil {
spec := *s.Fail
s.Fail = nil
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
s.mu.Unlock()
// The body deliberately embeds the presented token: surviving
// this proves the client drops response bodies from errors.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(spec.Status)
fmt.Fprintf(w, spec.Body, token)
return
}
if s.Token != "" && token != s.Token {
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, ""})
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"message":"bad credentials: token %s was rejected"}`, token)
return
}
s.requests = append(s.requests, Request{r.Method, r.URL.Path, r.URL.RawQuery, string(body)})
var (
respStatus = http.StatusOK
respBody string
)
path := r.URL.Path
q := r.URL.Query()
switch {
case r.Method == http.MethodGet && path == "/api/v1/user/repos":
respStatus, respBody = s.listRepos(s.Login)
case r.Method == http.MethodPost && path == "/api/v1/user/repos":
respStatus, respBody = s.createRepo(s.Login, body)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/api/v1/users/") && strings.HasSuffix(path, "/repos"):
owner := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/users/"), "/repos")
respStatus, respBody = s.listRepos(owner)
case r.Method == http.MethodPost && strings.HasPrefix(path, "/api/v1/orgs/") && strings.HasSuffix(path, "/repos"):
owner := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/orgs/"), "/repos")
respStatus, respBody = s.createRepo(owner, body)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/api/v1/repos/") && strings.Count(path, "/") == 5:
owner, name := splitRepo(strings.TrimPrefix(path, "/api/v1/repos/"))
respStatus, respBody = s.getRepo(owner, name)
case r.Method == http.MethodGet && strings.HasSuffix(path, "/branches") && strings.HasPrefix(path, "/api/v1/repos/"):
owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/branches"))
respStatus, respBody = s.listBranches(owner, name)
case r.Method == http.MethodPost && strings.Contains(path, "/statuses/") && strings.HasPrefix(path, "/api/v1/repos/"):
rest := strings.TrimPrefix(path, "/api/v1/repos/")
parts := strings.SplitN(rest, "/statuses/", 2)
owner, name := splitRepo(parts[0])
respStatus, respBody = s.createStatus(owner, name, parts[1], body)
case r.Method == http.MethodGet && strings.HasSuffix(path, "/statuses") && strings.HasPrefix(path, "/api/v1/repos/"):
rest := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/statuses")
parts := strings.SplitN(rest, "/commits/", 2)
owner, name := splitRepo(parts[0])
respStatus, respBody = s.listStatuses(owner, name, parts[1])
case r.Method == http.MethodGet && strings.HasSuffix(path, "/status") && strings.HasPrefix(path, "/api/v1/repos/"):
rest := strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/status")
parts := strings.SplitN(rest, "/commits/", 2)
owner, name := splitRepo(parts[0])
respStatus, respBody = s.combinedStatus(owner, name, parts[1])
case r.Method == http.MethodGet && strings.HasSuffix(path, "/pulls") && strings.HasPrefix(path, "/api/v1/repos/"):
owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/pulls"))
respStatus, respBody = s.listPulls(owner, name, q.Get("state"))
case r.Method == http.MethodPost && strings.HasSuffix(path, "/pulls") && strings.HasPrefix(path, "/api/v1/repos/"):
owner, name := splitRepo(strings.TrimSuffix(strings.TrimPrefix(path, "/api/v1/repos/"), "/pulls"))
respStatus, respBody = s.createPull(owner, name, body)
case r.Method == http.MethodPost && strings.Contains(path, "/pulls/") && strings.HasSuffix(path, "/merge") && strings.HasPrefix(path, "/api/v1/repos/"):
rest := strings.TrimPrefix(path, "/api/v1/repos/")
parts := strings.SplitN(rest, "/pulls/", 2)
owner, name := splitRepo(parts[0])
num, _ := strconv.ParseInt(strings.TrimSuffix(parts[1], "/merge"), 10, 64)
respStatus, respBody = s.mergePull(owner, name, num, body)
case r.Method == http.MethodGet && strings.Contains(path, "/pulls/") && strings.HasPrefix(path, "/api/v1/repos/"):
rest := strings.TrimPrefix(path, "/api/v1/repos/")
parts := strings.SplitN(rest, "/pulls/", 2)
owner, name := splitRepo(parts[0])
num, _ := strconv.ParseInt(parts[1], 10, 64)
respStatus, respBody = s.getPull(owner, name, num)
default:
respStatus, respBody = http.StatusNotFound, `{"message":"Not Found"}`
}
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(respStatus)
if respBody != "" {
io.WriteString(w, respBody)
}
}
func splitRepo(s string) (owner, name string) {
parts := strings.SplitN(s, "/", 2)
if len(parts) != 2 {
return "", ""
}
return parts[0], parts[1]
}
func jsonReply(v any) (int, string) {
b, err := json.Marshal(v)
if err != nil {
return http.StatusInternalServerError, `{"message":"marshal"}`
}
return http.StatusOK, string(b)
}
// --- repos -----------------------------------------------------------------
func (s *Server) listRepos(owner string) (int, string) {
var out []Repo
for _, key := range s.sortedRepoKeys() {
st := s.repos[key]
if st.repo.Owner.Login == owner {
out = append(out, st.repo)
}
}
if out == nil {
out = []Repo{}
}
return jsonReply(out)
}
func (s *Server) sortedRepoKeys() []string {
keys := make([]string, 0, len(s.repos))
for k := range s.repos {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func (s *Server) getRepo(owner, name string) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
return jsonReply(st.repo)
}
func (s *Server) createRepo(owner string, body []byte) (int, string) {
var p struct {
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
AutoInit bool `json:"auto_init"`
DefaultBranch string `json:"default_branch"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"message":"bad json"}`
}
if p.Name == "" {
return http.StatusUnprocessableEntity, `{"message":"name is required"}`
}
if _, exists := s.repos[owner+"/"+p.Name]; exists {
return http.StatusConflict, `{"message":"The repository with the same name already exists."}`
}
branches := []string(nil)
if p.AutoInit {
branches = []string{"main"}
}
key := s.addRepoLocked(owner, p.Name, p.DefaultBranch, branches)
st := s.repos[key]
st.repo.Description = p.Description
st.repo.Private = p.Private
return http.StatusCreated, mustJSON(st.repo)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}
// --- branches ---------------------------------------------------------------
func (s *Server) listBranches(owner, name string) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
out := make([]Branch, 0, len(st.order))
for _, b := range st.order {
sha := st.branches[b]
out = append(out, Branch{Name: b, Commit: CommitInfo{ID: sha, Message: "fake commit " + sha[:7]}})
}
return jsonReply(out)
}
// resolveRef maps a ref (full sha, sha prefix, or branch name) to a commit
// sha; ok=false when nothing matches.
func (st *repoState) resolveRef(ref string) (string, bool) {
if sha, ok := st.branches[ref]; ok {
return sha, true
}
for _, sha := range st.branches {
if strings.HasPrefix(sha, ref) && len(ref) >= 7 {
return sha, true
}
}
return "", false
}
// --- statuses ---------------------------------------------------------------
var validStates = map[string]bool{"pending": true, "success": true, "error": true, "failure": true}
func (s *Server) createStatus(owner, name, sha string, body []byte) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
full, ok := st.resolveRef(sha)
if !ok {
return http.StatusNotFound, fmt.Sprintf(`{"message":"commit %s not found"}`, sha)
}
var p StatusRecord
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"message":"bad json"}`
}
if !validStates[p.State] {
return http.StatusUnprocessableEntity, `{"message":"invalid state"}`
}
if p.Context == "" {
p.Context = "default"
}
s.nextStat++
p.ID = s.nextStat
p.CreatedAt = "2026-08-29T16:00:00Z"
p.UpdatedAt = p.CreatedAt
st.statuses[full] = append(st.statuses[full], p)
return http.StatusCreated, mustJSON(p)
}
// latestPerContext folds the append-only history into the newest status
// per context (Gitea's reported shape), in insertion order.
func latestPerContext(history []StatusRecord) []StatusRecord {
idx := map[string]int{}
var out []StatusRecord
for _, sr := range history {
if i, ok := idx[sr.Context]; ok {
out[i] = sr
} else {
idx[sr.Context] = len(out)
out = append(out, sr)
}
}
return out
}
func (s *Server) listStatuses(owner, name, ref string) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
full, ok := st.resolveRef(ref)
if !ok {
return http.StatusNotFound, `{"message":"commit not found"}`
}
out := latestPerContext(st.statuses[full])
if out == nil {
out = []StatusRecord{}
}
return jsonReply(out)
}
func worstState(statuses []StatusRecord) string {
rank := map[string]int{"success": 0, "pending": 1, "failure": 2, "error": 3}
worst, seen := "", false
for _, sr := range statuses {
if !seen || rank[sr.State] > rank[worst] {
worst, seen = sr.State, true
}
}
return worst
}
func (s *Server) combinedStatus(owner, name, ref string) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
full, ok := st.resolveRef(ref)
if !ok {
return http.StatusNotFound, `{"message":"commit not found"}`
}
latest := latestPerContext(st.statuses[full])
if len(latest) == 0 {
return http.StatusNotFound, `{"message":"no statuses for this commit"}`
}
out := struct {
State string `json:"state"`
SHA string `json:"sha"`
TotalCount int `json:"total_count"`
Statuses []StatusRecord `json:"statuses"`
}{worstState(latest), full, len(latest), latest}
return jsonReply(out)
}
// --- pull requests ----------------------------------------------------------
func (s *Server) prBranch(st *repoState, ref string) (PRBranch, bool) {
branch := ref
if i := strings.IndexByte(ref, ':'); i >= 0 { // fork syntax owner:branch
branch = ref[i+1:]
}
sha, ok := st.branches[branch]
if !ok {
return PRBranch{}, false
}
return PRBranch{Ref: branch, SHA: sha, Repo: st.repo}, true
}
func (s *Server) createPull(owner, name string, body []byte) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
var p struct {
Title string `json:"title"`
Body string `json:"body"`
Base string `json:"base"`
Head string `json:"head"`
}
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"message":"bad json"}`
}
if p.Title == "" {
return http.StatusUnprocessableEntity, `{"message":"title is required"}`
}
base, ok := s.prBranch(st, p.Base)
if !ok {
return http.StatusUnprocessableEntity, fmt.Sprintf(`{"message":"base branch %s does not exist"}`, p.Base)
}
head, ok := s.prBranch(st, p.Head)
if !ok {
return http.StatusUnprocessableEntity, fmt.Sprintf(`{"message":"head branch %s does not exist"}`, p.Head)
}
if base.Ref == head.Ref {
return http.StatusUnprocessableEntity, `{"message":"head and base cannot be the same"}`
}
for _, pr := range st.prs {
if pr.State == "open" && pr.Base.Ref == base.Ref && pr.Head.Ref == head.Ref {
return http.StatusConflict, `{"message":"The pull request has already been created."}`
}
}
st.nextPR++
now := "2026-08-29T16:00:00Z"
pr := &PullRequest{
Number: st.nextPR,
Title: p.Title,
Body: p.Body,
State: "open",
User: User{ID: 10, Login: s.Login},
Base: base,
Head: head,
Mergeable: true,
HTMLURL: st.repo.HTMLURL + "/pulls/" + strconv.FormatInt(st.nextPR, 10),
CreatedAt: now,
UpdatedAt: now,
}
st.prs[pr.Number] = pr
return http.StatusCreated, mustJSON(pr)
}
func (s *Server) listPulls(owner, name, state string) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
if state == "" {
state = "open" // Gitea default
}
var nums []int64
for n := range st.prs {
nums = append(nums, n)
}
sort.Slice(nums, func(i, j int) bool { return nums[i] < nums[j] })
out := []PullRequest{}
for _, n := range nums {
pr := st.prs[n]
if state == "all" || pr.State == state {
out = append(out, *pr)
}
}
return jsonReply(out)
}
func (s *Server) getPull(owner, name string, num int64) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
pr, ok := st.prs[num]
if !ok {
return http.StatusNotFound, `{"message":"The pull request couldn't be found."}`
}
return jsonReply(pr)
}
var mergeStyles = map[string]bool{"merge": true, "rebase": true, "rebase-merge": true, "squash": true, "fast-forward": true}
func (s *Server) mergePull(owner, name string, num int64, body []byte) (int, string) {
st, ok := s.repos[owner+"/"+name]
if !ok {
return http.StatusNotFound, `{"message":"The target couldn't be found."}`
}
pr, ok := st.prs[num]
if !ok {
return http.StatusNotFound, `{"message":"The pull request couldn't be found."}`
}
var p struct {
Do string `json:"Do"`
Title string `json:"MergeTitleField"`
Message string `json:"MergeMessageField"`
}
if len(body) > 0 {
if err := json.Unmarshal(body, &p); err != nil {
return http.StatusBadRequest, `{"message":"bad json"}`
}
}
if p.Do == "" {
p.Do = "merge"
}
if !mergeStyles[p.Do] {
return http.StatusUnprocessableEntity, `{"message":"invalid merge style"}`
}
if pr.State != "open" || pr.Merged {
return http.StatusConflict, `{"message":"The pull request has already been merged or closed."}`
}
pr.Merged = true
pr.State = "closed"
pr.Mergeable = false
pr.MergedAt = "2026-08-29T16:05:00Z"
pr.UpdatedAt = pr.MergedAt
// The head lands on base: base now points at the head sha.
st.branches[pr.Base.Ref] = pr.Head.SHA
return http.StatusOK, `{"message":"pull request has been merged"}`
}