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:.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) } } }