Files
mesh-catalog/modules/gitea/cmd/agent-teams/teams_test.go
T
jschoubben 2b8b47d862 G-catalog-B 8: the retire removes only the teams the npm pair provisioner made
A person who later makes a team named packages or readers would have lost it at the next start. A team
counts as the provisioner's only in its organisation and when every member is one of its accounts (it
put its own accounts there alone, and they go with the first run), so a team made later stays.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 18:13:44 +01:00

411 lines
13 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"slices"
"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
// owners are accounts that own a repository: the forge refuses to delete them (422); prohibited, whose login
// was prohibited.
owners, prohibited map[string]bool
}
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/admin/users/"):
u := strings.TrimPrefix(p, "/api/v1/admin/users/")
switch {
case !f.users[u]:
reply(404, nil)
case r.Method == http.MethodDelete && f.owners[u]:
reply(422, map[string]string{"message": "user still owns one or more repositories"})
case r.Method == http.MethodDelete:
delete(f.users, u)
for _, t := range f.teams {
delete(t.Members, u)
}
reply(204, nil)
case r.Method == http.MethodPatch && in["prohibit_login"] == true:
if f.prohibited == nil {
f.prohibited = map[string]bool{}
}
f.prohibited[u] = true
reply(200, map[string]string{})
}
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) && r.Method == http.MethodDelete:
t := f.find(teamPath.FindStringSubmatch(p)[1])
f.teams = slices.DeleteFunc(f.teams, func(x *fTeam) bool { return x == t })
reply(204, nil)
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")
}
}
// Security review F5 (ADR 0605 §5): the npm pair's teams packages and readers go, and each account of the pair's form
// whose only teams they are; one that owns a repository is kept with its login prohibited; an account of the pair's
// form still in another team, an account of another form and every other team stay; a second run changes nothing.
func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) {
f := newFake([]string{"novox"}, "mesh_ace_mesh_build_node", "mesh_shanks_lab", "mesh_g14_agent", "mesh_novox_owner",
"mesh-packages", "agent-ace", "jschoubben", "mesh_ace_agent")
f.owners = map[string]bool{"mesh_novox_owner": true}
f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner"})
f.add("novox", "readers", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_shanks_lab"})
f.add("novox", "agents", "none", nil, []string{"mesh_g14_agent", "agent-ace"})
// Catalog note: a team of a retired name the provisioner did not make (a person in it, or in another
// organisation) stays.
f.orgs = append(f.orgs, "other")
f.add("other", "readers", "none", nil, []string{"mesh_ace_agent"})
f.add("novox", "mesh-packages", "none", nil, []string{"mesh-packages"})
s, said := syncer(t, f)
if err := s.Sync(context.Background(), Value{}); err != nil {
t.Fatal(err)
}
if f.byName("novox", "packages") != nil || f.byName("novox", "readers") != nil {
t.Errorf("the retired teams stay: %+v", f.teams)
}
if f.byName("novox", "agents") == nil || f.byName("novox", "mesh-packages") == nil {
t.Errorf("a team the module did not retire was removed: %+v", f.teams)
}
for u, want := range map[string]bool{"mesh_ace_mesh_build_node": false, "mesh_shanks_lab": false, "mesh_g14_agent": true,
"mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true, "mesh_ace_agent": true} {
if f.users[u] != want {
t.Errorf("account %s kept %v, want %v", u, f.users[u], want)
}
}
if !f.prohibited["mesh_novox_owner"] || len(f.prohibited) != 1 {
t.Errorf("prohibited %v, want mesh_novox_owner alone", f.prohibited)
}
if f.byName("other", "readers") == nil {
t.Errorf("a readers team in another organisation was removed")
}
// A team a person makes after the first run under a retired name, with persons in it or none, stays.
f.add("novox", "packages", "none", nil, []string{"jschoubben"})
f.add("novox", "readers", "none", nil, nil)
before := len(*said)
if err := s.Sync(context.Background(), Value{}); err != nil || len(*said) != before {
t.Errorf("a second run: %v, said %v; want nothing", err, (*said)[before:])
}
if f.byName("novox", "packages") == nil || f.byName("novox", "readers") == nil || !f.users["jschoubben"] {
t.Errorf("a team a person made later under a retired name was removed: %+v", f.teams)
}
}