Files
mesh-catalog/modules/gitea/cmd/forge-account-provider/builder_test.go
T
jschoubben 35ff67bda0
mesh/delivery-group group feat/774-l3-reland rejected: composed together: the checker's verdict at the members' heads (b48c90fea31f): integration: mesh-self-ch…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: integration: mesh-self-check/cmd/mesh-self-check.TestTheSelfCheckWholeRunsOnTheBus: the run answered {Run:doctor-1793351737-1 At:20…
Design 47's last milestones: the keeper, self-check, healers, hand-acts, provisioner, vault, artifact sweep, disk readings, credential forms (issues 774, 1952, 1953, 1954, 1955)
One commit with exactly the tree of the reviewed head 35b4509c0a, on main 12c2574c9d, so two compiled programs added and removed on the branch leave no trace in history (issue 1577).
2026-10-30 02:21:51 +01:00

354 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"], svc.loadedFrom["mesh-build-image"] = true, 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 seat mesh-provisioner's plans.
func (r *teamRig) bind(node, module string) {
r.bus.bindPlan(node, module, "mesh_"+strings.ReplaceAll(node+"_"+module, "-", "_"))
}
// unbind is the operator's yes to retiring module's forge-account on node (ADR 0570 clause 5).
func (r *teamRig) unbind(node, module string) {
r.bus.retire(node, module)
}
// 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", "mesh-build-image": "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)
}
}
}