| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | func TestWebMCPScriptUsesNativeModelContextAndExistingAuth(t *testing.T) { |
| 15 | backend := &fakeBackend{} |
| 16 | app, err := New(backend, Options{}) |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | recorder := httptest.NewRecorder() |
| 21 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/webmcp.js" , nil)) |
| 22 | if recorder.Code != http.StatusOK { |
| 23 | t.Fatalf("status=%d" , recorder.Code) |
| 24 | } |
| 25 | body := recorder.Body.String() |
| 26 | for _, marker := range []string{"document.modelContext" , "navigator.modelContext" , "tiny.list_repositories" , "tiny.read_repository" , "tiny.read_file" , "tiny.read_status" , "tiny.add_job" , "tiny.set_policy" , "structuredContent" , "state.errors" , "tiny.read_management" , "tinySignedFetch" } { |
| 27 | if !strings.Contains(body, marker) { |
| 28 | t.Fatalf("WebMCP script missing %q" , marker) |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | func TestWebMCPQueryReturnsStructuredBackendResult(t *testing.T) { |
| 34 | backend := &webMCPBackend{fakeBackend: fakeBackend{}} |
| 35 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "actor" , nil }}) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | recorder := httptest.NewRecorder() |
| 40 | request := httptest.NewRequest(http.MethodGet, "/webmcp/query?method=browserepos¶ms=" +url.QueryEscape(`[{"limit":2}]` ), nil) |
| 41 | request.Header.Set("content-type" , "application/json" ) |
| 42 | app.ServeHTTP(recorder, request) |
| 43 | if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "repo-visible" ) || backend.method != "browserepos" || backend.actor != "actor" { |
| 44 | t.Fatalf("status=%d body=%s method=%s actor=%s" , recorder.Code, recorder.Body.String(), backend.method, backend.actor) |
| 45 | } |
| 46 | if recorder.Header().Get("cache-control" ) != "no-store, private" { |
| 47 | t.Fatalf("query response must not be cached: %q" , recorder.Header().Get("cache-control" )) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestWebMCPQueryErrorsAreJSON(t *testing.T) { |
| 52 | app, err := New(&fakeBackend{}, Options{Actor: func(*http.Request) (string, error) { |
| 53 | return "" , context.Canceled |
| 54 | }}) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | recorder := httptest.NewRecorder() |
| 59 | request := httptest.NewRequest(http.MethodGet, "/webmcp/query?method=browserepos" , nil) |
| 60 | app.ServeHTTP(recorder, request) |
| 61 | if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Header().Get("content-type" ), "application/json" ) || !strings.Contains(recorder.Body.String(), `"error"` ) { |
| 62 | t.Fatalf("status=%d content-type=%q body=%s" , recorder.Code, recorder.Header().Get("content-type" ), recorder.Body.String()) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func TestWebMCPQueryRejectsMutationMethods(t *testing.T) { |
| 67 | app, err := New(&fakeBackend{}, Options{}) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | recorder := httptest.NewRecorder() |
| 72 | request := httptest.NewRequest(http.MethodGet, "/webmcp/query?method=setpolicy" , nil) |
| 73 | app.ServeHTTP(recorder, request) |
| 74 | if recorder.Code != http.StatusBadRequest { |
| 75 | t.Fatalf("status=%d body=%s" , recorder.Code, recorder.Body.String()) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | type webMCPBackend struct { |
| 80 | fakeBackend |
| 81 | method string |
| 82 | actor string |
| 83 | } |
| 84 | |
| 85 | func (b *webMCPBackend) Query(_ context.Context, method string, _ []json.RawMessage, actor string) (any, error) { |
| 86 | b.method, b.actor = method, actor |
| 87 | return map[string]any{"items" : []any{map[string]any{"identifier" : "repo-visible" }}}, nil |
| 88 | } |
| 89 | |
| 90 | func TestPagesAdvertiseWebMCPScript(t *testing.T) { |
| 91 | app, err := New(&fakeBackend{}, Options{}) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | recorder := httptest.NewRecorder() |
| 96 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/" , nil)) |
| 97 | if !strings.Contains(recorder.Body.String(), `src="/webmcp.js"` ) { |
| 98 | t.Fatal("page does not load WebMCP registration" ) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | func TestHealthPageReportsVersionsAndBrowserTools(t *testing.T) { |
| 103 | backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a" , 64))} |
| 104 | app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }, Version: "1.4.0" , Revision: "abc1234" }) |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | recorder := httptest.NewRecorder() |
| 109 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/health" , nil)) |
| 110 | body := recorder.Body.String() |
| 111 | for _, want := range []string{"<code>1.4.0</code>" , "abc1234" , "<code>" + scriptsVersion + "</code>" , `id="webmcp-status"` , "WebMCP is unavailable" , "signed forms" } { |
| 112 | if recorder.Code != http.StatusOK || !strings.Contains(body, want) { |
| 113 | t.Fatalf("health page missing %q: status=%d" , want, recorder.Code) |
| 114 | } |
| 115 | } |
| 116 | for _, old := range []string{"/tools" , "/manage/status" } { |
| 117 | moved := httptest.NewRecorder() |
| 118 | app.ServeHTTP(moved, httptest.NewRequest(http.MethodGet, old, nil)) |
| 119 | if moved.Code != http.StatusMovedPermanently || moved.Header().Get("Location" ) != "/manage/health" { |
| 120 | t.Fatalf("%s: status=%d location=%q" , old, moved.Code, moved.Header().Get("Location" )) |
| 121 | } |
| 122 | } |
| 123 | } |