Files
mesh-catalog/modules/gitea/cmd/agent-teams/teams_test.go
T

334 lines
9.2 KiB
Go

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")
}
}