diff --git a/modules/gitea/cmd/agent-token/token.go b/modules/gitea/cmd/agent-token/token.go index a827312f..e427b632 100644 --- a/modules/gitea/cmd/agent-token/token.go +++ b/modules/gitea/cmd/agent-token/token.go @@ -17,6 +17,7 @@ import ( "golang.org/x/crypto/nacl/box" + "gitea/internal/forgeaccount" "gitea/internal/forgehttp" ) @@ -31,7 +32,7 @@ const ( // TokenName is the token's name in the agent account's list, found and deleted by it at the next mint. TokenName = "mesh-agent-read" // AccountPrefix names an agent's account for its node (ADR 0365 §1). - AccountPrefix = "agent-" + AccountPrefix = forgeaccount.AccountPrefix // vaultModule is the vault's module: its account is `.mesh-vault` (ADR 0259 §8's runs-as). vaultModule = "mesh-vault" // deadline bounds one call, inside the vault's ask. @@ -43,22 +44,12 @@ const ( // the agents team's (a write team on every repository); main takes no push whatever the token (ADR 0398 §2). var TokenScopes = []string{"write:repository", "write:issue"} +// The agents team and its shape are the forgeaccount package's (ADR 0398 §1). const ( - // TeamOrg is the organisation whose repositories are the mesh's. - TeamOrg = "novox" - // TeamName is the one team every agent's account is in (ADR 0398 §1). - TeamName = "agents" + TeamOrg = forgeaccount.TeamOrg + TeamName = forgeaccount.TeamName ) -// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the -// organisation and no more, the shape of the forge tools' team (tools-account.ts). -func teamShape() map[string]any { - return map[string]any{ - "name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false, - "units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"}, - } -} - var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) // fromTheVault refuses every caller but the vault's own account (ADR 0349 clause 10): whoever else could call this @@ -206,10 +197,10 @@ func (m *Minter) Answer(ask Ask) (Answer, error) { continue } account := AccountPrefix + h.Node - if err := m.ensureAccount(ctx, account); err != nil { + if err := m.accounts().EnsureAccount(ctx, account); err != nil { return Answer{}, err } - if err := m.ensureInTeam(ctx, account); err != nil { + if err := m.accounts().EnsureInTeam(ctx, account); err != nil { return Answer{}, err } token, err := m.mint(ctx, account) @@ -274,91 +265,6 @@ func credentialLines(account, token string, origins []string) (string, error) { return strings.Join(lines, "\n") + "\n", nil } -// gitea's account as the admin reads it. -type forgeUser struct { - IsAdmin bool `json:"is_admin"` - Active bool `json:"active"` - ProhibitLogin bool `json:"prohibit_login"` - Restricted bool `json:"restricted"` -} - -// ensureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never -// an administrator, active, able to use its token. Its team is ensureInTeam's. -func (m *Minter) ensureAccount(ctx context.Context, account string) error { - var u forgeUser - err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) - switch { - case errors.Is(err, forgehttp.ErrNotFound): - password := m.password() - err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{ - "username": account, "email": account + "@noreply.localhost", "password": password, - "must_change_password": false, "visibility": "private", - }, nil) - password = "" - if err != nil { - return fmt.Errorf("the account %s could not be made: %w", account, err) - } - m.say("made the forge account %s for that machine's agent, in no team yet", account) - return nil - case err != nil: - return fmt.Errorf("the account %s could not be read: %w", account, err) - } - if u.IsAdmin || !u.Active || u.ProhibitLogin { - if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{ - "login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false, - }, nil); err != nil { - return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err) - } - m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again", - account, u.IsAdmin, u.Active, u.ProhibitLogin) - } - return nil -} - -// ensureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the -// account to it; an account already in it stays (ADR 0398 §1, issue 948). -func (m *Minter) ensureInTeam(ctx context.Context, account string) error { - var id int64 - for page := 1; page <= 20 && id == 0; page++ { - var teams []struct { - ID int64 `json:"id"` - Name string `json:"name"` - } - if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil { - return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err) - } - for _, t := range teams { - if t.Name == TeamName { - id = t.ID - } - } - if len(teams) < 50 { - break - } - } - if id != 0 { - if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil { - return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err) - } - } else { - var made struct { - ID int64 `json:"id"` - } - if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil { - return fmt.Errorf("the team %s could not be made: %w", TeamName, err) - } - id = made.ID - m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg) - } - if id == 0 { - return fmt.Errorf("the forge answered no id for the team %s", TeamName) - } - if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil { - return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err) - } - return nil -} - type forgeToken struct { ID int64 `json:"id"` Name string `json:"name"` @@ -392,13 +298,9 @@ func (m *Minter) mint(ctx context.Context, account string) (string, error) { return strings.TrimSpace(made.SHA1), nil } -func (m *Minter) password() string { - if m.Password != nil { - return m.Password() - } - b := make([]byte, 24) - _, _ = rand.Read(b) - return base64.RawURLEncoding.EncodeToString(b) +// accounts is the forge's accounts as this minter reaches them (ADR 0400 §3: one place says what an agent's account is). +func (m *Minter) accounts() *forgeaccount.Accounts { + return &forgeaccount.Accounts{Forge: m.Forge, Password: m.Password, Log: m.Log} } func (m *Minter) say(format string, a ...any) { @@ -408,3 +310,6 @@ func (m *Minter) say(format string, a ...any) { } fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// forgeUser is gitea's account as the admin reads it (the forgeaccount package's). +type forgeUser = forgeaccount.User diff --git a/modules/gitea/internal/forgeaccount/account.go b/modules/gitea/internal/forgeaccount/account.go new file mode 100644 index 00000000..57ec5776 --- /dev/null +++ b/modules/gitea/internal/forgeaccount/account.go @@ -0,0 +1,145 @@ +// Package forgeaccount is the forge accounts the mesh makes for its agents (novox/hq ADR 0365 clauses 1 and 3, ADR +// 0398 §1, ADR 0399, ADR 0400; issue 955): an account of the agent's own, no administrator, in the agents write +// team. Both the token tool (cmd/agent-token) and the forge-account provider use it, so there is one place that says +// what an agent's account is. +package forgeaccount + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "os" + + "gitea/internal/forgehttp" +) + +const ( + // AccountPrefix names an agent's account for its node (ADR 0365 §1). + AccountPrefix = "agent-" + // TeamOrg is the organisation whose repositories are the mesh's. + TeamOrg = "novox" + // TeamName is the one team every agent's account is in (ADR 0398 §1). + TeamName = "agents" +) + +// teamShape is what the agents team grants: write on code, issues and pull requests of every repository of the +// organisation and no more, the shape of the forge tools' team (tools-account.ts). +func teamShape() map[string]any { + return map[string]any{ + "name": TeamName, "permission": "write", "includes_all_repositories": true, "can_create_org_repo": false, + "units_map": map[string]string{"repo.code": "write", "repo.issues": "write", "repo.pulls": "write"}, + } +} + +// User is gitea's account as the admin reads it. +type User struct { + IsAdmin bool `json:"is_admin"` + Active bool `json:"active"` + ProhibitLogin bool `json:"prohibit_login"` + Restricted bool `json:"restricted"` +} + +// Accounts makes and keeps the agents' accounts with the administrator's login. +type Accounts struct { + Forge forgehttp.Client + // Password makes the password an account is created with: random, sent once and kept nowhere. + Password func() string + Log func(format string, a ...any) +} + +// EnsureAccount makes the agent's account, plain, when there is none, and puts back what was changed by hand: never +// an administrator, active, able to use its token. Its team is EnsureInTeam's. +func (m *Accounts) EnsureAccount(ctx context.Context, account string) error { + var u User + err := m.Forge.GetJSON(ctx, "/api/v1/users/"+account, &u) + switch { + case errors.Is(err, forgehttp.ErrNotFound): + password := m.password() + err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "username": account, "email": account + "@noreply.localhost", "password": password, + "must_change_password": false, "visibility": "private", + }, nil) + password = "" + if err != nil { + return fmt.Errorf("the account %s could not be made: %w", account, err) + } + m.say("made the forge account %s for that machine's agent, in no team yet", account) + return nil + case err != nil: + return fmt.Errorf("the account %s could not be read: %w", account, err) + } + if u.IsAdmin || !u.Active || u.ProhibitLogin { + if err := m.Forge.SendJSON(ctx, http.MethodPatch, "/api/v1/admin/users/"+account, map[string]any{ + "login_name": account, "source_id": 0, "admin": false, "active": true, "prohibit_login": false, + }, nil); err != nil { + return fmt.Errorf("the account %s could not be made a plain account again: %w", account, err) + } + m.say("the account %s was changed by hand (administrator %t, active %t, login prohibited %t); made a plain account again", + account, u.IsAdmin, u.Active, u.ProhibitLogin) + } + return nil +} + +// EnsureInTeam makes the agents team when there is none, puts a team that is there back to its shape, and adds the +// account to it; an account already in it stays (ADR 0398 §1, issue 948). +func (m *Accounts) EnsureInTeam(ctx context.Context, account string) error { + var id int64 + for page := 1; page <= 20 && id == 0; page++ { + var teams []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } + if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/orgs/%s/teams?limit=50&page=%d", TeamOrg, page), &teams); err != nil { + return fmt.Errorf("the teams of %s could not be read: %w", TeamOrg, err) + } + for _, t := range teams { + if t.Name == TeamName { + id = t.ID + } + } + if len(teams) < 50 { + break + } + } + if id != 0 { + if err := m.Forge.SendJSON(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/teams/%d", id), teamShape(), nil); err != nil { + return fmt.Errorf("the team %s could not be put back to its shape: %w", TeamName, err) + } + } else { + var made struct { + ID int64 `json:"id"` + } + if err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/orgs/"+TeamOrg+"/teams", teamShape(), &made); err != nil { + return fmt.Errorf("the team %s could not be made: %w", TeamName, err) + } + id = made.ID + m.say("made the forge team %s in %s: write on every repository", TeamName, TeamOrg) + } + if id == 0 { + return fmt.Errorf("the forge answered no id for the team %s", TeamName) + } + if err := m.Forge.SendJSON(ctx, http.MethodPut, fmt.Sprintf("/api/v1/teams/%d/members/%s", id, account), nil, nil); err != nil { + return fmt.Errorf("the account %s could not be added to the team %s: %w", account, TeamName, err) + } + return nil +} + +func (m *Accounts) password() string { + if m.Password != nil { + return m.Password() + } + b := make([]byte, 24) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func (m *Accounts) say(format string, a ...any) { + if m.Log != nil { + m.Log(format, a...) + return + } + fmt.Fprintf(os.Stderr, format+"\n", a...) +} diff --git a/modules/gitea/internal/forgeaccount/keys.go b/modules/gitea/internal/forgeaccount/keys.go new file mode 100644 index 00000000..018241b9 --- /dev/null +++ b/modules/gitea/internal/forgeaccount/keys.go @@ -0,0 +1,132 @@ +package forgeaccount + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "gitea/internal/forgehttp" +) + +// TitlePrefix starts the title of every key the mesh registers; a key without it is the operator's and is never +// touched (ADR 0399 §2, ADR 0400). +const TitlePrefix = "mesh:" + +// KeyTitle is the title of a machine account's key: `mesh:.`. +func KeyTitle(node, account string) string { return TitlePrefix + node + "." + account } + +// WantedKey is a public key the mesh wants registered, under its title. +type WantedKey struct { + Title string + // Key is the public key line: type, base64 and a comment. + Key string +} + +// Key is a key the forge holds for an account. +type Key struct { + ID int64 `json:"id"` + Title string `json:"title"` + Key string `json:"key"` +} + +// material is a public key line without its comment: its type and its base64, which is what makes it the same key. +func material(line string) string { + f := strings.Fields(line) + if len(f) < 2 { + return strings.TrimSpace(line) + } + return f[0] + " " + f[1] +} + +// Keys lists the SSH keys the account holds. +func (m *Accounts) Keys(ctx context.Context, user string) ([]Key, error) { + var all []Key + for page := 1; page <= 20; page++ { + var got []Key + if err := m.Forge.GetJSON(ctx, fmt.Sprintf("/api/v1/users/%s/keys?limit=50&page=%d", user, page), &got); err != nil { + return nil, fmt.Errorf("the keys of %s could not be read: %w", user, err) + } + all = append(all, got...) + if len(got) < 50 { + break + } + } + return all, nil +} + +// AddKey registers a key for the account, one it may write with. +func (m *Accounts) AddKey(ctx context.Context, user string, k WantedKey) error { + err := m.Forge.SendJSON(ctx, http.MethodPost, "/api/v1/admin/users/"+user+"/keys", + map[string]any{"title": k.Title, "key": k.Key, "read_only": false}, nil) + if err != nil { + return fmt.Errorf("the key %s could not be added for %s: %w", k.Title, user, err) + } + return nil +} + +// DeleteKey removes one key of the account, by its id. +func (m *Accounts) DeleteKey(ctx context.Context, user string, id int64) error { + err := m.Forge.SendJSON(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/admin/users/%s/keys/%d", user, id), nil, nil) + if err != nil && !errors.Is(err, forgehttp.ErrNotFound) { + return fmt.Errorf("the key %d of %s could not be deleted: %w", id, user, err) + } + return nil +} + +// EnsureKeys registers each wanted key the account does not hold and removes the keys the mesh registered +// (title starting `mesh:`) that are no longer wanted. A key whose title does not start `mesh:` is the operator's and +// is never touched, and a wanted key the account already holds under another title is left as it is (ADR 0399 §2). +// A key is added before another title's is removed, so an account is not left without one. +func (m *Accounts) EnsureKeys(ctx context.Context, user string, wanted []WantedKey) error { + held, err := m.Keys(ctx, user) + if err != nil { + return err + } + have := map[string]bool{} + for _, k := range held { + have[material(k.Key)] = true + } + want := map[string]bool{} + for _, w := range wanted { + want[material(w.Key)] = true + } + gone := map[int64]bool{} + remove := func(k Key) error { + if gone[k.ID] { + return nil + } + gone[k.ID] = true + if err := m.DeleteKey(ctx, user, k.ID); err != nil { + return err + } + m.say("removed the key %s of %s: it is no longer wanted", k.Title, user) + return nil + } + for _, w := range wanted { + if have[material(w.Key)] { + continue + } + // The forge holds a title once: the mesh's key of the same title, now another key, goes first. + for _, k := range held { + if k.Title == w.Title && strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] { + if err := remove(k); err != nil { + return err + } + } + } + if err := m.AddKey(ctx, user, w); err != nil { + return err + } + m.say("registered the key %s for %s", w.Title, user) + } + for _, k := range held { + if strings.HasPrefix(k.Title, TitlePrefix) && !want[material(k.Key)] { + if err := remove(k); err != nil { + return err + } + } + } + return nil +} diff --git a/modules/gitea/internal/forgeaccount/keys_test.go b/modules/gitea/internal/forgeaccount/keys_test.go new file mode 100644 index 00000000..105c871a --- /dev/null +++ b/modules/gitea/internal/forgeaccount/keys_test.go @@ -0,0 +1,192 @@ +package forgeaccount + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + + "gitea/internal/forgehttp" +) + +type heldKey struct { + ID int64 `json:"id"` + Title string `json:"title"` + Key string `json:"key"` +} + +// keyForge is the part of gitea's API the keys use, as the administrator's login reaches it (issue 955 F2). +type keyForge struct { + mu sync.Mutex + keys map[string][]heldKey + next int64 + calls []string + posts []map[string]any +} + +var keysPath = regexp.MustCompile(`^/api/v1/(?:admin/)?users/([^/]+)/keys(?:/(\d+))?$`) + +func (f *keyForge) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, r.Method+" "+r.URL.Path) + m := keysPath.FindStringSubmatch(r.URL.Path) + if m == nil { + http.NotFound(w, r) + return + } + user := m[1] + reply := func(status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } + } + switch { + case r.Method == http.MethodGet && m[2] == "" && !strings.Contains(r.URL.Path, "/admin/"): + reply(200, f.keys[user]) + case r.Method == http.MethodPost && m[2] == "" && strings.Contains(r.URL.Path, "/admin/"): + body, _ := io.ReadAll(r.Body) + var in map[string]any + _ = json.Unmarshal(body, &in) + f.posts = append(f.posts, in) + key, _ := in["key"].(string) + for _, k := range f.keys[user] { + if k.Key == key { + reply(422, map[string]any{"message": "Key content has been used as non-deploy key"}) + return + } + } + title, _ := in["title"].(string) + for _, k := range f.keys[user] { + if k.Title == title { + reply(422, map[string]any{"message": "Key title has been used"}) + return + } + } + f.next++ + f.keys[user] = append(f.keys[user], heldKey{ID: f.next, Title: title, Key: key}) + reply(201, map[string]any{"id": f.next}) + case r.Method == http.MethodDelete && m[2] != "" && strings.Contains(r.URL.Path, "/admin/"): + var kept []heldKey + for _, k := range f.keys[user] { + if fmt.Sprint(k.ID) != m[2] { + kept = append(kept, k) + } + } + f.keys[user] = kept + reply(204, nil) + default: + http.Error(w, "unexpected "+r.Method+" "+r.URL.Path, 400) + } +} + +const ( + keyA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + keyB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +) + +func keyAccounts(t *testing.T, f *keyForge) *Accounts { + t.Helper() + srv := httptest.NewServer(f) + t.Cleanup(srv.Close) + pw := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(pw, []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + return &Accounts{Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Log: func(string, ...any) {}} +} + +func TestEnsureKeysAddsTheWantedKeyUnderItsTitle(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{}} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}); err != nil { + t.Fatal(err) + } + if len(f.posts) != 1 || f.posts[0]["title"] != "mesh:g14.nox" || f.posts[0]["read_only"] != false || + f.posts[0]["key"] != keyA+" nox@g14 (mesh)" { + t.Fatalf("posts = %v", f.posts) + } +} + +func TestEnsureKeysAgainChangesNothing(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{}} + a := keyAccounts(t, f) + want := []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}} + for range 2 { + if err := a.EnsureKeys(t.Context(), "agent-g14", want); err != nil { + t.Fatal(err) + } + } + if len(f.posts) != 1 || len(f.keys["agent-g14"]) != 1 { + t.Fatalf("posts = %v, held = %v", f.posts, f.keys) + } +} + +func TestEnsureKeysReplacesAChangedMeshKeyOfTheSameTitle(t *testing.T) { + // The forge refuses a title held twice, so the old key goes first. + f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 7, Title: "mesh:g14.nox", Key: keyA}}}, next: 7} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyB + " nox@g14 (mesh)"}}); err != nil { + t.Fatal(err) + } + held := f.keys["agent-g14"] + if len(held) != 1 || !strings.HasPrefix(held[0].Key, keyB) { + t.Fatalf("held = %v", held) + } +} + +func TestEnsureKeysRemovesAMeshKeyOfAnotherTitleAfterTheNewOneIsIn(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 7, Title: "mesh:g14.old", Key: keyA}}}, next: 7} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyB}}); err != nil { + t.Fatal(err) + } + var post, del int + for i, c := range f.calls { + if strings.HasPrefix(c, "POST") { + post = i + } + if strings.HasPrefix(c, "DELETE") { + del = i + } + } + if post == 0 || del == 0 || post > del || len(f.keys["agent-g14"]) != 1 || f.keys["agent-g14"][0].Title != "mesh:g14.nox" { + t.Fatalf("calls = %v, held = %v", f.calls, f.keys) + } +} + +func TestEnsureKeysNeverTouchesAKeyTheOperatorAdded(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": { + {ID: 3, Title: "my laptop", Key: keyB}, + {ID: 7, Title: "mesh:g14.nox", Key: keyA}, + }}, next: 7} + a := keyAccounts(t, f) + // Nothing wanted any more: the mesh's key goes, the operator's stays. + if err := a.EnsureKeys(t.Context(), "agent-g14", nil); err != nil { + t.Fatal(err) + } + held := f.keys["agent-g14"] + if len(held) != 1 || held[0].Title != "my laptop" { + t.Fatalf("held = %v", held) + } +} + +func TestEnsureKeysLeavesAWantedKeyTheOperatorAlreadyHoldsUnderAnotherTitle(t *testing.T) { + f := &keyForge{keys: map[string][]heldKey{"agent-g14": {{ID: 3, Title: "by hand", Key: keyA}}}, next: 3} + a := keyAccounts(t, f) + if err := a.EnsureKeys(t.Context(), "agent-g14", []WantedKey{{Title: "mesh:g14.nox", Key: keyA + " nox@g14 (mesh)"}}); err != nil { + t.Fatal(err) + } + if len(f.posts) != 0 || len(f.keys["agent-g14"]) != 1 || f.keys["agent-g14"][0].Title != "by hand" { + t.Fatalf("posts = %v, held = %v", f.posts, f.keys) + } +}