Files
llmgw/internal/web/admin_mcp_test.go
T
Ilker ManapandClaude Opus 5.5 ecf5545a7a
CI / test (push) Successful in 9m14s
VS Code's sign-in keeps its place; recordings checked for secrets
From review.
- /profile asked before signing in keeps its query for ten minutes, and
  the sign-in (password or OIDC) returns to it: VS Code's sign-in state
  came back lost, and the confirmation came every time. Only /profile is
  ever returned to.
- The extension keeps an https gateway at https when a link names it by
  plain http (a proxy ending TLS unannounced), so the key is never sent in
  the clear. The install command says --force, since dev builds share a
  version and VS Code kept the old one.
- Every frame of every install recording is checked for a key, a
  password or a private key, not only the page.
- The admin API document again matches the API: f94d902's
  MCPServerRequest.clear was not in it.
- CHANGELOG for this round's fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:40:19 +01:00

235 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"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&#39;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&#39;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)
}
}
}
func TestAGrantBelowALevelThatNamesOtherToolsSaysSoAndKeepsWhatItHad(t *testing.T) {
mux, st, _ := mcpPage(t, mcpAdmin)
ctx := context.Background()
org, _ := st.CreateOrg(ctx, "Acme")
team, _ := st.CreateTeam(ctx, org, "eng")
_ = st.PutLimits(ctx, store.Limits{Scope: store.ScopeOrg, ScopeID: org, AllowedTools: []string{"hr.*"}})
_ = st.PutMCPServer(ctx, store.MCPServer{Name: "jira", URL: "https://jira/mcp", Enabled: true})
body := mirrorPost(t, mux, "/admin/mcp/jira/grant", url.Values{"csrf": {"tok"}, "scope": {"team"}, "which": {"Acme/eng"},
"tools": {"jira.*"}}).Body.String()
if !strings.Contains(body, "But organization Acme names tools and not these") {
t.Errorf("the grant should say the organization keeps jira out of reach: %s", body)
}
if l, _ := st.LimitsFor(ctx, store.ScopeTeam, team); !slices.Equal(l.AllowedTools, []string{"hr.*", "jira.*"}) {
t.Errorf("the team's list is %v; it should keep hr's, which it saw from its organization", l.AllowedTools)
}
}
func TestAnMCPServersFieldsCanBeEmptiedAndItsAddressHoldsNoPassword(t *testing.T) {
mux, st, _ := mcpPage(t, mcpAdmin)
ctx := context.Background()
_ = st.PutMCPServer(ctx, store.MCPServer{Name: "hr", URL: "https://hr/mcp", Description: "people", Enabled: true,
Timeout: 30 * time.Second, RatePerMinute: 10})
mirrorPost(t, mux, "/admin/mcp/hr/edit", url.Values{"csrf": {"tok"}, "url": {"https://hr/mcp"}, "description": {""},
"timeout": {""}, "rate": {""}})
if m, _ := st.MCPServer(ctx, "hr"); m.Description != "" || m.Timeout != 0 || m.RatePerMinute != 0 {
t.Errorf("emptied fields kept: %+v", m)
}
body := mirrorPost(t, mux, "/admin/mcp/add", url.Values{"csrf": {"tok"}, "name": {"db"},
"url": {"https://admin:hunter2@db.internal/mcp"}, "auth": {"none"}}).Body.String()
if _, err := st.MCPServer(ctx, "db"); err == nil || !strings.Contains(body, "user and password in it") {
t.Errorf("an address with a password was kept: %s", body)
}
// A grant to a key that is not there is refused, not said done.
body = mirrorPost(t, mux, "/admin/mcp/hr/grant", url.Values{"csrf": {"tok"}, "scope": {"key"}, "which": {"key-nope"},
"tools": {"hr.*"}}).Body.String()
if !strings.Contains(body, "There is no key-nope") {
t.Errorf("a grant to no key: %s", body)
}
}
func TestASignInComesBackToWhatProfileWasAskedWith(t *testing.T) {
// VS Code's sign-in opens /profile?tool=vscode&state=…; not signed in,
// that went to /login and came back to a bare /profile.
p := NewAccountPages(fakeAccounts{}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
w := httptest.NewRecorder()
p.profile(w, httptest.NewRequest("GET", "/profile?tool=vscode&state=s123", nil))
var kept *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == afterSignInCookie {
kept = c
}
}
if w.Code != http.StatusSeeOther || kept == nil {
t.Fatalf("%d, cookie %v", w.Code, kept)
}
r := httptest.NewRequest("POST", "/login", nil)
r.AddCookie(kept)
if got := afterSignIn(httptest.NewRecorder(), r); got != "/profile?tool=vscode&state=s123" {
t.Errorf("after signing in: %q", got)
}
if got := afterSignIn(httptest.NewRecorder(), httptest.NewRequest("POST", "/login", nil)); got != "/profile" {
t.Errorf("without one: %q", got)
}
}