package main import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "testing" "gitea/internal/forgehttp" ) type fTeam struct { ID int64 Org string Name string Perm string Units map[string]string Repos map[string]bool Members map[string]bool } type fakeForge struct { mu sync.Mutex orgs []string users map[string]bool teams []*fTeam next int64 } func newFake(orgs []string, users ...string) *fakeForge { f := &fakeForge{orgs: orgs, users: map[string]bool{}, next: 1} for _, u := range users { f.users[u] = true } return f } func (f *fakeForge) add(org, name, perm string, repos, members []string) *fTeam { t := &fTeam{ID: f.next, Org: org, Name: name, Perm: perm, Repos: map[string]bool{}, Members: map[string]bool{}} f.next++ for _, r := range repos { t.Repos[r] = true } for _, m := range members { t.Members[m] = true } f.teams = append(f.teams, t) return t } func (f *fakeForge) byName(org, name string) *fTeam { for _, t := range f.teams { if t.Org == org && t.Name == name { return t } } return nil } var ( orgTeams = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/teams$`) teamPath = regexp.MustCompile(`^/api/v1/teams/(\d+)$`) teamRepos = regexp.MustCompile(`^/api/v1/teams/(\d+)/repos(?:/(.+))?$`) teamMems = regexp.MustCompile(`^/api/v1/teams/(\d+)/members(?:/(.+))?$`) ) func (f *fakeForge) find(id string) *fTeam { n, _ := strconv.ParseInt(id, 10, 64) for _, t := range f.teams { if t.ID == n { return t } } return nil } func keys(m map[string]bool) []string { out := []string{} for k := range m { out = append(out, k) } sort.Strings(out) return out } 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 != "mesh-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) } } paged := r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1" var in map[string]any _ = json.NewDecoder(r.Body).Decode(&in) p := r.URL.Path switch { case p == "/api/v1/version": reply(200, map[string]string{"version": "1"}) case p == "/api/v1/admin/orgs": out := []map[string]string{} for _, o := range f.orgs { out = append(out, map[string]string{"name": o}) } if paged { out = nil } reply(200, out) case strings.HasPrefix(p, "/api/v1/users/"): if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] { reply(404, nil) return } reply(200, map[string]string{}) case orgTeams.MatchString(p): org := orgTeams.FindStringSubmatch(p)[1] known := false for _, o := range f.orgs { known = known || o == org } if !known { reply(404, nil) return } if r.Method == http.MethodPost { units, _ := in["units_map"].(map[string]any) t := f.add(org, in["name"].(string), in["permission"].(string), nil, nil) t.Units = map[string]string{} for k, v := range units { t.Units[k] = v.(string) } reply(201, map[string]any{"id": t.ID, "name": t.Name, "permission": t.Perm}) return } out := []map[string]any{} for _, t := range f.teams { if t.Org == org && !paged { out = append(out, map[string]any{"id": t.ID, "name": t.Name, "permission": t.Perm}) } } reply(200, out) case teamPath.MatchString(p): t := f.find(teamPath.FindStringSubmatch(p)[1]) t.Perm = in["permission"].(string) reply(200, nil) case teamRepos.MatchString(p): m := teamRepos.FindStringSubmatch(p) t := f.find(m[1]) switch { case m[2] == "" && r.Method == http.MethodGet: out := []map[string]string{} for _, k := range keys(t.Repos) { out = append(out, map[string]string{"full_name": k}) } if paged { out = nil } reply(200, out) case r.Method == http.MethodPut: t.Repos[m[2]] = true reply(204, nil) case r.Method == http.MethodDelete: delete(t.Repos, m[2]) reply(204, nil) } case teamMems.MatchString(p): m := teamMems.FindStringSubmatch(p) t := f.find(m[1]) switch { case m[2] == "" && r.Method == http.MethodGet: out := []map[string]string{} for _, k := range keys(t.Members) { out = append(out, map[string]string{"login": k}) } if paged { out = nil } reply(200, out) case r.Method == http.MethodPut: t.Members[m[2]] = true reply(204, nil) case r.Method == http.MethodDelete: delete(t.Members, m[2]) reply(204, nil) } default: reply(404, nil) } } 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: "mesh-admin", PasswordFile: pw}, Log: func(format string, a ...any) { said = append(said, sprintf(format, a...)) }}, &said } func sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) } func equal(a, b []string) bool { return strings.Join(a, ",") == strings.Join(b, ",") } // The first test of issue 758: a value makes a read-only team with exactly its repositories and members; a changed // value takes a repository and a member away; a team that is not named agent-read-* is never touched. func TestValueMakesReadTeamAndChangeRemoves(t *testing.T) { f := newFake([]string{"novox"}, "agent-g14", "agent-ace") other := f.add("novox", "Owners", "owner", []string{"novox/hq"}, []string{"agent-ace"}) s, said := syncer(t, f) ctx := context.Background() err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq", "novox/mesh-catalog"}, Members: []string{"g14"}}}) if err != nil { t.Fatal(err) } team := f.byName("novox", "agent-read-all") if team == nil || team.Perm != "read" { t.Fatalf("team = %+v, want a read team", team) } for _, u := range team.Units { if u != "read" { t.Errorf("unit %q, want read only", u) } } if !equal(keys(team.Repos), []string{"novox/hq", "novox/mesh-catalog"}) || !equal(keys(team.Members), []string{"agent-g14"}) { t.Fatalf("repos %v members %v", keys(team.Repos), keys(team.Members)) } before := len(*said) if err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq", "novox/mesh-catalog"}, Members: []string{"g14"}}}); err != nil { t.Fatal(err) } if len(*said) != before { t.Errorf("a second run changed something: %v", (*said)[before:]) } if err := s.Sync(ctx, Value{"all": {Repos: []string{"novox/hq"}, Members: nil}}); err != nil { t.Fatal(err) } if !equal(keys(team.Repos), []string{"novox/hq"}) || len(team.Members) != 0 { t.Fatalf("after change: repos %v members %v", keys(team.Repos), keys(team.Members)) } // the team leaves the value: emptied, never deleted if err := s.Sync(ctx, Value{}); err != nil { t.Fatal(err) } if f.byName("novox", "agent-read-all") == nil || len(team.Repos) != 0 || len(team.Members) != 0 { t.Fatalf("removed team not emptied: %+v", team) } if other.Perm != "owner" || !equal(keys(other.Repos), []string{"novox/hq"}) || !equal(keys(other.Members), []string{"agent-ace"}) { t.Fatalf("a team without the prefix was touched: %+v", other) } for _, line := range *said { if strings.Contains(line, "pw") { t.Errorf("a line names the password: %q", line) } } } func TestPermissionIsCorrectedAndMissingAccountSkipped(t *testing.T) { f := newFake([]string{"novox"}, "agent-g14") team := f.add("novox", "agent-read-all", "write", nil, nil) s, said := syncer(t, f) err := s.Sync(context.Background(), Value{"all": {Repos: []string{"novox/hq"}, Members: []string{"g14", "later"}}}) if err != nil { t.Fatal(err) } if team.Perm != "read" || !equal(keys(team.Members), []string{"agent-g14"}) { t.Fatalf("team = %+v", team) } if !strings.Contains(strings.Join(*said, "\n"), "agent-later") { t.Errorf("the missing account was not said: %v", *said) } } func TestRefusesWhatIsNotAReadTeam(t *testing.T) { f := newFake([]string{"novox", "other"}) s, _ := syncer(t, f) for name, v := range map[string]Value{ "no repo": {"a": {Members: []string{"g14"}}}, "two owners": {"a": {Repos: []string{"novox/hq", "other/x"}}}, "bad repo": {"a": {Repos: []string{"hq"}}}, "bad member": {"a": {Repos: []string{"novox/hq"}, Members: []string{"../x"}}}, "unknown org": {"a": {Repos: []string{"nowhere/hq"}}}, "bad teamname": {"a b": {Repos: []string{"novox/hq"}}}, } { if err := s.Sync(context.Background(), v); err == nil { t.Errorf("%s: no error", name) } } if len(f.teams) != 0 { t.Errorf("teams made: %+v", f.teams) } } func TestReadValue(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "t.json") _ = os.WriteFile(p, []byte(`{"agent-read-teams":{"a":{"repos":["o/r"],"members":["g14"]}}}`), 0o600) v, err := ReadValue(p) if err != nil || len(v["a"].Repos) != 1 || v["a"].Members[0] != "g14" { 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(`{"agent-read-teams":{"a":{"repos":["o/r"],"write":true}}}`), 0o600) if _, err := ReadValue(p); err == nil { t.Fatal("an unknown field was accepted") } }