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
324 lines
8.7 KiB
Go
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
|
|
}
|