CI / test (push) Successful in 9m8s
/admin/mcp, for administrators only, since a server's credential reaches whatever it guards: each server's state, who has been granted its tools, its rate, the headers it is sent by name with the sealed ones marked, and its address; adding one with a bearer token or another header kept in the store; testing it with its credential and listing its tools; granting them to a team, project, org, person or key, written into that level's limits beside its other grants and its rate and budget; revoking; changing; replacing the credential; turning off and on; removing; and how a person points an agent at the gateway's /mcp with a key of their own. English and Turkish. The secret's field is named so the audit line redacts it. The admin API's server merge and test are package functions now, adminapi.MCPServerFrom and adminapi.TestMCPServer, shared by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
6.7 KiB
Go
168 lines
6.7 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/http/httputil"
|
||
"net/url"
|
||
"path/filepath"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/mcpclient/mcptest"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||
)
|
||
|
||
// mcpPage mounts /admin/mcp over a store of its own, for a person.
|
||
func mcpPage(t *testing.T, user store.User) (*http.ServeMux, *store.DB, *auditSink) {
|
||
t.Helper()
|
||
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "llmgw.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { _ = st.Close() })
|
||
secrets.SetStore(st.StoredSecret)
|
||
t.Cleanup(func() { secrets.SetStore(nil) })
|
||
p := NewAccountPages(fakeAccounts{user: user}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||
sink := &auditSink{}
|
||
p.SetAudit(sink)
|
||
mux := http.NewServeMux()
|
||
p.RegisterAdminMCP(mux, st, nil)
|
||
return mux, st, sink
|
||
}
|
||
|
||
var mcpAdmin = store.User{ID: "a", Email: "a@example.com", IsAdmin: true}
|
||
|
||
func TestTheMCPPageIsForAdministratorsOnly(t *testing.T) {
|
||
for _, role := range []string{store.RoleViewer, store.RoleOperator, store.RoleBilling} {
|
||
mux, _, _ := mcpPage(t, store.User{ID: "s", Email: "s@example.com", Role: role})
|
||
if w := mirrorGet(t, mux, "/admin/mcp"); w.Code != http.StatusNotFound {
|
||
t.Errorf("a %s got %d", role, w.Code)
|
||
}
|
||
}
|
||
mux, _, _ := mcpPage(t, mcpAdmin)
|
||
body := mirrorGet(t, mux, "/admin/mcp").Body.String()
|
||
if !strings.Contains(body, `href="/admin/mcp" aria-current="page"`) || !strings.Contains(body, "/mcp --header") {
|
||
t.Errorf("the menu should mark the page, and the page say how an agent is pointed at it: %s", body)
|
||
}
|
||
if w := mirrorPost(t, mux, "/admin/mcp/add", url.Values{"name": {"x"}, "url": {"https://x/mcp"}}); w.Code != http.StatusBadRequest {
|
||
t.Errorf("a form without its token got %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAnMCPServerIsAddedTestedAndItsCredentialKeptSealed(t *testing.T) {
|
||
mux, st, sink := mcpPage(t, mcpAdmin)
|
||
up := mcptest.New()
|
||
defer up.Close()
|
||
// What the server is sent, every request: the fake records calls only.
|
||
var auth []string
|
||
target, _ := url.Parse(up.URL)
|
||
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
auth = append(auth, r.Header.Get("Authorization"))
|
||
httputil.NewSingleHostReverseProxy(target).ServeHTTP(w, r)
|
||
}))
|
||
defer front.Close()
|
||
|
||
w := mirrorPost(t, mux, "/admin/mcp/add", url.Values{"csrf": {"tok"}, "name": {"jira"}, "url": {front.URL},
|
||
"description": {"tickets"}, "auth": {"bearer"}, "secret_value": {"s3cret-token"}, "rate": {"30"}})
|
||
body := w.Body.String()
|
||
if !strings.Contains(body, "Added jira") || strings.Contains(body, "s3cret-token") {
|
||
t.Fatalf("not added, or the token shown: %s", body)
|
||
}
|
||
m, err := st.MCPServer(context.Background(), "jira")
|
||
if err != nil || m.HeaderRefs["Authorization"] != "store:mcp/jira/Authorization" || m.RatePerMinute != 30 || !m.Enabled {
|
||
t.Fatalf("stored %+v, %v", m, err)
|
||
}
|
||
for _, e := range sink.events {
|
||
if strings.Contains(e.Detail, "s3cret-token") || strings.Contains(e.Target, "s3cret-token") {
|
||
t.Errorf("the audit line holds the token: %+v", e)
|
||
}
|
||
}
|
||
if !strings.Contains(body, "nobody yet") || !strings.Contains(body, "sealed") {
|
||
t.Errorf("a new server is granted to nobody, its credential sealed: %s", body)
|
||
}
|
||
|
||
// The test asks it for its tools with the sealed token.
|
||
body = mirrorPost(t, mux, "/admin/mcp/jira/test", url.Values{"csrf": {"tok"}}).Body.String()
|
||
if !strings.Contains(body, "jira answers, with 3 tools") || !strings.Contains(body, "<code>jira.echo</code>") {
|
||
t.Fatalf("the test's answer: %s", body)
|
||
}
|
||
if len(auth) == 0 || auth[len(auth)-1] != "Bearer s3cret-token" {
|
||
t.Error("the server was not sent its token")
|
||
}
|
||
|
||
// Off, then removed.
|
||
mirrorPost(t, mux, "/admin/mcp/jira/disable", url.Values{"csrf": {"tok"}})
|
||
if m, _ := st.MCPServer(context.Background(), "jira"); m.Enabled {
|
||
t.Error("still on")
|
||
}
|
||
mirrorPost(t, mux, "/admin/mcp/jira/remove", url.Values{"csrf": {"tok"}})
|
||
if _, err := st.MCPServer(context.Background(), "jira"); err == nil {
|
||
t.Error("still there")
|
||
}
|
||
}
|
||
|
||
func TestAnMCPServersToolsAreGrantedBesideAnotherServers(t *testing.T) {
|
||
mux, st, _ := mcpPage(t, mcpAdmin)
|
||
ctx := context.Background()
|
||
org, _ := st.CreateOrg(ctx, "Acme")
|
||
team, _ := st.CreateTeam(ctx, org, "ml")
|
||
rpm := 60
|
||
if err := st.PutLimits(ctx, store.Limits{Scope: store.ScopeTeam, ScopeID: team, RPM: &rpm, AllowedTools: []string{"jira.*"}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.PutMCPServer(ctx, store.MCPServer{Name: "hr", URL: "https://hr/mcp", Enabled: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
limits := func() store.Limits {
|
||
l, _ := st.LimitsFor(ctx, store.ScopeTeam, team)
|
||
return l
|
||
}
|
||
|
||
body := mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/ml"},
|
||
"tools": {"hr.search, hr.get"}}).Body.String()
|
||
l := limits()
|
||
if !strings.Contains(body, "hr's tools granted to Acme/ml") ||
|
||
!slices.Equal(l.AllowedTools, []string{"jira.*", "hr.search", "hr.get"}) || l.RPM == nil || *l.RPM != 60 {
|
||
t.Fatalf("the team's limits are %+v; jira's grant and its rate should have stayed: %s", l, body)
|
||
}
|
||
if !strings.Contains(body, "team Acme/ml: <code>hr.search, hr.get</code>") {
|
||
t.Errorf("the grant should show on hr's row: %s", body)
|
||
}
|
||
// Another server's tools, or no such team, are refused.
|
||
if body := mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/ml"},
|
||
"tools": {"jira.delete"}}).Body.String(); !strings.Contains(body, "jira.delete is not one of hr's tools") {
|
||
t.Errorf("another server's tool was granted: %s", body)
|
||
}
|
||
if body := mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/none"},
|
||
"tools": {"hr.*"}}).Body.String(); !strings.Contains(body, "There is no Acme/none") {
|
||
t.Errorf("a team that does not exist was granted: %s", body)
|
||
}
|
||
|
||
mirrorPost(t, mux, "/admin/mcp/hr/revoke", url.Values{"csrf": {"tok"}, "scope": {"team"}, "id": {team}})
|
||
if l := limits(); !slices.Equal(l.AllowedTools, []string{"jira.*"}) {
|
||
t.Fatalf("after the revoke the team grants %v", l.AllowedTools)
|
||
}
|
||
}
|
||
|
||
func TestTheMCPPageSpeaksTurkishWhenAsked(t *testing.T) {
|
||
mux, st, _ := mcpPage(t, mcpAdmin)
|
||
_ = st.PutMCPServer(context.Background(), store.MCPServer{Name: "hr", URL: "https://hr/mcp", Enabled: true})
|
||
body := peopleInTurkish(t, mux, http.MethodGet, "/admin/mcp", nil)
|
||
for _, want := range []string{"MCP sunucuları", "henüz kimse", "Sunucu ekle", "Araçlarına yetki ver"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Errorf("missing %q", want)
|
||
}
|
||
}
|
||
for _, not := range []string{"nobody yet", "Add a server"} {
|
||
if strings.Contains(body, not) {
|
||
t.Errorf("English left: %q", not)
|
||
}
|
||
}
|
||
}
|