dispatch#67 fix #2 needed a read-only CI credential and hit a hard wall: Gitea's own token-creation and admin-user endpoints reject token auth outright -- POST /users/{u}/tokens returns 'auth required', POST /admin/users returns 'token does not have at least one of required scope(s), required=[write:admin]' -- for ANY existing token, by design. An existing token cannot mint another token or create an account; the only way through either endpoint is real basic auth as an actual site admin. That is exactly what a sandboxed agent session cannot safely hold. internal/adminbootstrap: the core bootstrap, HTTP-only, fully testable against a fake transport (5 tests, no network). Given real admin username/password, it creates the target account (or resets its password if it already exists -- deliberately unconditional, gated behind a required ConfirmServiceAccount flag since that reset is only safe for a dedicated bot account), mints it a scoped token by authenticating AS that account (Gitea requires this specifically -- an admin cannot mint on another account's behalf), grants it read-only collaborator access on named repos, and stores the result via a SecretStore -- never returning the token or either password to the caller. internal/localtools: the MCP tool wrapper (mint_service_account_token) and the real op-CLI-backed SecretStore (token passed via stdin, never argv, per the estate's own secret-handling rule). Deliberately its OWN package, not internal/tools -- this capability is far too dangerous to risk it ever being wired into RegisterAll and reachable from the shared HTTP gitea-mcp server, even behind OIDC. internal/mcp/stdio.go: a stdio JSON-RPC transport (one message per line, standard MCP stdio framing) for the existing registry.Tool interface -- the only transport gitea-mcp had before this was HTTP (server.go), which a sandboxed session can reach but a real admin password never should be piped through. cmd/gitea-admin-mcp: the binary. Meant to be spawned by a LOCAL Claude Code session (.mcp.json stdio server) or run by hand, via 'op run --env-file ~/.op-env' so admin credentials arrive just-in-time and never touch a sandboxed session. 11 new tests total, all passing under -race. Nothing in cmd/gitea-mcp (the shared HTTP server) changed.
111 lines
3.8 KiB
Go
111 lines
3.8 KiB
Go
package localtools_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/adminbootstrap"
|
|
"git.d-ma.be/mathias/gitea-mcp/internal/localtools"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// This tool lives in its own package, never internal/tools, on purpose: it
|
|
// resets whatever password its target account has, which is far too
|
|
// dangerous a capability to risk it ever being wired into RegisterAll and
|
|
// exposed on the shared HTTP gitea-mcp server. Only cmd/gitea-admin-mcp (a
|
|
// separate, local-only, stdio binary) imports this package.
|
|
|
|
type fakeBootstrapper struct {
|
|
gotReq adminbootstrap.Request
|
|
result adminbootstrap.Result
|
|
err error
|
|
}
|
|
|
|
func (f *fakeBootstrapper) Run(_ context.Context, req adminbootstrap.Request) (adminbootstrap.Result, error) {
|
|
f.gotReq = req
|
|
return f.result, f.err
|
|
}
|
|
|
|
func TestMintServiceAccountToken_ParsesArgsAndCallsBootstrapper(t *testing.T) {
|
|
fb := &fakeBootstrapper{result: adminbootstrap.Result{
|
|
Username: "dispatch-bot", Created: true,
|
|
SecretRef: "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token",
|
|
}}
|
|
tool := localtools.NewMintServiceAccountToken(fb)
|
|
|
|
args, _ := json.Marshal(map[string]any{
|
|
"username": "dispatch-bot",
|
|
"email": "dispatch@d-ma.be",
|
|
"scopes": []string{"read:repository", "read:issue"},
|
|
"token_name": "dispatch-ci-readonly",
|
|
"secret_name": "gitea-dispatch-bot-ci-readonly",
|
|
"grant_read_repos": []string{"mathias/cobalt-dingo"},
|
|
"confirm_service_account": true,
|
|
})
|
|
|
|
out, err := tool.Call(context.Background(), args)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "dispatch-bot", fb.gotReq.Username)
|
|
assert.Equal(t, []string{"read:repository", "read:issue"}, fb.gotReq.Scopes)
|
|
assert.Equal(t, true, fb.gotReq.ConfirmServiceAccount)
|
|
require.Equal(t, []adminbootstrap.RepoRef{{Owner: "mathias", Name: "cobalt-dingo"}}, fb.gotReq.GrantReadRepos)
|
|
|
|
var result map[string]any
|
|
require.NoError(t, json.Unmarshal(out, &result))
|
|
assert.Equal(t, "op://AgentSecrets/gitea-dispatch-bot-ci-readonly/token", result["SecretRef"])
|
|
// The tool's own output must never carry a raw token or password field --
|
|
// adminbootstrap.Result structurally can't (it has none), which this pins.
|
|
_, hasToken := result["Token"]
|
|
_, hasPassword := result["Password"]
|
|
assert.False(t, hasToken)
|
|
assert.False(t, hasPassword)
|
|
}
|
|
|
|
func TestMintServiceAccountToken_RejectsMalformedRepoRef(t *testing.T) {
|
|
fb := &fakeBootstrapper{}
|
|
tool := localtools.NewMintServiceAccountToken(fb)
|
|
|
|
args, _ := json.Marshal(map[string]any{
|
|
"username": "dispatch-bot",
|
|
"email": "dispatch@d-ma.be",
|
|
"scopes": []string{"read:repository"},
|
|
"token_name": "x",
|
|
"secret_name": "x",
|
|
"grant_read_repos": []string{"not-owner-slash-repo"},
|
|
"confirm_service_account": true,
|
|
})
|
|
|
|
_, err := tool.Call(context.Background(), args)
|
|
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "owner/repo")
|
|
}
|
|
|
|
func TestMintServiceAccountToken_PropagatesBootstrapperError(t *testing.T) {
|
|
fb := &fakeBootstrapper{err: assert.AnError}
|
|
tool := localtools.NewMintServiceAccountToken(fb)
|
|
|
|
args, _ := json.Marshal(map[string]any{
|
|
"username": "dispatch-bot",
|
|
"email": "dispatch@d-ma.be",
|
|
"scopes": []string{"read:repository"},
|
|
"token_name": "x",
|
|
"secret_name": "x",
|
|
"confirm_service_account": true,
|
|
})
|
|
|
|
_, err := tool.Call(context.Background(), args)
|
|
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestMintServiceAccountToken_Descriptor(t *testing.T) {
|
|
tool := localtools.NewMintServiceAccountToken(&fakeBootstrapper{})
|
|
d := tool.Descriptor()
|
|
assert.Equal(t, "mint_service_account_token", d.Name)
|
|
assert.NotEmpty(t, d.Description)
|
|
}
|