1package main23import (4 "bytes"5 "context"6 "crypto/sha256"7 "encoding/base64"8 "encoding/hex"9 "encoding/json"10 "errors"11 "io"12 "net/http"13 "net/http/httptest"14 "strings"15 "sync"16 "testing"1718 "github.com/FelineStateMachine/tinyrelay/protocol/nostr"19 "github.com/FelineStateMachine/tinyrelay/tinyagent/client"20)2122// diagnoseRelay serves the three routes diagnose probes: readiness, the23// NIP-11 document and the signed browsegrant call. Each answer is scripted.24type diagnoseRelay struct {25 t *testing.T26 pubkey string27 prefix string28 readyz int29 nip11 int30 rpcStatus int31 rpcBody string32 server *httptest.Server33 mu sync.Mutex34 requests []string35 params string36}3738func newDiagnoseRelay(t *testing.T, prefix, pubkey string) *diagnoseRelay {39 t.Helper()40 d := &diagnoseRelay{t: t, pubkey: pubkey, prefix: prefix, readyz: http.StatusOK, nip11: http.StatusOK, rpcStatus: http.StatusOK, rpcBody: `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{"pubkey":"` + pubkey + `","name":"helper"},"allows":{"kinds":[9],"rooms":["build"],"repos":[],"wiki":"","jobs":"","sites":[]},"rate":60,"expires":4102444800,"missing":{"rooms":[],"kinds":[]}}}`}41 d.server = httptest.NewServer(d)42 t.Cleanup(d.server.Close)43 return d44}4546func (d *diagnoseRelay) base() string { return d.server.URL + d.prefix }4748func (d *diagnoseRelay) seen() []string {49 d.mu.Lock()50 defer d.mu.Unlock()51 return append([]string(nil), d.requests...)52}5354func (d *diagnoseRelay) ServeHTTP(w http.ResponseWriter, r *http.Request) {55 d.mu.Lock()56 d.requests = append(d.requests, r.Method+" "+r.URL.Path)57 d.mu.Unlock()58 w.Header().Set("Content-Type", "application/json")59 switch {60 case r.Method == http.MethodGet && r.URL.Path == d.prefix+"/readyz":61 if d.readyz == 0 {62 http.NotFound(w, r)63 return64 }65 w.WriteHeader(d.readyz)66 _, _ = io.WriteString(w, `{"status":"ready"}`)67 case r.Method == http.MethodGet && r.URL.Path == d.prefix+"/" && strings.Contains(r.Header.Get("Accept"), "application/nostr+json"):68 w.WriteHeader(d.nip11)69 _, _ = io.WriteString(w, `{"name":"test"}`)70 case r.Method == http.MethodPost && r.URL.Path == d.prefix+"/manage/rpc":71 body, _ := io.ReadAll(r.Body)72 if !strings.Contains(r.Header.Get("Content-Type"), "application/nostr+json+rpc") {73 w.WriteHeader(http.StatusBadRequest)74 _, _ = io.WriteString(w, `{"error":"invalid: content type"}`)75 return76 }77 if err := d.checkProof(r, body); err != nil {78 w.WriteHeader(http.StatusUnauthorized)79 _, _ = io.WriteString(w, `{"error":"auth-required: `+err.Error()+`"}`)80 return81 }82 var call struct {83 Method string `json:"method"`84 Params []json.RawMessage `json:"params"`85 }86 if err := json.Unmarshal(body, &call); err != nil || call.Method != "browsegrant" {87 w.WriteHeader(http.StatusBadRequest)88 _, _ = io.WriteString(w, `{"error":"invalid: expected browsegrant"}`)89 return90 }91 d.mu.Lock()92 if len(call.Params) > 0 {93 d.params = string(call.Params[0])94 }95 d.mu.Unlock()96 w.WriteHeader(d.rpcStatus)97 _, _ = io.WriteString(w, d.rpcBody)98 default:99 http.NotFound(w, r)100 }101}102103func (d *diagnoseRelay) checkProof(r *http.Request, body []byte) error {104 header := r.Header.Get("Authorization")105 if !strings.HasPrefix(header, "Nostr ") {106 return errors.New("missing Nostr authorization")107 }108 raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, "Nostr "))109 if err != nil {110 return err111 }112 e, err := nostr.Parse(raw)113 if err != nil {114 return err115 }116 if err := nostr.Validate(e); err != nil {117 return err118 }119 sum := sha256.Sum256(body)120 switch {121 case e.Kind != 27235:122 return errors.New("wrong kind")123 case e.PubKey != d.pubkey:124 return errors.New("wrong signer")125 case nostr.Tag(e, "u") != d.server.URL+r.URL.RequestURI():126 return errors.New("u tag " + nostr.Tag(e, "u"))127 case nostr.Tag(e, "method") != http.MethodPost:128 return errors.New("method tag")129 case nostr.Tag(e, "payload") != hex.EncodeToString(sum[:]):130 return errors.New("payload tag")131 }132 return nil133}134135func runDiagnose(t *testing.T, relay string, extra ...string) (diagnosis, error) {136 t.Helper()137 var out bytes.Buffer138 args := append([]string{"diagnose", "--relay", relay, "--key-env", "TINY_MCP_TEST_KEY"}, extra...)139 err := run(args, strings.NewReader(""), &out, io.Discard)140 var report diagnosis141 if out.Len() > 0 {142 if decodeErr := json.Unmarshal(out.Bytes(), &report); decodeErr != nil {143 t.Fatalf("diagnose output %s: %v", out.String(), decodeErr)144 }145 }146 return report, err147}148149func TestDiagnoseReportsMissingGrantEntries(t *testing.T) {150 _, pub := testKey(t)151 relay := newDiagnoseRelay(t, "/r/acme", pub)152 relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{"pubkey":"` + pub + `","name":"helper"},"allows":{"kinds":[9],"rooms":["build"],"repos":[],"wiki":"","jobs":"","sites":[]},"rate":60,"expires":4102444800,"missing":{"rooms":["ops"],"kinds":[12]}}}`153 report, err := runDiagnose(t, relay.base(), "--rooms", "build, ops", "--kinds", "9,12")154 if exitStatus(err) != 2 || err == nil || !strings.HasPrefix(err.Error(), "needs-grant: call request_grant") {155 t.Fatalf("exit %d err %v", exitStatus(err), err)156 }157 if report.Verdict != verdictNeedsGrant || report.Advice != "call request_grant with rooms ops and kinds 12" {158 t.Fatalf("verdict %s advice %q", report.Verdict, report.Advice)159 }160 if report.PubKey != pub || report.Relay != relay.base() || !report.Transport.OK || report.Transport.Check != "readyz" || !report.Authentication.OK || report.Authentication.Status != 200 {161 t.Fatalf("probes %+v", report)162 }163 if !report.Member || report.Role != "agent" || report.State != "active" || !report.Enforced || report.Rate != 60 || string(report.Grant) == "null" || len(report.Allows) == 0 {164 t.Fatalf("grant fields %+v", report)165 }166 if report.Needs == nil || strings.Join(report.Needs.Rooms, ",") != "build,ops" || report.Missing == nil || strings.Join(report.Missing.Rooms, ",") != "ops" || len(report.Missing.Kinds) != 1 || report.Missing.Kinds[0] != 12 {167 t.Fatalf("needs %+v missing %+v", report.Needs, report.Missing)168 }169 if relay.params != `{"kinds":[9,12],"rooms":["build","ops"]}` {170 t.Fatalf("relay received params %s", relay.params)171 }172 if got := strings.Join(relay.seen(), ","); got != "GET /r/acme/readyz,POST /r/acme/manage/rpc" {173 t.Fatalf("requests %s", got)174 }175}176177func TestDiagnoseOKThroughRPCMethod(t *testing.T) {178 secret, pub := testKey(t)179 relay := newDiagnoseRelay(t, "", pub)180 c, err := client.New(relay.base(), secret)181 if err != nil {182 t.Fatal(err)183 }184 server := &rpcServer{client: c, subs: map[string]context.CancelFunc{}, sem: make(chan struct{}, 1)}185 result, err := server.dispatch(context.Background(), "diagnose", json.RawMessage(`{"rooms":["build"],"kinds":[9]}`), io.Discard)186 if err != nil {187 t.Fatal(err)188 }189 report := result.(diagnosis)190 if report.Verdict != verdictOK || report.Advice != "the grant covers what was asked" || report.Missing == nil || len(report.Missing.Rooms) != 0 {191 t.Fatalf("report %+v", report)192 }193 if _, err := server.dispatch(context.Background(), "diagnose", json.RawMessage(`{"kinds":[-3]}`), io.Discard); err == nil {194 t.Fatal("bad kinds accepted")195 }196 // A human member is never held to a grant.197 relay.rpcBody = `{"result":{"member":true,"role":"member","state":"none","enforced":false,"grant":null}}`198 if report, err := runDiagnose(t, relay.base(), "--kinds", "9"); err != nil || report.Verdict != verdictOK || !strings.Contains(report.Advice, "member role") {199 t.Fatalf("member report %+v err %v", report, err)200 }201 // A member without a grant, and paused, revoked and expired grants, need202 // the operator.203 for _, state := range []string{"none", "paused", "revoked", "expired"} {204 relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"` + state + `","enforced":true,"grant":null,"expires":1700000000}}`205 report, err := runDiagnose(t, relay.base())206 if exitStatus(err) != 2 || report.Verdict != verdictNeedsGrant || !strings.Contains(report.Advice, state) && !strings.Contains(report.Advice, "no agent grant") {207 t.Fatalf("%s: report %+v err %v", state, report, err)208 }209 }210}211212func TestDiagnoseKeepsAuthorizationApartFromTransport(t *testing.T) {213 _, pub := testKey(t)214 relay := newDiagnoseRelay(t, "", pub)215 relay.rpcStatus, relay.rpcBody = http.StatusUnauthorized, `{"error":"auth-required: sign this request"}`216 report, err := runDiagnose(t, relay.base(), "--rooms", "build")217 if exitStatus(err) != 2 || report.Verdict != verdictUnauthorized || !report.Transport.OK || report.Authentication.OK || report.Authentication.Class != classAuthorization || report.Authentication.Status != 401 {218 t.Fatalf("401 report %+v err %v", report, err)219 }220 if !strings.Contains(report.Advice, "HTTP 401") || !strings.Contains(report.Authentication.Error, "auth-required") {221 t.Fatalf("401 advice %q error %q", report.Advice, report.Authentication.Error)222 }223 relay.rpcStatus, relay.rpcBody = http.StatusForbidden, `{"error":"restricted: this relay is members-only"}`224 report, err = runDiagnose(t, relay.base(), "--rooms", "build")225 if exitStatus(err) != 2 || report.Verdict != verdictNotMember || !report.Transport.OK || report.Authentication.Class != classAuthorization || !strings.Contains(report.Advice, "covering rooms build") {226 t.Fatalf("403 report %+v err %v", report, err)227 }228 relay.rpcStatus, relay.rpcBody = http.StatusOK, `{"result":{"member":false,"role":"","state":"none","enforced":true,"grant":null,"missing":{"rooms":["build"],"kinds":[]}}}`229 if report, err := runDiagnose(t, relay.base(), "--rooms", "build"); exitStatus(err) != 2 || report.Verdict != verdictNotMember || report.Member {230 t.Fatalf("stranger report %+v err %v", report, err)231 }232 // A private relay answers its NIP-11 document with 401: reachable.233 relay.readyz, relay.nip11 = 0, http.StatusUnauthorized234 relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{},"missing":{"rooms":[],"kinds":[]}}}`235 report, err = runDiagnose(t, relay.base(), "--rooms", "build")236 if err != nil || report.Verdict != verdictOK || !report.Transport.OK || report.Transport.Check != "nip11" || report.Transport.Status != 401 {237 t.Fatalf("private relay report %+v err %v", report, err)238 }239 // A relay that predates browsegrant still proves the signature works. It240 // answers HTTP 400 with the unsupported-method error its dispatcher241 // writes for a name it does not know.242 for _, body := range []string{`{"error":"unsupported: unknown community method \"browsegrant\""}`, `{"error":"unsupported: browse operation"}`} {243 relay.rpcStatus, relay.rpcBody = http.StatusBadRequest, body244 report, err = runDiagnose(t, relay.base())245 if err != nil || report.Verdict != verdictOK || !report.Authentication.OK || report.Authentication.Status != 400 || !strings.Contains(report.Advice, "does not answer browsegrant") {246 t.Fatalf("old relay report %+v err %v", report, err)247 }248 }249 // A 5xx on the signed call is transport, not authorization.250 relay.rpcStatus, relay.rpcBody = http.StatusBadGateway, `upstream down`251 report, err = runDiagnose(t, relay.base())252 if exitStatus(err) != 3 || report.Verdict != verdictUnreachable || report.Authentication.Class != classTransport || !report.Transport.OK {253 t.Fatalf("502 report %+v err %v", report, err)254 }255}256257// TestDiagnoseNeverReadsOtherClientErrorsAsOK is the review's loopback258// probe: a 429 or a 400 that is not the unsupported-method error must not259// be reported as a working signature on an old relay.260func TestDiagnoseNeverReadsOtherClientErrorsAsOK(t *testing.T) {261 _, pub := testKey(t)262 relay := newDiagnoseRelay(t, "", pub)263 cases := []struct {264 name string265 status int266 body string267 class string268 verdict string269 advice string270 }{271 {"rate limited", http.StatusTooManyRequests, `{"error":"rate limited"}`, classRateLimited, verdictUnreachable, "rate-limited"},272 {"invalid params", http.StatusBadRequest, `{"error":"invalid: rooms must be room ids"}`, classProtocol, verdictError, "HTTP 400"},273 {"not found", http.StatusNotFound, `not here`, classProtocol, verdictError, "HTTP 404"},274 {"plain 400", http.StatusBadRequest, ``, classProtocol, verdictError, "HTTP 400"},275 }276 for _, tc := range cases {277 relay.rpcStatus, relay.rpcBody = tc.status, tc.body278 report, err := runDiagnose(t, relay.base(), "--rooms", "build")279 if exitStatus(err) != 3 || err == nil {280 t.Fatalf("%s: exit %d err %v", tc.name, exitStatus(err), err)281 }282 if report.Verdict != tc.verdict || !report.Transport.OK || report.Authentication.OK || report.Authentication.Class != tc.class || report.Authentication.Status != tc.status {283 t.Fatalf("%s: report %+v", tc.name, report)284 }285 if !strings.Contains(report.Advice, tc.advice) || strings.Contains(report.Advice, "does not answer browsegrant") {286 t.Fatalf("%s: advice %q", tc.name, report.Advice)287 }288 }289 if diagnoseExit(verdictError) != 3 {290 t.Fatalf("error verdict exits %d", diagnoseExit(verdictError))291 }292}293294func TestDiagnoseUnreachableRelay(t *testing.T) {295 _, pub := testKey(t)296 relay := newDiagnoseRelay(t, "", pub)297 base := relay.base()298 relay.server.Close()299 report, err := runDiagnose(t, base, "--kinds", "9")300 if exitStatus(err) != 3 || err == nil || !strings.HasPrefix(err.Error(), "unreachable: ") {301 t.Fatalf("exit %d err %v", exitStatus(err), err)302 }303 if report.Verdict != verdictUnreachable || report.Transport.OK || report.Transport.Class != classTransport || report.Transport.Error == "" || report.Authentication.OK || report.Authentication.Class != "" {304 t.Fatalf("report %+v", report)305 }306 if report.PubKey != pub || report.Needs == nil || report.Needs.Kinds[0] != 9 {307 t.Fatalf("identity and needs %+v", report)308 }309 if _, err := runDiagnose(t, base, "--kinds", "nine"); err == nil || exitStatus(err) != 1 {310 t.Fatalf("bad kinds flag: %v", err)311 }312 if err := run([]string{"diagnose"}, strings.NewReader(""), io.Discard, io.Discard); err == nil || !strings.HasPrefix(err.Error(), "usage: tinyagent diagnose") {313 t.Fatalf("usage: %v", err)314 }315}316317func TestMCPDiagnoseToolAnswersWithoutTheRelayTable(t *testing.T) {318 _, pub := testKey(t)319 relay := newDiagnoseRelay(t, "/r/acme", pub)320 relay.rpcBody = `{"result":{"member":true,"role":"agent","state":"active","enforced":true,"grant":{"name":"helper"},"allows":{"kinds":[9],"rooms":["build"],"repos":[],"wiki":"","jobs":"","sites":[]},"missing":{"rooms":[],"kinds":[20001]}}}`321 c := startFacade(t, "--relay", relay.base(), "--key-env", "TINY_MCP_TEST_KEY")322 c.initialize("2025-06-18")323 result, response := c.tool(mcpDiagnoseTool, map[string]any{"rooms": []any{"build"}, "kinds": []any{9, 20001}})324 if response.Error != nil || result.IsError {325 t.Fatalf("tiny_diagnose: %+v %+v", response.Error, result)326 }327 if firstText(result) != "needs-grant: call request_grant with kinds 20001" {328 t.Fatalf("summary %q", firstText(result))329 }330 structured, _ := result.StructuredContent.(map[string]any)331 if structured["verdict"] != verdictNeedsGrant || structured["pubkey"] != pub || structured["state"] != "active" {332 t.Fatalf("structured %v", structured)333 }334 if relay.params != `{"kinds":[9,20001],"rooms":["build"]}` {335 t.Fatalf("relay received params %s", relay.params)336 }337 if result, _ := c.tool(mcpDiagnoseTool, map[string]any{"kinds": "nine"}); !result.IsError || !strings.Contains(firstText(result), "kinds must be") {338 t.Fatalf("bad arguments: %+v", result)339 }340 for _, path := range relay.seen() {341 if strings.HasSuffix(path, "/mcp") {342 t.Fatalf("diagnosis touched the MCP endpoint: %v", relay.seen())343 }344 }345}