agentbbs/internal/forgejo/key422_test.go
Anthony Ettinger 078937109c
Some checks are pending
CI / build (push) Waiting to run
deploy / deploy (push) Waiting to run
test / test (push) Waiting to run
fix(agentgit): register the member's SSH key on every BBS login, not just at signup (#131)
provisionGit returned early when the Forgejo account already existed, so
EnsureKey only ever ran during first provisioning. A member who deleted their
key on git.profullstack.com never got it back: every later BBS login hit the
`if !created { return }` and skipped key registration entirely. The comment on
the web verify path ("key is added on next BBS login") was describing behaviour
that could not happen.

Key registration now runs on every provisionGit call that carries a session
key. EnsureKey was already idempotent — it GETs the account's keys and compares
key material ignoring the comment — so re-running it is free when nothing
changed, and it re-adds a removed key, picks up a rotated one, and backfills
members who joined before AgentGit captured keys. The welcome email stays gated
on `created`, since the one-time password is only meaningful for a new account.

Two things that would have made this unreliable in the new every-login path:

- The key title was the constant "agentbbs". Forgejo rejects a duplicate title
  with 422, so a member who rotated their BBS key would have had the new one
  silently dropped. The title now carries a short fingerprint, so distinct keys
  coexist and the same key stays stable across logins.

- EnsureKey mapped *every* 422 to "already exists" and returned nil. That hid
  genuine rejections forever, which matters far more now the call is on the hot
  path. Only "has been used" bodies are swallowed; a rejected key surfaces with
  Forgejo's own reason so it lands in the logs.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 04:28:18 -07:00

56 lines
1.7 KiB
Go

package forgejo
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// key422Server answers the dedupe GET with an empty list, then returns 422 with
// the given body for the POST.
func key422Server(t *testing.T, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[]`))
return
}
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(body))
}))
}
func TestEnsureKeyTreatsAlreadyUsed422AsBenign(t *testing.T) {
srv := key422Server(t, `{"message":"Key content has been used as non-deploy key"}`)
defer srv.Close()
c := Config{BaseURL: srv.URL, Token: "secret"}
added, err := c.EnsureKey("alice", "agentbbs", aliceKey)
if err != nil {
t.Fatalf("a duplicate key must not be an error, got %v", err)
}
if added {
t.Error("expected added=false for a key already on the account")
}
}
// A 422 that is Forgejo rejecting the key content must surface, not be silently
// swallowed as "already exists" — that is how an unregisterable key stayed
// invisible in the logs forever.
func TestEnsureKeySurfacesRejecting422(t *testing.T) {
srv := key422Server(t, `{"message":"Key content is not a valid SSH key"}`)
defer srv.Close()
c := Config{BaseURL: srv.URL, Token: "secret"}
added, err := c.EnsureKey("alice", "agentbbs", aliceKey)
if err == nil {
t.Fatal("expected an error when Forgejo rejects the key content")
}
if added {
t.Error("expected added=false on rejection")
}
if !strings.Contains(err.Error(), "not a valid SSH key") {
t.Errorf("error should carry Forgejo's reason, got %v", err)
}
}