Test that the gitea module makes the organisation's repositories from its config, protected before any team reaches them, and that no tool creates in the organisation (issue 1124, ADR 0428)
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1124/AC1: the manifest declares `repositories` under config as a trusted object value with an empty default, as
|
||||
// it declares agent-read-teams (ADR 0365 §3, ADR 0348), renders it into a file of its own, and runs this program
|
||||
// at the module's start and whenever that file changes.
|
||||
func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Config map[string]map[string]any `json:"config"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := m.Config[ConfigKey]
|
||||
if v == nil || v["type"] != "object" || v["trusted"] != true {
|
||||
t.Fatalf("config %s = %v, want a trusted object", ConfigKey, v)
|
||||
}
|
||||
if d, ok := v["default"].(map[string]any); !ok || len(d) != 0 {
|
||||
t.Fatalf("its default is %v, want {}", v["default"])
|
||||
}
|
||||
var file, process map[string]any
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range strs(r["takes-config"]) {
|
||||
if k == ConfigKey {
|
||||
file = r
|
||||
}
|
||||
}
|
||||
}
|
||||
if file == nil || file["type"] != "file" {
|
||||
t.Fatalf("no file takes the value %s", ConfigKey)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
|
||||
process = r
|
||||
}
|
||||
}
|
||||
if process == nil {
|
||||
t.Fatalf("no process runs again when %s changes", file["id"])
|
||||
}
|
||||
if strings.Join(strs(process["run"]), " ") != "./repositories sync" || process["artifact"] != "repositories" {
|
||||
t.Errorf("the process runs %v from %v", process["run"], process["artifact"])
|
||||
}
|
||||
env, _ := process["env"].(map[string]any)
|
||||
for _, k := range []string{"MESH_GITEA_URL", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD_FILE", "MESH_REPOSITORIES_FILE", "MESH_REPOSITORIES_OUTCOME"} {
|
||||
if env[k] == nil {
|
||||
t.Errorf("the process is not given %s", k)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == "repositories" && a["from"] == "cmd/repositories" && a["binary"] == "repositories" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("no bundle builds cmd/repositories")
|
||||
}
|
||||
// Like agent-read-teams: the same kind of value, the same way of applying it.
|
||||
teams := m.Config["agent-read-teams"]
|
||||
if teams["trusted"] != v["trusted"] || teams["type"] != v["type"] {
|
||||
t.Errorf("repositories is declared unlike agent-read-teams: %v vs %v", v, teams)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The tests of issue 1124 / ADR 0428: the organisation's repositories are the gitea module's config value
|
||||
// `repositories`, made and protected by the setup login, never by a tool.
|
||||
|
||||
const mainRule = "main"
|
||||
|
||||
// 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that
|
||||
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/repo-check (and
|
||||
// mesh/merge-gate, 1124/AC5), and only then moved into the organisation. Nothing reaches the organisation before the
|
||||
// narrowed rule, and nothing else is pushed to main.
|
||||
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
|
||||
f := newFake()
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"mesh-delivery": {Private: false, Description: "the mesh's delivery"}})
|
||||
|
||||
want := []string{
|
||||
"POST /api/v1/user/repos",
|
||||
"POST /api/v1/repos/mesh-admin/mesh-delivery/branch_protections",
|
||||
"POST /api/v1/repos/mesh-admin/mesh-delivery/contents",
|
||||
"PATCH /api/v1/repos/mesh-admin/mesh-delivery/branch_protections/main",
|
||||
"POST /api/v1/repos/mesh-admin/mesh-delivery/transfer",
|
||||
}
|
||||
if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("writes, in order:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
// Nothing names the organisation's copy before the narrowed rule but the read that found it missing.
|
||||
narrowed := false
|
||||
for _, c := range f.all() {
|
||||
if strings.HasPrefix(c, "PATCH ") {
|
||||
narrowed = true
|
||||
}
|
||||
if strings.Contains(c, "/repos/novox/mesh-delivery") && !narrowed && c != "GET /api/v1/repos/novox/mesh-delivery" {
|
||||
t.Errorf("%s before the narrowed rule", c)
|
||||
}
|
||||
}
|
||||
repo := f.repos["novox/mesh-delivery"]
|
||||
if repo == nil || f.repos["mesh-admin/mesh-delivery"] != nil {
|
||||
t.Fatalf("not moved into the organisation: %v", f.repos)
|
||||
}
|
||||
if repo.Private || repo.Description != "the mesh's delivery" {
|
||||
t.Errorf("made as %+v, want public with its description", repo)
|
||||
}
|
||||
if repo.Commits != 1 || len(repo.Files) != 2 || repo.Files["README.md"] == "" || repo.Files["merge-check.sh"] == "" {
|
||||
t.Errorf("first commit: %d commits, files %v", repo.Commits, keysOf(repo.Files))
|
||||
}
|
||||
if !strings.Contains(repo.Files["README.md"], "the mesh's delivery") {
|
||||
t.Errorf("the README does not name its purpose: %q", repo.Files["README.md"])
|
||||
}
|
||||
rule := repo.Rules[mainRule]
|
||||
if rule["enable_push"] != false || rule["enable_push_whitelist"] == true || len(strs(rule["push_whitelist_usernames"])) != 0 {
|
||||
t.Errorf("main still takes a push: %v", rule)
|
||||
}
|
||||
if rule["enable_merge_whitelist"] != true || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
|
||||
t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
|
||||
t.Errorf("main does not require mesh/repo-check and mesh/merge-gate (1124/AC5): %v", rule)
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
|
||||
t.Errorf("outcome %+v, want made", r)
|
||||
}
|
||||
|
||||
// A second pass changes nothing.
|
||||
before := len(f.writes())
|
||||
o = run(t, s, Value{"mesh-delivery": {Private: false, Description: "the mesh's delivery"}})
|
||||
if got := f.writes(); len(got) != before {
|
||||
t.Errorf("a second pass wrote %v", got[before:])
|
||||
}
|
||||
if r := result(o, "mesh-delivery"); r.Did != DidUnchanged {
|
||||
t.Errorf("second outcome %+v, want unchanged", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC2: the setup login's rule lets that login alone push, and it is what the first commit lands through.
|
||||
func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) {
|
||||
f := newFake()
|
||||
var seen map[string]any
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if method == "POST" && strings.HasSuffix(path, "/contents") {
|
||||
seen = map[string]any{}
|
||||
for k, v := range f.repos["mesh-admin/x"].Rules[mainRule] {
|
||||
seen[k] = v
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{"x": {Private: true}})
|
||||
if seen["enable_push"] != true || seen["enable_push_whitelist"] != true || strings.Join(strs(seen["push_whitelist_usernames"]), ",") != admin {
|
||||
t.Fatalf("the rule at the first commit was %v, want a push by %s alone", seen, admin)
|
||||
}
|
||||
if !f.repos["novox/x"].Private {
|
||||
t.Error("made public, want private")
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right;
|
||||
// a rule still letting the setup login push is narrowed, its statuses kept (1124/AC5).
|
||||
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
|
||||
f := newFake()
|
||||
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
|
||||
"enable_push": true, "enable_push_whitelist": true, "push_whitelist_usernames": []any{admin},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"other/x", "mesh/merge-gate"},
|
||||
}}, "main")
|
||||
bare := f.add("novox/bare", nil, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"hq": {}, "bare": {}})
|
||||
|
||||
for _, c := range f.writes() {
|
||||
if strings.HasSuffix(c, "/user/repos") || strings.HasSuffix(c, "/contents") || strings.HasSuffix(c, "/transfer") {
|
||||
t.Errorf("an existing repository got %s", c)
|
||||
}
|
||||
}
|
||||
rule := hq.Rules[mainRule]
|
||||
if rule["enable_push"] != false || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
|
||||
t.Errorf("hq's rule not narrowed: %v", rule)
|
||||
}
|
||||
checks := strs(rule["status_check_contexts"])
|
||||
for _, c := range []string{"other/x", "mesh/merge-gate", "mesh/repo-check"} {
|
||||
if !has(checks, c) {
|
||||
t.Errorf("hq's rule lacks %s: %v", c, checks)
|
||||
}
|
||||
}
|
||||
made := bare.Rules[mainRule]
|
||||
if made == nil || made["enable_push"] != false || !has(strs(made["status_check_contexts"]), "mesh/repo-check") ||
|
||||
!has(strs(made["status_check_contexts"]), "mesh/merge-gate") {
|
||||
t.Errorf("bare's rule: %v", made)
|
||||
}
|
||||
if hq.Commits != 0 || bare.Commits != 0 {
|
||||
t.Error("something was pushed to an existing main")
|
||||
}
|
||||
if r := result(o, "hq"); r.Did != DidPutRight {
|
||||
t.Errorf("hq outcome %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC3: a repository found still under the setup account is finished: with main already written it is not
|
||||
// written again; without a rule it gets the steps it lacks.
|
||||
func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("mesh-admin/half", map[string]map[string]any{mainRule: {
|
||||
"enable_push": true, "enable_push_whitelist": true, "push_whitelist_usernames": []any{admin},
|
||||
}}, "main")
|
||||
f.add("mesh-admin/empty", nil)
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"half": {}, "empty": {}})
|
||||
|
||||
got := strings.Join(f.writes(), "\n")
|
||||
want := strings.Join([]string{
|
||||
"POST /api/v1/repos/mesh-admin/empty/branch_protections",
|
||||
"POST /api/v1/repos/mesh-admin/empty/contents",
|
||||
"PATCH /api/v1/repos/mesh-admin/empty/branch_protections/main",
|
||||
"POST /api/v1/repos/mesh-admin/empty/transfer",
|
||||
"PATCH /api/v1/repos/mesh-admin/half/branch_protections/main",
|
||||
"POST /api/v1/repos/mesh-admin/half/transfer",
|
||||
}, "\n")
|
||||
if got != want {
|
||||
t.Fatalf("writes:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
for _, n := range []string{"half", "empty"} {
|
||||
repo := f.repos["novox/"+n]
|
||||
if repo == nil || repo.Rules[mainRule]["enable_push"] != false {
|
||||
t.Errorf("%s not finished: %+v", n, repo)
|
||||
}
|
||||
if r := result(o, n); r.Did != DidMade {
|
||||
t.Errorf("%s outcome %+v", n, r)
|
||||
}
|
||||
}
|
||||
if f.repos["novox/half"].Commits != 0 {
|
||||
t.Error("main of half was written again")
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC5: putting a rule right adds a missing one of mesh/repo-check and mesh/merge-gate and keeps every status
|
||||
// already there; no pass drops one.
|
||||
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
|
||||
f := newFake()
|
||||
only := f.add("novox/only", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/repo-check"},
|
||||
}}, "main")
|
||||
more := f.add("novox/more", map[string]map[string]any{mainRule: {
|
||||
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
|
||||
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
|
||||
}}, "main")
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"only": {}, "more": {}})
|
||||
if c := strs(only.Rules[mainRule]["status_check_contexts"]); !has(c, "mesh/merge-gate") || !has(c, "mesh/repo-check") {
|
||||
t.Errorf("only: %v", c)
|
||||
}
|
||||
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 3 || !has(c, "mesh/delivery") {
|
||||
t.Errorf("more lost a status: %v", c)
|
||||
}
|
||||
for _, w := range f.writes() {
|
||||
if strings.Contains(w, "/more/") {
|
||||
t.Errorf("a right rule was written: %s", w)
|
||||
}
|
||||
}
|
||||
if result(o, "more").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
|
||||
t.Errorf("outcome %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC6: a refused step is said with the repository, the step and the forge's answer, nothing further of that
|
||||
// repository is sent, and the next listed repository is still made; the health check then is unhealthy naming them.
|
||||
func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
|
||||
steps := map[string]func(method, path string) bool{
|
||||
StepCreate: func(m, p string) bool { return m == "POST" && p == "/api/v1/user/repos" },
|
||||
StepAdminRule: func(m, p string) bool { return m == "POST" && strings.HasSuffix(p, "/a/branch_protections") },
|
||||
StepFirstCommit: func(m, p string) bool { return m == "POST" && strings.HasSuffix(p, "/a/contents") },
|
||||
StepNarrow: func(m, p string) bool { return m == "PATCH" && strings.Contains(p, "/a/branch_protections/") },
|
||||
StepMove: func(m, p string) bool { return m == "POST" && strings.HasSuffix(p, "/a/transfer") },
|
||||
}
|
||||
for step, match := range steps {
|
||||
t.Run(step, func(t *testing.T) {
|
||||
f := newFake()
|
||||
refused := false
|
||||
f.refuse = func(method, path string) (int, string) {
|
||||
if refused {
|
||||
return 0, ""
|
||||
}
|
||||
// The create of a is refused, not b's.
|
||||
if step == StepCreate && match(method, path) && !strings.Contains(strings.Join(f.calls, "\n"), "/b") {
|
||||
refused = true
|
||||
return 403, "the forge says no to " + step
|
||||
}
|
||||
if step != StepCreate && match(method, path) {
|
||||
refused = true
|
||||
return 403, "the forge says no to " + step
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
s, _ := syncer(t, f)
|
||||
o := run(t, s, Value{"a": {}, "b": {}})
|
||||
r := result(o, "a")
|
||||
if r.Did != DidRefused || r.Step != step || !strings.Contains(r.Said, "the forge says no to "+step) {
|
||||
t.Fatalf("a: %+v, want refused at %s with the forge's answer", r, step)
|
||||
}
|
||||
// Nothing of a after the refused call.
|
||||
calls := f.all()
|
||||
at := -1
|
||||
for i, c := range calls {
|
||||
m := strings.SplitN(c, " ", 2)
|
||||
if match(m[0], m[1]) {
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, c := range calls[at+1:] {
|
||||
if strings.Contains(c, "/a/") || strings.HasSuffix(c, "/a") {
|
||||
t.Errorf("after the refused %s: %s", step, c)
|
||||
}
|
||||
}
|
||||
if f.repos["novox/b"] == nil || result(o, "b").Did != DidMade {
|
||||
t.Errorf("b was not made after a's refusal: %+v", result(o, "b"))
|
||||
}
|
||||
h := Judge(&o, nil, time.Now())
|
||||
if h.Healthy || !strings.Contains(h.Why, "novox/a") || !strings.Contains(h.Why, step) ||
|
||||
!strings.Contains(h.Why, "the forge says no to "+step) {
|
||||
t.Errorf("health %+v, want unhealthy naming novox/a, %s and the answer", h, step)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC7: a repository taken out of the value gets no call at all.
|
||||
func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
|
||||
f := newFake()
|
||||
f.add("novox/old", map[string]map[string]any{mainRule: {"enable_push": true}}, "main")
|
||||
f.add("mesh-admin/older", nil)
|
||||
s, _ := syncer(t, f)
|
||||
run(t, s, Value{})
|
||||
run(t, s, Value{"new": {}})
|
||||
for _, c := range f.all() {
|
||||
if strings.Contains(c, "old") {
|
||||
t.Errorf("a repository out of the value was asked: %s", c)
|
||||
}
|
||||
}
|
||||
if f.repos["novox/old"].Rules[mainRule]["enable_push"] != true || f.repos["mesh-admin/older"] == nil {
|
||||
t.Error("a repository out of the value was changed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC6: the outcome the pass leaves is what the health check judges: healthy when every repository was made or
|
||||
// put right, unhealthy naming each refusal, and unhealthy when no pass has left one.
|
||||
func TestJudge(t *testing.T) {
|
||||
now := time.Now()
|
||||
ok := Outcome{At: now, Repositories: []Result{{Name: "hq", Did: DidUnchanged}, {Name: "x", Did: DidMade}}}
|
||||
if h := Judge(&ok, nil, now); !h.Healthy || h.State != "healthy" {
|
||||
t.Errorf("%+v", h)
|
||||
}
|
||||
if h := Judge(nil, os.ErrNotExist, now); h.Healthy || !strings.Contains(h.Why, "no pass") {
|
||||
t.Errorf("no outcome: %+v", h)
|
||||
}
|
||||
if h := Judge(nil, errors.New("broken"), now); h.Healthy {
|
||||
t.Errorf("unreadable outcome: %+v", h)
|
||||
}
|
||||
if h := Judge(&Outcome{At: now, Failed: "the forge is not reachable"}, nil, now); h.Healthy ||
|
||||
!strings.Contains(h.Why, "the forge is not reachable") {
|
||||
t.Errorf("a pass that did not run: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC6: the outcome is written where the health check reads it, and read back whole.
|
||||
func TestOutcomeRoundTrip(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "outcome.json")
|
||||
o := Outcome{At: time.Now().UTC().Truncate(time.Second), Repositories: []Result{{Name: "a", Did: DidRefused, Step: StepMove, Said: "no"}}}
|
||||
if err := WriteOutcome(p, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadOutcome(p)
|
||||
if err != nil || got.Repositories[0] != o.Repositories[0] || !got.At.Equal(o.At) {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC1: the value is read from the file the mesh renders; a missing key is the empty value; an unknown field
|
||||
// or a name that is no repository name is refused.
|
||||
func TestReadValue(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "r.json")
|
||||
_ = os.WriteFile(p, []byte(`{"repositories":{"mesh-delivery":{"private":true,"description":"d"}}}`), 0o600)
|
||||
v, err := ReadValue(p)
|
||||
if err != nil || !v["mesh-delivery"].Private || v["mesh-delivery"].Description != "d" {
|
||||
t.Fatalf("%v %v", v, err)
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(`{}`), 0o600)
|
||||
if v, err := ReadValue(p); err != nil || len(v) != 0 {
|
||||
t.Fatalf("%v %v", v, err)
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(`{"repositories":{"a":{"owner":"novox"}}}`), 0o600)
|
||||
if _, err := ReadValue(p); err == nil {
|
||||
t.Fatal("an unknown field was accepted")
|
||||
}
|
||||
for _, bad := range []string{"..", "a/b", "", "a b", "-x"} {
|
||||
raw, _ := json.Marshal(map[string]any{"repositories": map[string]any{bad: map[string]any{}}})
|
||||
_ = os.WriteFile(p, raw, 0o600)
|
||||
if _, err := ReadValue(p); err == nil {
|
||||
t.Errorf("%q was accepted as a repository name", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1124/AC4: the first commit's merge-check.sh declares its toolchain in its first lines, runs the tests of a Go
|
||||
// module in the tree, and on a tree with none says so and passes.
|
||||
func TestFirstCommitMergeCheck(t *testing.T) {
|
||||
files := FirstCommit("mesh-delivery", Repo{Description: "the mesh's delivery"})
|
||||
script := files["merge-check.sh"]
|
||||
lines := strings.SplitN(script, "\n", 4)
|
||||
if lines[0] != "#!/bin/sh" || lines[1] != "# mesh-check-toolchain: go" {
|
||||
t.Fatalf("first lines %q", lines[:2])
|
||||
}
|
||||
if !strings.Contains(files["README.md"], "mesh-delivery") || !strings.Contains(files["README.md"], "the mesh's delivery") {
|
||||
t.Errorf("README %q", files["README.md"])
|
||||
}
|
||||
goBin, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
t.Skip("no go on PATH to run the script with")
|
||||
}
|
||||
|
||||
tree := t.TempDir()
|
||||
mod := filepath.Join(tree, "sub")
|
||||
_ = os.MkdirAll(mod, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(tree, "merge-check.sh"), []byte(script), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(mod, "go.mod"), []byte("module tiny\n\ngo 1.21\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(mod, "tiny_test.go"), []byte(`package tiny
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRan(t *testing.T) { _ = os.WriteFile("ran", []byte("yes"), 0o644) }
|
||||
`), 0o644)
|
||||
cmd := exec.Command("sh", "merge-check.sh")
|
||||
cmd.Dir = tree
|
||||
cmd.Env = append(os.Environ(), "GOFLAGS=", "GOTOOLCHAIN=local", "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("on a tree with a Go module: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(mod, "ran")); err != nil || !strings.Contains(string(out), "integration") {
|
||||
t.Fatalf("its tests did not run, or not with -tags integration:\n%s", out)
|
||||
}
|
||||
|
||||
// A failing test fails the check.
|
||||
_ = os.WriteFile(filepath.Join(mod, "fail_test.go"), []byte("package tiny\n\nimport \"testing\"\n\nfunc TestFails(t *testing.T) { t.Fatal(\"no\") }\n"), 0o644)
|
||||
if out, err := cmd2(tree, cmd.Env).CombinedOutput(); err == nil {
|
||||
t.Fatalf("a failing test passed the check:\n%s", out)
|
||||
}
|
||||
|
||||
empty := t.TempDir()
|
||||
_ = os.WriteFile(filepath.Join(empty, "merge-check.sh"), []byte(script), 0o755)
|
||||
out, err = cmd2(empty, cmd.Env).CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "no Go module") {
|
||||
t.Fatalf("on an empty tree: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func cmd2(dir string, env []string) *exec.Cmd {
|
||||
c := exec.Command("sh", "merge-check.sh")
|
||||
c.Dir, c.Env = dir, env
|
||||
return c
|
||||
}
|
||||
|
||||
func keysOf(m map[string]string) []string {
|
||||
out := []string{}
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user