334 lines
9.2 KiB
Go
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")
|
|
}
|
|
}
|