Files
mesh-catalog/modules/gitea/cmd/repositories/fake_test.go
T
jschoubben 11761637cf
mesh/merge-gate head 11761637cf81 merged into trunk e47d0694c752 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 11761637cf81 merged into trunk e47d0694c752 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Put main's rule right again once a new repository is in the organisation: the forge drops a merger without access, and under the setup account the tools' account has none (issue 1124, ADR 0428 §3, review round 1)
2026-10-18 16:58:17 +02:00

324 lines
8.7 KiB
Go

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
}