mcpclient.New previously accepted an empty token and silently omitted the Authorization header at request time. When the env var sourcing the token was missing from a Kubernetes Secret (envFrom doesn't warn on missing keys), this surfaced as an opaque 401 from the upstream MCP server with no log trail — see hyperguild #13 and brain entry "mcpclient-empty-token-silent-401-envfrom-missing-key". mcpclient.New now returns ErrTokenRequired when token is empty. The routing pod's project_create init checks the error and exits with a clear message pointing at routing-secrets, turning a runtime 401 storm into a startup crashloop the operator can fix immediately. Tests pass a dummy "test" token (httptest servers don't enforce bearer auth, so any non-empty value works). Added a regression test asserting empty-token construction returns ErrTokenRequired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
360 lines
12 KiB
Go
360 lines
12 KiB
Go
package project_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/mathiasbq/supervisor/internal/githubclient"
|
|
"github.com/mathiasbq/supervisor/internal/mcpclient"
|
|
"github.com/mathiasbq/supervisor/internal/skills/project"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeGitHub captures POST /user/repos calls.
|
|
type fakeGitHub struct {
|
|
mu sync.Mutex
|
|
Calls []map[string]any
|
|
ReturnError int // 0 = 201 Created, 422 = already exists, etc.
|
|
}
|
|
|
|
func (g *fakeGitHub) handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var args map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&args)
|
|
g.mu.Lock()
|
|
g.Calls = append(g.Calls, args)
|
|
code := g.ReturnError
|
|
g.mu.Unlock()
|
|
switch code {
|
|
case 0:
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"full_name":"mathiasb/x","html_url":"https://github.com/mathiasb/x","clone_url":"https://github.com/mathiasb/x.git"}`))
|
|
case 422:
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"errors":[{"message":"name already exists on this account"}]}`))
|
|
default:
|
|
w.WriteHeader(code)
|
|
_, _ = w.Write([]byte(`{"message":"boom"}`))
|
|
}
|
|
})
|
|
}
|
|
|
|
// fakeGiteaMCP implements just enough of the JSON-RPC tools/call surface
|
|
// to drive project_create end-to-end without an actual gitea-mcp server.
|
|
type fakeGiteaMCP struct {
|
|
mu sync.Mutex
|
|
// Recorded calls in order.
|
|
Calls []recordedCall
|
|
// Per-tool response. Default is a generic success object.
|
|
Responses map[string]any
|
|
// Per-tool error response, takes precedence over Responses.
|
|
Errors map[string]rpcErr
|
|
}
|
|
|
|
type rpcErr struct {
|
|
Code int
|
|
Message string
|
|
}
|
|
|
|
type recordedCall struct {
|
|
Tool string
|
|
Args map[string]any
|
|
}
|
|
|
|
func (f *fakeGiteaMCP) handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID int `json:"id"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
_ = json.Unmarshal(req.Params, &p)
|
|
var args map[string]any
|
|
_ = json.Unmarshal(p.Arguments, &args)
|
|
|
|
f.mu.Lock()
|
|
f.Calls = append(f.Calls, recordedCall{Tool: p.Name, Args: args})
|
|
errResp, hasErr := f.Errors[p.Name]
|
|
var resp any
|
|
if r, ok := f.Responses[p.Name]; ok {
|
|
resp = r
|
|
} else {
|
|
resp = map[string]any{"html_url": "http://gitea.example/" + p.Name}
|
|
}
|
|
f.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if hasErr {
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": req.ID,
|
|
"error": map[string]any{"code": errResp.Code, "message": errResp.Message},
|
|
})
|
|
_, _ = w.Write(body)
|
|
return
|
|
}
|
|
respText, _ := json.Marshal(resp)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": req.ID,
|
|
"result": map[string]any{
|
|
"content": []map[string]any{{"type": "text", "text": string(respText)}},
|
|
},
|
|
})
|
|
_, _ = w.Write(body)
|
|
})
|
|
}
|
|
|
|
func newSkill(t *testing.T, f *fakeGiteaMCP) (*project.Skill, *fakeGitHub) {
|
|
t.Helper()
|
|
srv := httptest.NewServer(f.handler())
|
|
t.Cleanup(srv.Close)
|
|
|
|
gh := &fakeGitHub{}
|
|
ghSrv := httptest.NewServer(gh.handler())
|
|
t.Cleanup(ghSrv.Close)
|
|
|
|
return project.New(project.Config{
|
|
Client: mustClient(t, srv.URL),
|
|
GitHub: githubclient.New("ghp_test").WithBaseURL(ghSrv.URL),
|
|
GiteaOwner: "mathias",
|
|
GitHubOwner: "mathiasb",
|
|
GitHubPAT: "ghp_test",
|
|
InfraRepo: "infra",
|
|
}), gh
|
|
}
|
|
|
|
// newSkillNoGitHub builds a skill with the GitHub client unset — degraded
|
|
// mode where the github-repo-creation step is skipped.
|
|
func newSkillNoGitHub(t *testing.T, f *fakeGiteaMCP) *project.Skill {
|
|
t.Helper()
|
|
srv := httptest.NewServer(f.handler())
|
|
t.Cleanup(srv.Close)
|
|
return project.New(project.Config{
|
|
Client: mustClient(t, srv.URL),
|
|
GiteaOwner: "mathias",
|
|
GitHubOwner: "mathiasb",
|
|
InfraRepo: "infra",
|
|
})
|
|
}
|
|
|
|
// mustClient builds an mcpclient against an httptest server. Uses a
|
|
// non-empty dummy token because httptest servers don't enforce bearer
|
|
// auth, but mcpclient.New now requires non-empty token (see #13).
|
|
func mustClient(t *testing.T, url string) *mcpclient.Client {
|
|
t.Helper()
|
|
c, err := mcpclient.New(url, "test-token")
|
|
require.NoError(t, err)
|
|
return c
|
|
}
|
|
|
|
func happyArgs() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"name":"my-experiment",
|
|
"description":"One-line desc",
|
|
"hypothesis":"We believe X produces Y",
|
|
"folder":"AGENTS",
|
|
"stack":"go-agent",
|
|
"private":true
|
|
}`)
|
|
}
|
|
|
|
func TestProjectCreate_HappyPath(t *testing.T) {
|
|
f := &fakeGiteaMCP{
|
|
Responses: map[string]any{
|
|
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
},
|
|
}
|
|
skill, gh := newSkill(t, f)
|
|
|
|
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.NoError(t, err)
|
|
|
|
var res map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &res))
|
|
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
|
|
assert.Equal(t, "https://github.com/mathiasb/my-experiment", res["github_url"])
|
|
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
|
|
assert.Contains(t, res["next_steps"], "cd ~/dev/AGENTS/my-experiment")
|
|
assert.Contains(t, res["next_steps"], "git remote add origin")
|
|
|
|
// All 4 gitea-mcp calls in order.
|
|
require.Len(t, f.Calls, 4)
|
|
assert.Equal(t, "create_project_from_template", f.Calls[0].Tool)
|
|
assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool)
|
|
assert.Equal(t, "file_write_branch", f.Calls[2].Tool)
|
|
assert.Equal(t, "issue_create", f.Calls[3].Tool)
|
|
|
|
// GitHub repo created between create_project_from_template and mirror.
|
|
require.Len(t, gh.Calls, 1)
|
|
assert.Equal(t, "my-experiment", gh.Calls[0]["name"])
|
|
assert.Equal(t, true, gh.Calls[0]["private"])
|
|
assert.Equal(t, false, gh.Calls[0]["auto_init"])
|
|
|
|
// template selection wired from stack
|
|
assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"])
|
|
// mirror config
|
|
assert.Equal(t, "add", f.Calls[1].Args["action"])
|
|
assert.Equal(t, "https://github.com/mathiasb/my-experiment.git", f.Calls[1].Args["remote_address"])
|
|
assert.Equal(t, "ghp_test", f.Calls[1].Args["remote_password"])
|
|
// infra commit path
|
|
assert.Equal(t, "k3s/staging/my-experiment/namespace.yaml", f.Calls[2].Args["path"])
|
|
assert.Contains(t, f.Calls[2].Args["content"], "name: staging-my-experiment")
|
|
assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild")
|
|
// PAT must NOT appear in the response
|
|
assert.NotContains(t, string(out), "ghp_test")
|
|
|
|
// reached records the github step too.
|
|
reached := res["reached"].([]any)
|
|
assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror", "infra_commit", "issue"}, reached)
|
|
}
|
|
|
|
func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) {
|
|
f := &fakeGiteaMCP{
|
|
Responses: map[string]any{
|
|
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
},
|
|
}
|
|
skill, gh := newSkill(t, f)
|
|
gh.ReturnError = 422 // already exists
|
|
|
|
_, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.NoError(t, err, "422 already-exists should be idempotent")
|
|
require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422")
|
|
}
|
|
|
|
func TestProjectCreate_GitHubFails(t *testing.T) {
|
|
f := &fakeGiteaMCP{}
|
|
skill, gh := newSkill(t, f)
|
|
gh.ReturnError = 401 // bad PAT
|
|
|
|
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.Error(t, err)
|
|
var res map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &res))
|
|
assert.Equal(t, "create_github_repo", res["failed_step"])
|
|
assert.Equal(t, []any{"create_repo"}, res["reached"])
|
|
require.Len(t, f.Calls, 1, "mirror + later steps must not run when github creation fails")
|
|
}
|
|
|
|
func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) {
|
|
f := &fakeGiteaMCP{
|
|
Responses: map[string]any{
|
|
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
},
|
|
}
|
|
skill := newSkillNoGitHub(t, f)
|
|
|
|
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.NoError(t, err)
|
|
var res map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &res))
|
|
// reached does NOT include create_github_repo when client is nil.
|
|
reached := res["reached"].([]any)
|
|
assert.Equal(t, []any{"create_repo", "mirror", "infra_commit", "issue"}, reached)
|
|
}
|
|
|
|
func TestProjectCreate_Idempotent_RepoExists(t *testing.T) {
|
|
f := &fakeGiteaMCP{
|
|
Errors: map[string]rpcErr{
|
|
"create_project_from_template": {Code: -32003, Message: "already exists"},
|
|
},
|
|
Responses: map[string]any{
|
|
"issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"},
|
|
},
|
|
}
|
|
skill, _ := newSkill(t, f)
|
|
|
|
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.NoError(t, err)
|
|
|
|
var res map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &res))
|
|
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"])
|
|
assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"])
|
|
|
|
// Still ran all 4 gitea-mcp steps; idempotent flow falls through.
|
|
require.Len(t, f.Calls, 4)
|
|
}
|
|
|
|
func TestProjectCreate_MirrorFails(t *testing.T) {
|
|
f := &fakeGiteaMCP{
|
|
Errors: map[string]rpcErr{
|
|
"repo_mirror_push": {Code: -32000, Message: "github unreachable"},
|
|
},
|
|
}
|
|
skill, _ := newSkill(t, f)
|
|
|
|
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `"mirror" failed`)
|
|
|
|
var res map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &res))
|
|
assert.Equal(t, "mirror", res["failed_step"])
|
|
reached := res["reached"].([]any)
|
|
assert.Equal(t, []any{"create_repo", "create_github_repo"}, reached)
|
|
|
|
// Steps 1 (create) + 2 (mirror attempt) reached gitea; github made 1 call.
|
|
require.Len(t, f.Calls, 2)
|
|
}
|
|
|
|
func TestProjectCreate_InfraCommitFails(t *testing.T) {
|
|
f := &fakeGiteaMCP{
|
|
Errors: map[string]rpcErr{
|
|
"file_write_branch": {Code: -32000, Message: "write rejected"},
|
|
},
|
|
}
|
|
skill, _ := newSkill(t, f)
|
|
|
|
out, err := skill.Handle(context.Background(), "project_create", happyArgs())
|
|
require.Error(t, err)
|
|
|
|
var res map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &res))
|
|
assert.Equal(t, "infra_commit", res["failed_step"])
|
|
reached := res["reached"].([]any)
|
|
assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror"}, reached)
|
|
require.Len(t, f.Calls, 3)
|
|
}
|
|
|
|
func TestProjectCreate_ValidationErrors(t *testing.T) {
|
|
f := &fakeGiteaMCP{}
|
|
skill, _ := newSkill(t, f)
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{"missing name", `{"description":"d","hypothesis":"h","stack":"go-agent"}`, "name"},
|
|
{"missing description", `{"name":"x","hypothesis":"h","stack":"go-agent"}`, "description"},
|
|
{"missing hypothesis", `{"name":"x","description":"d","stack":"go-agent"}`, "hypothesis"},
|
|
{"bad stack", `{"name":"x","description":"d","hypothesis":"h","stack":"python"}`, "stack"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := skill.Handle(context.Background(), "project_create", json.RawMessage(tc.body))
|
|
require.Error(t, err)
|
|
assert.True(t, strings.Contains(err.Error(), tc.want), "want %q in %v", tc.want, err)
|
|
})
|
|
}
|
|
assert.Empty(t, f.Calls, "no upstream calls should occur on validation failure")
|
|
}
|
|
|
|
func TestProjectCreate_UnknownTool(t *testing.T) {
|
|
f := &fakeGiteaMCP{}
|
|
skill, _ := newSkill(t, f)
|
|
_, err := skill.Handle(context.Background(), "nope", happyArgs())
|
|
require.Error(t, err)
|
|
}
|