357 lines
13 KiB
Go
357 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/provider"
|
|
|
|
"gitea/internal/forgehttp"
|
|
)
|
|
|
|
// Issue 1481 (novox/hq ADR 0466 §3): the build modules' keys on one forge user that reads and pushes nothing. The forge
|
|
// is faked with the teams, members and keys it keeps, so a team's shape and a key's right can be read back.
|
|
|
|
type forgeKey struct {
|
|
ID int64
|
|
Title string
|
|
Key string
|
|
ReadOnly bool
|
|
}
|
|
|
|
type teamForge struct {
|
|
mu sync.Mutex
|
|
users map[string]bool
|
|
teams map[string]map[string]any // by name: the shape last sent
|
|
ids map[string]int64
|
|
members map[string][]string // by team name
|
|
keys map[string][]forgeKey
|
|
next int64
|
|
}
|
|
|
|
func newTeamForge() *teamForge {
|
|
return &teamForge{users: map[string]bool{}, teams: map[string]map[string]any{}, ids: map[string]int64{},
|
|
members: map[string][]string{}, keys: map[string][]forgeKey{}, next: 10}
|
|
}
|
|
|
|
var (
|
|
teamRe = regexp.MustCompile(`^/api/v1/teams/(\d+)(?:/members/([^/]+))?$`)
|
|
userKeyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`)
|
|
)
|
|
|
|
func (f *teamForge) teamByID(id string) string {
|
|
for name, n := range f.ids {
|
|
if fmt.Sprint(n) == id {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (f *teamForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
body, _ := io.ReadAll(r.Body)
|
|
var in map[string]any
|
|
_ = json.Unmarshal(body, &in)
|
|
reply := func(code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
if v != nil {
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
}
|
|
p := r.URL.Path
|
|
if m := userKeyRe.FindStringSubmatch(p); m != nil {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
out := []map[string]any{}
|
|
for _, k := range f.keys[m[1]] {
|
|
out = append(out, map[string]any{"id": k.ID, "title": k.Title, "key": k.Key})
|
|
}
|
|
reply(200, out)
|
|
case http.MethodPost:
|
|
f.next++
|
|
ro, _ := in["read_only"].(bool)
|
|
f.keys[m[1]] = append(f.keys[m[1]], forgeKey{ID: f.next, Title: in["title"].(string), Key: in["key"].(string), ReadOnly: ro})
|
|
reply(201, map[string]any{"id": f.next})
|
|
case http.MethodDelete:
|
|
var kept []forgeKey
|
|
for _, k := range f.keys[m[1]] {
|
|
if fmt.Sprint(k.ID) != m[2] {
|
|
kept = append(kept, k)
|
|
}
|
|
}
|
|
f.keys[m[1]] = kept
|
|
reply(204, nil)
|
|
}
|
|
return
|
|
}
|
|
if m := teamRe.FindStringSubmatch(p); m != nil {
|
|
name := f.teamByID(m[1])
|
|
switch {
|
|
case name == "":
|
|
reply(404, map[string]any{"message": "no team"})
|
|
case r.Method == http.MethodPatch && m[2] == "":
|
|
f.teams[name] = in
|
|
reply(200, map[string]any{})
|
|
case r.Method == http.MethodPut:
|
|
for _, u := range f.members[name] {
|
|
if u == m[2] {
|
|
reply(204, nil)
|
|
return
|
|
}
|
|
}
|
|
f.members[name] = append(f.members[name], m[2])
|
|
reply(204, nil)
|
|
default:
|
|
http.Error(w, "unexpected "+r.Method+" "+p, 400)
|
|
}
|
|
return
|
|
}
|
|
switch {
|
|
case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/users/"):
|
|
if !f.users[strings.TrimPrefix(p, "/api/v1/users/")] {
|
|
reply(404, map[string]any{"message": "no"})
|
|
return
|
|
}
|
|
reply(200, map[string]any{"active": true})
|
|
case r.Method == http.MethodPost && p == "/api/v1/admin/users":
|
|
f.users[in["username"].(string)] = true
|
|
reply(201, map[string]any{})
|
|
case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodGet:
|
|
out := []map[string]any{}
|
|
for name, id := range f.ids {
|
|
out = append(out, map[string]any{"id": id, "name": name})
|
|
}
|
|
reply(200, out)
|
|
case p == "/api/v1/orgs/novox/teams" && r.Method == http.MethodPost:
|
|
f.next++
|
|
name := in["name"].(string)
|
|
f.ids[name], f.teams[name] = f.next, in
|
|
reply(201, map[string]any{"id": f.next})
|
|
default:
|
|
http.Error(w, "unexpected "+r.Method+" "+p, 400)
|
|
}
|
|
}
|
|
|
|
func (f *teamForge) titles(user string) []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := []string{}
|
|
for _, k := range f.keys[user] {
|
|
out = append(out, k.Title)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type teamRig struct {
|
|
t *testing.T
|
|
bus *fakeBus
|
|
forge *teamForge
|
|
svc *Service
|
|
runner *fakeRunner
|
|
}
|
|
|
|
func newTeamRig(t *testing.T) *teamRig {
|
|
t.Helper()
|
|
forge := newTeamForge()
|
|
srv := httptest.NewServer(forge)
|
|
t.Cleanup(srv.Close)
|
|
dir := t.TempDir()
|
|
pw, host, origins := filepath.Join(dir, "pw"), filepath.Join(dir, "host"), filepath.Join(dir, "origins")
|
|
for p, c := range map[string]string{pw: "x\n", host: "novox.internal\n", origins: "https://git.novox.be\n"} {
|
|
if err := os.WriteFile(p, []byte(c), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
runner := &fakeRunner{out: scanned}
|
|
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
|
|
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}})
|
|
svc.loaded = true
|
|
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"] = true, true
|
|
return &teamRig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
|
|
}
|
|
|
|
func (r *teamRig) pass() []provider.Outcome {
|
|
r.t.Helper()
|
|
out, err := r.svc.Provider(r.bus).Pass(context.Background())
|
|
if err != nil {
|
|
r.t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bind makes module on node bind gitea's forge-account in the assignments mirror.
|
|
func (r *teamRig) bind(node, module string) {
|
|
r.bus.set(provider.AssignmentsBucket, node+"."+module, []byte(fmt.Sprintf(
|
|
`{"node":%q,"module":%q,"provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s"}]}}`,
|
|
node, module, strings.ReplaceAll(node+"_"+module, "-", "_"))))
|
|
}
|
|
|
|
func (r *teamRig) unbind(node, module string) {
|
|
r.bus.mu.Lock()
|
|
delete(r.bus.buckets[provider.AssignmentsBucket], node+"."+module)
|
|
r.bus.mu.Unlock()
|
|
}
|
|
|
|
// key reports a key in module's state keys, of the kind and for the account given.
|
|
func (r *teamRig) key(module, node, account, kind, public string) {
|
|
b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": kind, "public": public,
|
|
"fingerprint": "SHA256:x", "at": "2026-10-20T10:00:00Z"})
|
|
r.svc.SourceKeyChanged(module, node+"."+account, b)
|
|
}
|
|
|
|
const (
|
|
pubGo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"
|
|
pubNode = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN"
|
|
pubOps = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO"
|
|
)
|
|
|
|
// 1481/AC1: an entry from mesh-build-go on ace: the forge user nox-builder and the team builders are ensured, the team's
|
|
// units are code, read only, on every repository, and the entry's values name nox-builder. The key is registered
|
|
// read-only under its title.
|
|
func TestABuildModulesEntryIsServedAsNoxBuilderInAReadOnlyTeam(t *testing.T) {
|
|
r := newTeamRig(t)
|
|
r.bind("ace", "mesh-build-go")
|
|
r.key("mesh-build-go", "ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)")
|
|
r.pass()
|
|
|
|
if !r.forge.users["nox-builder"] || r.forge.users["nox-agent"] {
|
|
t.Errorf("users = %v, want nox-builder alone", r.forge.users)
|
|
}
|
|
shape := r.forge.teams["builders"]
|
|
if shape == nil {
|
|
t.Fatalf("no team builders: %v", r.forge.teams)
|
|
}
|
|
units, _ := json.Marshal(shape["units_map"])
|
|
if shape["permission"] != "read" || shape["includes_all_repositories"] != true || shape["can_create_org_repo"] != false || string(units) != `{"repo.code":"read"}` {
|
|
t.Errorf("the team's shape = %v (units %s), want code read on every repository and no other right", shape, units)
|
|
}
|
|
if got := strings.Join(r.forge.members["builders"], ","); got != "nox-builder" || len(r.forge.members["agents"]) != 0 {
|
|
t.Errorf("members = %v", r.forge.members)
|
|
}
|
|
keys := r.forge.keys["nox-builder"]
|
|
if len(keys) != 1 || keys[0].Title != "mesh:ace.mesh-build-go" || !keys[0].ReadOnly {
|
|
t.Errorf("nox-builder's keys = %+v, want one read-only key titled mesh:ace.mesh-build-go", keys)
|
|
}
|
|
e := r.bus.entry(t, "ace.mesh-build-go.forge-account")
|
|
if e.Values["user"] != "nox-builder" || e.Values["ssh_alias"] != "mesh-forge" || e.Values["host_key"] != "ssh-ed25519 AAAAC3HOSTKEY" {
|
|
t.Errorf("values = %v", e.Values)
|
|
}
|
|
}
|
|
|
|
// 1481/AC2: an entry from ssh-client is what it was: nox-agent, the agents team, a key that may write. (The existing
|
|
// tests of the provider pass unchanged.)
|
|
func TestAnSshClientEntryIsStillTheAgentsWithAKeyThatMayWrite(t *testing.T) {
|
|
r := newTeamRig(t)
|
|
// the agents team exists, as the forge has it
|
|
r.forge.ids["agents"], r.forge.teams["agents"] = 5, map[string]any{}
|
|
r.bind("g14", "ssh-client")
|
|
r.key("ssh-client", "g14", "nox", "machine", pubA+" nox@g14 (mesh)")
|
|
r.pass()
|
|
if e := r.bus.entry(t, "g14.ssh-client.forge-account"); e.Values["user"] != "nox-agent" {
|
|
t.Errorf("values = %v", e.Values)
|
|
}
|
|
keys := r.forge.keys["nox-agent"]
|
|
if len(keys) != 1 || keys[0].Title != "mesh:g14.nox" || keys[0].ReadOnly {
|
|
t.Errorf("nox-agent's keys = %+v, want one key that may write", keys)
|
|
}
|
|
if r.forge.teams["builders"] != nil || r.forge.users["nox-builder"] {
|
|
t.Errorf("a machine's entry made the builders' account or team")
|
|
}
|
|
if got := strings.Join(r.forge.members["agents"], ","); got != "nox-agent" {
|
|
t.Errorf("agents = %v", r.forge.members)
|
|
}
|
|
}
|
|
|
|
// 1481/AC3: a builder entry gone: only the keys titled mesh:<node>.mesh-build-go are removed; the user and the team
|
|
// stay, and so do the other build module's key on that machine and a key of the operator's.
|
|
func TestABuilderEntryGoneRemovesItsOwnKeyAndNothingElse(t *testing.T) {
|
|
r := newTeamRig(t)
|
|
r.bind("ace", "mesh-build-go")
|
|
r.bind("ace", "mesh-build-node")
|
|
r.key("mesh-build-go", "ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)")
|
|
r.key("mesh-build-node", "ace", "mesh-build-node", "builder", pubNode+" mesh-build-node@ace (mesh)")
|
|
r.pass()
|
|
r.forge.mu.Lock()
|
|
r.forge.keys["nox-builder"] = append(r.forge.keys["nox-builder"], forgeKey{ID: 99, Title: "my own", Key: pubOps})
|
|
r.forge.mu.Unlock()
|
|
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-go,mesh:ace.mesh-build-node,my own" {
|
|
t.Fatalf("before: %s", got)
|
|
}
|
|
r.unbind("ace", "mesh-build-go")
|
|
r.pass()
|
|
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-node,my own" {
|
|
t.Errorf("after: %s, want only the go module's key gone", got)
|
|
}
|
|
if !r.forge.users["nox-builder"] || r.forge.teams["builders"] == nil || len(r.forge.members["builders"]) != 1 {
|
|
t.Errorf("the user or the team went: users %v, members %v", r.forge.users, r.forge.members)
|
|
}
|
|
if r.bus.has("ace.mesh-build-go.forge-account") {
|
|
t.Errorf("the entry stayed")
|
|
}
|
|
}
|
|
|
|
// A build module and ssh-client on one machine each have their own entry, keys, user and team.
|
|
func TestABuildModuleAndSshClientOnOneMachineDoNotShareAnything(t *testing.T) {
|
|
r := newTeamRig(t)
|
|
r.forge.ids["agents"], r.forge.teams["agents"] = 5, map[string]any{}
|
|
r.bind("ace", "ssh-client")
|
|
r.bind("ace", "mesh-build-go")
|
|
r.key("ssh-client", "ace", "nox", "machine", pubA+" nox@ace (mesh)")
|
|
r.key("mesh-build-go", "ace", "mesh-build-go", "builder", pubGo+" mesh-build-go@ace (mesh)")
|
|
r.pass()
|
|
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "mesh:ace.nox" {
|
|
t.Errorf("nox-agent: %s", got)
|
|
}
|
|
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-go" {
|
|
t.Errorf("nox-builder: %s", got)
|
|
}
|
|
// ssh-client's entry gone: nox-builder's key stays.
|
|
r.unbind("ace", "ssh-client")
|
|
r.pass()
|
|
if got := strings.Join(r.forge.titles("nox-builder"), ","); got != "mesh:ace.mesh-build-go" {
|
|
t.Errorf("nox-builder after ssh-client left: %s", got)
|
|
}
|
|
if got := strings.Join(r.forge.titles("nox-agent"), ","); got != "" {
|
|
t.Errorf("nox-agent after ssh-client left: %s", got)
|
|
}
|
|
}
|
|
|
|
// A build module's entry waits for its own module's builder key: a machine key of the same node, a key of the wrong
|
|
// kind in the build module's state, or the other build module's key is none.
|
|
func TestABuildModulesEntryWaitsForItsOwnBuilderKey(t *testing.T) {
|
|
r := newTeamRig(t)
|
|
r.bind("ace", "mesh-build-go")
|
|
r.key("ssh-client", "ace", "nox", "machine", pubA) // a machine's key
|
|
r.key("mesh-build-go", "ace", "mesh-build-go", "machine", pubGo) // the wrong kind in the build module's state
|
|
r.key("mesh-build-node", "ace", "mesh-build-node", "builder", pubNode) // the other build module's
|
|
out := r.pass()
|
|
if r.bus.has("ace.mesh-build-go.forge-account") || len(r.forge.users) != 0 || len(r.forge.teams) != 0 {
|
|
t.Fatalf("something was made without a builder key: users %v teams %v", r.forge.users, r.forge.teams)
|
|
}
|
|
if len(out) != 1 || !strings.Contains(out[0].Why, "mesh-build-go") || !strings.Contains(out[0].Why, "builder key") {
|
|
t.Errorf("the wait should name the module and the key: %+v", out)
|
|
}
|
|
}
|
|
|
|
func TestTheKindOfAModulesKeyAndItsForgeUser(t *testing.T) {
|
|
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
|
|
got, err := (&Service{}).valuesForUser("ace", module)
|
|
if err != nil || got != want {
|
|
t.Errorf("%s: %q, %v; want %q", module, got, err, want)
|
|
}
|
|
}
|
|
}
|