package main import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "sort" "strings" "sync" "testing" "gitea/internal/forgehttp" ) // The fake forge of issue 1124 / ADR 0428: it keeps repositories by owner/name with their branch protection rules, // their branches and their files, records every call in order, and refuses a call a test names. Like the forge, it // refuses a commit onto a branch whose rule does not let the caller push, and it answers only the setup login. const ( admin = "mesh-admin" tools = "mesh-tools" org = "novox" ) type fRepo struct { Private bool Description string Rules map[string]map[string]any // by rule_name Branches map[string]bool Files map[string]string Commits int } type fakeForge struct { mu sync.Mutex repos map[string]*fRepo // "owner/name" calls []string // "METHOD /path" // refuse answers a call with a status and a message when it returns a status above zero. refuse func(method, path string) (int, string) } func newFake() *fakeForge { return &fakeForge{repos: map[string]*fRepo{}} } func (f *fakeForge) add(full string, rules map[string]map[string]any, branches ...string) *fRepo { r := &fRepo{Rules: map[string]map[string]any{}, Branches: map[string]bool{}, Files: map[string]string{}} for k, v := range rules { r.Rules[k] = v } for _, b := range branches { r.Branches[b] = true } f.repos[full] = r return r } // writes are the calls that change something, in order. func (f *fakeForge) writes() []string { f.mu.Lock() defer f.mu.Unlock() var out []string for _, c := range f.calls { if !strings.HasPrefix(c, "GET ") { out = append(out, c) } } return out } func (f *fakeForge) all() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.calls...) } var ( repoPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)$`) rulesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branch_protections(?:/([^/]+))?$`) branchPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branches/([^/]+)$`) filesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/contents$`) movePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/transfer$`) ) func strs(v any) []string { var out []string if l, ok := v.([]any); ok { for _, s := range l { out = append(out, fmt.Sprint(s)) } } if l, ok := v.([]string); ok { out = append(out, l...) } return out } // lets says whether a rule lets the login push. func lets(rule map[string]any, login string) bool { if rule == nil { return true } if rule["enable_push"] != true { return false } if rule["enable_push_whitelist"] != true { return true } for _, u := range strs(rule["push_whitelist_usernames"]) { if u == login { return true } } return false } // reaches says whether a login has access to a repository, as the forge judges a merge whitelist: its owner does, // and in the organisation the tools' team does (it is on every repository there); nobody else here. func reaches(full, login string) bool { owner := strings.SplitN(full, "/", 2)[0] return owner == login || (owner == org && login == tools) } // keepReaching drops from a rule's merge whitelist every user without access to the repository, as gitea's // updateUserWhitelist does on creating or editing a branch protection. func keepReaching(full string, rule map[string]any) { if _, ok := rule["merge_whitelist_usernames"]; !ok { return } kept := []any{} for _, u := range strs(rule["merge_whitelist_usernames"]) { if reaches(full, u) { kept = append(kept, u) } } rule["merge_whitelist_usernames"] = kept } func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if u, p, ok := r.BasicAuth(); !ok || u != admin || p != "pw" { http.Error(w, "no", http.StatusUnauthorized) return } reply := func(status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if v != nil { _ = json.NewEncoder(w).Encode(v) } } p := r.URL.Path f.calls = append(f.calls, r.Method+" "+p) if f.refuse != nil { if status, said := f.refuse(r.Method, p); status > 0 { reply(status, map[string]string{"message": said}) return } } var in map[string]any _ = json.NewDecoder(r.Body).Decode(&in) switch { case p == "/api/v1/version": reply(200, map[string]string{"version": "1.27"}) case p == "/api/v1/user/repos" && r.Method == http.MethodPost: name := fmt.Sprint(in["name"]) if f.repos[admin+"/"+name] != nil { reply(409, map[string]string{"message": "The repository with the same name already exists."}) return } if in["auto_init"] == true { reply(400, map[string]string{"message": "this fake takes only an empty repository"}) return } repo := f.add(admin+"/"+name, nil) repo.Private, _ = in["private"].(bool) repo.Description, _ = in["description"].(string) reply(201, map[string]any{"full_name": admin + "/" + name, "empty": true}) case repoPath.MatchString(p) && r.Method == http.MethodGet: m := repoPath.FindStringSubmatch(p) repo := f.repos[m[1]+"/"+m[2]] if repo == nil { reply(404, map[string]string{"message": "not found"}) return } reply(200, map[string]any{"full_name": m[1] + "/" + m[2], "empty": len(repo.Branches) == 0}) case rulesPath.MatchString(p): m := rulesPath.FindStringSubmatch(p) repo := f.repos[m[1]+"/"+m[2]] if repo == nil { reply(404, nil) return } switch { case r.Method == http.MethodGet && m[3] == "": names := make([]string, 0, len(repo.Rules)) for n := range repo.Rules { names = append(names, n) } sort.Strings(names) out := []map[string]any{} for _, n := range names { rule := map[string]any{"rule_name": n} for k, v := range repo.Rules[n] { rule[k] = v } out = append(out, rule) } reply(200, out) case r.Method == http.MethodPost && m[3] == "": name := fmt.Sprint(in["rule_name"]) if repo.Rules[name] != nil { reply(403, map[string]string{"message": "branch protection already exist"}) return } delete(in, "rule_name") keepReaching(m[1]+"/"+m[2], in) repo.Rules[name] = in reply(201, in) case r.Method == http.MethodPatch && m[3] != "": rule := repo.Rules[m[3]] if rule == nil { reply(404, nil) return } for k, v := range in { rule[k] = v } keepReaching(m[1]+"/"+m[2], rule) reply(200, rule) default: reply(405, nil) } case branchPath.MatchString(p) && r.Method == http.MethodGet: m := branchPath.FindStringSubmatch(p) repo := f.repos[m[1]+"/"+m[2]] if repo == nil || !repo.Branches[m[3]] { reply(404, nil) return } reply(200, map[string]any{"name": m[3]}) case filesPath.MatchString(p) && r.Method == http.MethodPost: m := filesPath.FindStringSubmatch(p) repo := f.repos[m[1]+"/"+m[2]] branch := fmt.Sprint(in["branch"]) if repo == nil { reply(404, nil) return } if !lets(repo.Rules[branch], admin) { reply(403, map[string]string{"message": "branch " + branch + " is protected"}) return } files, _ := in["files"].([]any) for _, x := range files { op, _ := x.(map[string]any) raw, _ := base64.StdEncoding.DecodeString(fmt.Sprint(op["content"])) repo.Files[fmt.Sprint(op["path"])] = string(raw) } repo.Branches[branch] = true repo.Commits++ reply(201, map[string]any{"commit": map[string]any{"sha": fmt.Sprintf("%040d", repo.Commits)}}) case movePath.MatchString(p) && r.Method == http.MethodPost: m := movePath.FindStringSubmatch(p) from := m[1] + "/" + m[2] repo := f.repos[from] to := fmt.Sprint(in["new_owner"]) + "/" + m[2] if repo == nil || f.repos[to] != nil { reply(422, map[string]string{"message": "cannot transfer"}) return } delete(f.repos, from) f.repos[to] = repo reply(202, map[string]any{"full_name": to}) default: reply(404, map[string]string{"message": "not found"}) } } func syncer(t *testing.T, f *fakeForge) (*Syncer, *[]string) { t.Helper() srv := httptest.NewServer(f) t.Cleanup(srv.Close) pw := filepath.Join(t.TempDir(), "pw") if err := os.WriteFile(pw, []byte("pw\n"), 0o600); err != nil { t.Fatal(err) } var said []string return &Syncer{Forge: forgehttp.Client{URL: srv.URL, User: admin, PasswordFile: pw}, Org: org, Admin: admin, Tools: tools, Log: func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }}, &said } func run(t *testing.T, s *Syncer, v Value) Outcome { t.Helper() o, err := s.Sync(context.Background(), v) if err != nil { t.Fatal(err) } return o } func result(o Outcome, name string) Result { for _, r := range o.Repositories { if r.Name == name { return r } } return Result{} } func has(list []string, want string) bool { for _, s := range list { if s == want { return true } } return false }