Files
mesh-catalog/modules/gitea/cmd/forge-account-provider/service_test.go
T
mesh-tools 661b83249c
mesh/merge-gate head 661b83249c2a merged into trunk bb291b5ad8af — pass: builds gitea, ssh-client → ace, g14, novox, shanks; no bus step; every machine com…
mesh/repo-check head 661b83249c2a merged into trunk bb291b5ad8af — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Give the forge an ssh name of its own, so it no longer merges with the operator's entry for its machine (issue 955, ADR 0409)
2026-10-17 00:01:42 +00:00

544 lines
16 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/provider"
"gitea/internal/forgehttp"
)
const (
pubA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
pubB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
)
// fakeBus is the bus as the provider reads and writes it.
type fakeBus struct {
mu sync.Mutex
buckets map[string]map[string][]byte
}
func newBus() *fakeBus { return &fakeBus{buckets: map[string]map[string][]byte{}} }
func (b *fakeBus) set(bucket, key string, v []byte) {
b.mu.Lock()
defer b.mu.Unlock()
if b.buckets[bucket] == nil {
b.buckets[bucket] = map[string][]byte{}
}
b.buckets[bucket][key] = v
}
func (b *fakeBus) Watch(_ context.Context, bucket string, h func(string, []byte)) error {
b.mu.Lock()
cur := map[string][]byte{}
for k, v := range b.buckets[bucket] {
cur[k] = v
}
b.mu.Unlock()
for k, v := range cur {
h(k, v)
}
return nil
}
func (b *fakeBus) Put(_ context.Context, bucket, key string, v []byte) error {
b.set(bucket, key, v)
return nil
}
func (b *fakeBus) Delete(_ context.Context, bucket, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.buckets[bucket], key)
return nil
}
func (b *fakeBus) entry(t *testing.T, key string) provider.Entry {
t.Helper()
b.mu.Lock()
raw := b.buckets[provider.BucketOf("gitea")][key]
b.mu.Unlock()
if raw == nil {
t.Fatalf("no entry %s", key)
}
var e provider.Entry
if err := json.Unmarshal(raw, &e); err != nil {
t.Fatal(err)
}
return e
}
func (b *fakeBus) has(key string) bool {
b.mu.Lock()
defer b.mu.Unlock()
_, ok := b.buckets[provider.BucketOf("gitea")][key]
return ok
}
// bindClient makes ssh-client on node bind gitea's forge-account in the assignments mirror.
func (b *fakeBus) bindClient(node string) {
b.set(provider.AssignmentsBucket, node+".ssh-client", []byte(fmt.Sprintf(
`{"node":%q,"module":"ssh-client","provisioning":{"provisions":[{"provision":"forge-account","provider":"novox","provider-module":"gitea","as":"mesh_%s_ssh_client"}]}}`,
node, node)))
}
// fakeForge is gitea's API as far as the provider uses it.
type fakeForge struct {
mu sync.Mutex
users map[string]bool
teams map[string]bool
members []string
held map[string][]map[string]any
next int64
}
func newForge() *fakeForge {
return &fakeForge{users: map[string]bool{}, held: map[string][]map[string]any{}}
}
var keyRe = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`)
func (f *fakeForge) 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 := keyRe.FindStringSubmatch(p); m != nil {
switch r.Method {
case http.MethodGet:
reply(200, f.held[m[1]])
case http.MethodPost:
f.next++
f.held[m[1]] = append(f.held[m[1]], map[string]any{"id": f.next, "title": in["title"], "key": in["key"]})
reply(201, map[string]any{"id": f.next})
case http.MethodDelete:
var kept []map[string]any
for _, k := range f.held[m[1]] {
if fmt.Sprint(k["id"]) != m[2] {
kept = append(kept, k)
}
}
f.held[m[1]] = kept
reply(204, nil)
}
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:
reply(200, []map[string]any{{"id": 5, "name": "agents"}})
case p == "/api/v1/teams/5" && r.Method == http.MethodPatch:
reply(200, map[string]any{})
case strings.HasPrefix(p, "/api/v1/teams/5/members/") && r.Method == http.MethodPut:
f.members = append(f.members, strings.TrimPrefix(p, "/api/v1/teams/5/members/"))
reply(204, nil)
default:
http.Error(w, "unexpected "+r.Method+" "+p, 400)
}
}
func (f *fakeForge) titles(user string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, k := range f.held[user] {
out = append(out, k["title"].(string))
}
return out
}
type fakeRunner struct {
out string
err error
args []string
}
func (r *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
r.args = append([]string{name}, args...)
return []byte(r.out), r.err
}
const scanned = "# 127.0.0.1:222 SSH-2.0-Go\n[127.0.0.1]:222 ssh-ed25519 AAAAC3HOSTKEY\n"
type rig struct {
t *testing.T
bus *fakeBus
forge *fakeForge
svc *Service
runner *fakeRunner
}
func newRig(t *testing.T) *rig {
t.Helper()
forge := newForge()
srv := httptest.NewServer(forge)
t.Cleanup(srv.Close)
dir := t.TempDir()
pw := filepath.Join(dir, "pw")
host := filepath.Join(dir, "host")
origins := 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 // the machines' keys are read; a test that means otherwise says so
return &rig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
}
func (r *rig) pass() []provider.Outcome {
r.t.Helper()
p := r.svc.Provider(r.bus)
out, err := p.Pass(context.Background())
if err != nil {
r.t.Fatal(err)
}
return out
}
func (r *rig) key(node, account, public string) {
b, _ := json.Marshal(map[string]string{"node": node, "account": account, "kind": "machine", "public": public,
"fingerprint": "SHA256:x", "at": "2026-10-16T10:00:00Z"})
r.svc.KeyChanged(node+"."+account, b)
}
func TestAConsumerWithAKeyGetsItsEntryAndItsKeyIsRegistered(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
r.pass()
e := r.bus.entry(t, "g14.ssh-client.forge-account")
want := map[string]any{"user": "agent-g14", "ssh_host": "novox.internal", "ssh_port": float64(222),
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY", "ssh_alias": "mesh-forge",
"host_key": "ssh-ed25519 AAAAC3HOSTKEY", "ssh_base": "ssh://git@novox.internal:222/",
"https_base": "https://git.novox.be/"}
for k, v := range want {
if e.Values[k] != v {
t.Errorf("%s = %v, want %v", k, e.Values[k], v)
}
}
if e.VaultName != "" || e.User != "" {
t.Errorf("a public key is no secret; the entry names a credential: %+v", e)
}
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "mesh:g14.nox" {
t.Errorf("registered keys = %v", got)
}
if !r.forge.users["agent-g14"] || len(r.forge.members) != 1 || r.forge.members[0] != "agent-g14" {
t.Errorf("account %v, team members %v", r.forge.users, r.forge.members)
}
if strings.Join(r.runner.args, " ") != "ssh-keyscan -t ed25519 -p 222 127.0.0.1" {
t.Errorf("scan = %v", r.runner.args)
}
}
func TestAConsumerWithoutAKeyGetsNoEntryAndNoAccount(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
r.key("ace", "nox", pubB) // another machine's key is not g14's
out := r.pass()
if r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("an entry was written for a machine that reported no key")
}
if len(r.forge.users) != 0 {
t.Fatalf("an account was made: %v", r.forge.users)
}
if len(out) != 1 || !strings.Contains(out[0].Why, "no key") {
t.Fatalf("the wait should be said: %+v", out)
}
}
func TestAKeyOfAnotherKindIsNotTheMachinesKey(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
b, _ := json.Marshal(map[string]string{"node": "g14", "account": "x", "kind": "session", "public": pubA})
r.svc.KeyChanged("g14.x", b)
r.pass()
if r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("answered with a key that is not a machine's")
}
}
func TestAChangedKeyReplacesTheOldMeshKey(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
r.pass()
r.forge.mu.Lock()
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
r.forge.mu.Unlock()
r.key("g14", "nox", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC nox@g14 (mesh)")
r.pass()
got := strings.Join(r.forge.titles("agent-g14"), ",")
if got != "my own,mesh:g14.nox" {
t.Fatalf("keys = %s", got)
}
for _, k := range r.forge.held["agent-g14"] {
if k["title"] == "mesh:g14.nox" && !strings.Contains(k["key"].(string), "CCCC") {
t.Fatalf("the old key stayed: %v", k)
}
}
}
func TestAConsumerThatNoLongerBindsLosesItsMeshKeysAndKeepsItsAccount(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
r.pass()
r.forge.mu.Lock()
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
r.forge.mu.Unlock()
r.bus.mu.Lock()
delete(r.bus.buckets[provider.AssignmentsBucket], "g14.ssh-client")
r.bus.mu.Unlock()
r.pass()
if r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("the entry stayed")
}
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" {
t.Fatalf("keys = %v", got)
}
if !r.forge.users["agent-g14"] {
t.Fatal("the account was removed")
}
}
func TestAKeyChangeNamesTheConsumersToAnswerAgain(t *testing.T) {
r := newRig(t)
var got []string
r.svc.OnKeys = func(node string) { got = append(got, node) }
r.key("g14", "nox", pubA)
if len(got) != 1 || got[0] != "g14" {
t.Fatalf("answered for %v", got)
}
}
func TestAKeyThatIsNotReadableIsNotKept(t *testing.T) {
r := newRig(t)
r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","kind":"machine"`))
r.svc.KeyChanged("g14.nox", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"PRIVATE KEY"}`))
r.bus.bindClient("g14")
r.pass()
if r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("a value that is no public key was registered")
}
}
func TestTheForgesHostKeyUnreadableMeansNoEntry(t *testing.T) {
r := newRig(t)
r.runner.err = errors.New("connection refused")
r.bus.bindClient("g14")
r.key("g14", "nox", pubA)
r.pass()
if r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("an entry without the forge's host key")
}
}
func TestKnownHostLineIsRewrittenToTheMeshName(t *testing.T) {
line, err := knownHostLine(scanned, "novox.internal", 222)
if err != nil || line != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
t.Fatalf("%q %v", line, err)
}
if _, err := knownHostLine("# nothing\n", "novox.internal", 222); err == nil {
t.Fatal("no key line was accepted")
}
}
func TestKeysWatchedRetriesUntilItAnswersAndPassesOnChanges(t *testing.T) {
r := newRig(t)
src := &fakeKeys{failFirst: 2}
r.svc.Pause = func(time.Duration) {}
r.svc.loaded = false
passes := 0
r.svc.OnKeys = func(string) { passes++ }
if err := r.svc.WatchKeys(context.Background(), src); err != nil {
t.Fatal(err)
}
if !r.svc.loaded {
t.Fatal("the keys are not loaded once the watch has answered")
}
if src.calls != 3 {
t.Fatalf("asked %d times", src.calls)
}
src.send("g14.nox", "put", []byte(`{"node":"g14","account":"nox","kind":"machine","public":"`+pubA+`"}`), false)
if passes != 1 {
t.Fatalf("passes = %d", passes)
}
// What was there when the watch began is taken in, and one pass answers it, not one per key.
src.send("ace.nox", "put", []byte(`{"node":"ace","account":"nox","kind":"machine","public":"`+pubB+`"}`), true)
if passes != 1 {
t.Fatalf("a current value passed on its own: %d", passes)
}
src.send("g14.nox", "delete", nil, false)
if passes != 2 || len(r.svc.keysOf("g14")) != 0 {
t.Fatalf("passes = %d, keys = %v", passes, r.svc.keysOf("g14"))
}
}
type fakeKeys struct {
failFirst int
calls int
handle func(key, op string, value []byte, current bool)
}
func (f *fakeKeys) Watch(handle func(key, op string, value []byte, current bool)) error {
f.calls++
if f.calls <= f.failFirst {
return errors.New("not yet")
}
f.handle = handle
return nil
}
func (f *fakeKeys) send(key, op string, v []byte, current bool) { f.handle(key, op, v, current) }
func TestADeletedKeyEntryRemovesTheMeshKeyAndTheBoundEntryButNotTheOperatorsKey(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
r.pass()
r.forge.mu.Lock()
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
r.forge.mu.Unlock()
r.svc.KeyGone("g14.nox")
r.pass()
if got := r.forge.titles("agent-g14"); len(got) != 1 || got[0] != "my own" {
t.Fatalf("keys = %v", got)
}
if r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("the entry still serves a machine whose key is gone")
}
}
func TestAPassBeforeTheKeysAreLoadedRemovesAndDeletesNothing(t *testing.T) {
r := newRig(t)
r.bus.bindClient("g14")
r.key("g14", "nox", pubA+" nox@g14 (mesh)")
r.pass()
r.forge.mu.Lock()
r.forge.held["agent-g14"] = append(r.forge.held["agent-g14"], map[string]any{"id": int64(99), "title": "my own", "key": pubB})
r.forge.mu.Unlock()
// A restart: the keys are not read yet, so a node with none is not a node that reported none.
r.svc.mu.Lock()
r.svc.keys = map[string]machineKey{}
r.svc.loaded = false
r.svc.mu.Unlock()
out := r.pass()
if got := strings.Join(r.forge.titles("agent-g14"), ","); got != "mesh:g14.nox,my own" {
t.Fatalf("keys = %s", got)
}
if !r.bus.has("g14.ssh-client.forge-account") {
t.Fatal("the entry was deleted before the keys were read")
}
if len(out) != 1 || !strings.Contains(out[0].Why, "not read yet") {
t.Fatalf("the wait should be said: %+v", out)
}
// Loaded, and the node reports none: now its keys go.
r.svc.mu.Lock()
r.svc.loaded = true
r.svc.mu.Unlock()
r.pass()
if got := strings.Join(r.forge.titles("agent-g14"), ","); got != "my own" || r.bus.has("g14.ssh-client.forge-account") {
t.Fatalf("keys = %s", got)
}
}
// Issue 955: a watch the runtime refuses (a grant that comes later) must not end the provider for good.
func TestKeepRunningStartsTheProviderAgainAfterARefusedWatch(t *testing.T) {
r := newRig(t)
var pauses []time.Duration
var said []string
r.svc.Pause = func(d time.Duration) { pauses = append(pauses, d) }
r.svc.cfg.Log = func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }
ctx, stop := context.WithCancel(context.Background())
defer stop()
calls := 0
serving := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
r.svc.KeepRunning(ctx, func(ctx context.Context) error {
calls++
if calls <= 2 {
return errors.New(`watching mesh-controller_assignments: gitea reads no state called "mesh-controller.assignments"`)
}
close(serving)
<-ctx.Done()
return ctx.Err()
})
}()
select {
case <-serving:
case <-time.After(5 * time.Second):
t.Fatal("the provider was not started again after its watch was refused")
}
stop()
<-done
if calls != 3 {
t.Fatalf("run %d times", calls)
}
if len(pauses) != 2 || pauses[0] != 5*time.Second || pauses[1] != 10*time.Second {
t.Fatalf("the backoff is %v", pauses)
}
if len(said) == 0 || !strings.Contains(said[0], "mesh-controller.assignments") {
t.Fatalf("a refusal is said loudly: %v", said)
}
loud := 0
for _, s := range said {
if strings.Contains(s, "stopped") {
loud++
}
}
if loud != 1 {
t.Fatalf("the same refusal is said %d times in one spell: %v", loud, said)
}
}
func TestKeepRunningBacksOffToTwoMinutes(t *testing.T) {
r := newRig(t)
var pauses []time.Duration
r.svc.Pause = func(d time.Duration) { pauses = append(pauses, d) }
ctx, stop := context.WithCancel(context.Background())
calls := 0
r.svc.KeepRunning(ctx, func(context.Context) error {
if calls++; calls == 9 {
stop()
}
return errors.New("refused")
})
last := pauses[len(pauses)-1]
if pauses[0] != 5*time.Second || last != 2*time.Minute {
t.Fatalf("the backoff is %v", pauses)
}
}