CI / test (push) Successful in 9m14s
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>
235 lines
9.9 KiB
Go
235 lines
9.9 KiB
Go
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'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)
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|