v0.50.0 c4ec31f

round3_test.go RawHistoryFolder

1package mcp
2
3import (
4	"context"
5	"errors"
6	"net/http"
7	"net/http/httptest"
8	"strings"
9	"sync/atomic"
10	"testing"
11	"time"
12)
13
14// Regression tests for the third review round (platform-independent parts).
15
16// ---- GO-004: HTTP error bodies ----
17
18func TestHTTPErrorBodySecretStraddlingDisplayCut(t *testing.T) {
19	// End to end: secrets straddling the display cut and split by escapes.
20	const secret = "HTTPSECRET-0123456789abcdef"
21	t.Setenv("MCP_T_BEARER", secret)
22	// The secret straddles the 300-byte display cut, and a second copy is
23	// split by escape sequences so it only matches after stripping.
24	body := strings.Repeat("x", maxStatusBodyDisplay-len(secret)/2) + secret + strings.Repeat("y", 2000) +
25		"HTTPSE\x1b[0mCRET-0123456789abcdef"
26	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27		http.Error(w, body, http.StatusInternalServerError)
28	}))
29	defer srv.Close()
30	cfg := ServerConfig{Type: "http", URL: srv.URL, BearerTokenEnvVar: "MCP_T_BEARER"}
31	tr, err := newHTTPTransport("x", cfg, nil)
32	if err != nil {
33		t.Fatal(err)
34	}
35	defer tr.close()
36	if got := tr.red.overlap(); got < len(secret) {
37		t.Fatalf("overlap %d does not cover the secret length %d", got, len(secret))
38	}
39	_, err = tr.request(context.Background(), 1, "tools/list", nil)
40	if err == nil {
41		t.Fatal("want an error")
42	}
43	for _, frag := range []string{secret, "HTTPSECR", "HTTPS", "SECRET"} {
44		if strings.Contains(err.Error(), frag) {
45			t.Errorf("secret fragment %q leaked: %q", frag, err)
46		}
47	}
48}
49
50func TestStatusErrorBodyTrimsPartialSecrets(t *testing.T) {
51	red := newRedactor(ServerConfig{Env: map[string]string{"K": "TOPSECRET-99"}})
52	// Body cut by the reader in the middle of the secret.
53	err := red.statusErrorBody(500, []byte("failed with token TOPSECR"), true)
54	if strings.Contains(err.Error(), "TOP") {
55		t.Errorf("reader-cut prefix survived: %q", err)
56	}
57	// The same text, complete: a fragment that is not the secret stays.
58	err = red.statusErrorBody(500, []byte("failed with token TOPSECR"), false)
59	if !strings.Contains(err.Error(), "TOPSECR") {
60		t.Errorf("complete bodies must not be trimmed: %q", err)
61	}
62	// A secret hidden by control sequences is matched after stripping, so the
63	// 300-byte display cut never lands inside it. (The trim after that cut is
64	// defense in depth: redaction runs first, so a whole secret is already the
65	// marker when the cut happens and no behavioural input reaches the trim.)
66	long := strings.Repeat("a", 296) + "TOPS" + "\x1b[0mECRET-99" + strings.Repeat("b", 50)
67	err = red.statusErrorBody(500, []byte(long), false)
68	if strings.Contains(err.Error(), "TOPS") || strings.Contains(err.Error(), "SECRET") {
69		t.Errorf("display cut exposed a secret prefix: %q", err)
70	}
71	if !strings.Contains(err.Error(), "…") {
72		t.Errorf("long body not marked cut: %q", err)
73	}
74	if n := len(err.(*HTTPStatusError).Body); n > maxStatusBodyDisplay+len("…") {
75		t.Errorf("body not bounded: %d", n)
76	}
77}
78
79// ---- GO-021: writer-side withdrawal ----
80
81// lateCtx reports an error from Err before Done closes, which lets a test
82// order the writer's and the caller's observations of the cancellation.
83type lateCtx struct {
84	context.Context
85	done chan struct{}
86	err  atomic.Bool
87}
88
89func newLateCtx() *lateCtx { return &lateCtx{Context: context.Background(), done: make(chan struct{})} }
90
91func (c *lateCtx) Done() <-chan struct{} { return c.done }
92func (c *lateCtx) Err() error {
93	if c.err.Load() {
94		return context.Canceled
95	}
96	return nil
97}
98
99func TestWriteFrameWriterSideSkipDoesNotFailTransport(t *testing.T) {
100	c, _ := connectFake(t, nil)
101	st := c.t.(*stdioTransport)
102	ctx := newLateCtx()
103	ctx.err.Store(true) // the writer will see an ended context at dequeue...
104
105	skipped := make(chan struct{})
106	release := make(chan struct{})
107	st.hookSkipped = func() {
108		close(ctx.done) // ...and only now does the caller learn of it
109		close(skipped)
110		<-release // keep the writer from reporting, so the caller takes the Done branch
111	}
112	type result struct {
113		sent bool
114		err  error
115	}
116	res := make(chan result, 1)
117	go func() {
118		sent, err := st.writeFrame(ctx, []byte(`{"jsonrpc":"2.0","method":"notifications/x"}`))
119		res <- result{sent, err}
120	}()
121	select {
122	case <-skipped:
123	case <-time.After(5 * time.Second):
124		t.Fatal("writer never skipped the frame")
125	}
126	var r result
127	select {
128	case r = <-res:
129	case <-time.After(5 * time.Second):
130		t.Fatal("caller did not return")
131	}
132	close(release)
133	if r.sent || !errors.Is(r.err, context.Canceled) {
134		t.Errorf("sent=%v err=%v, want withdrawn", r.sent, r.err)
135	}
136	select {
137	case <-st.done():
138		t.Fatalf("a frame withdrawn by the writer failed the transport: %v", st.failure())
139	default:
140	}
141	// The transport still works.
142	if got := callText(t, c, "echo", `{"text":"ok"}`); got != "echo: ok" {
143		t.Errorf("after skip: %q", got)
144	}
145}
146
147// ---- GO-022: budget marker after rune rounding ----
148
149func TestBudgetedFieldMarksTruncationAfterRuneRounding(t *testing.T) {
150	b := &budgetedText{}
151	b.write(strings.Repeat("a", maxResultTextBytes-4)) // remaining = 4
152	// remaining+1 = 5 bytes lands inside the second 3-byte rune; rounding
153	// down gives 3 bytes, which is within budget, yet text was dropped.
154	got := b.field("日日日日")
155	if got != "日" {
156		t.Fatalf("field = %q", got)
157	}
158	if !b.clipped {
159		t.Fatal("clipping not recorded")
160	}
161	b.write("- " + got)
162	if !b.full() {
163		t.Error("truncation flag not set although text was clipped")
164	}
165	if !strings.HasSuffix(b.String(), "[truncated at 51200 bytes]") {
166		t.Errorf("marker missing: %q", b.String()[len(b.String())-40:])
167	}
168	// Later writes are ignored.
169	before := b.String()
170	b.write("more")
171	if b.String() != before {
172		t.Error("write accepted after truncation")
173	}
174	// A field that fits is neither clipped nor marked.
175	c := &budgetedText{}
176	if got := c.field("日本"); got != "日本" || c.clipped {
177		t.Errorf("field = %q clipped=%v", got, c.clipped)
178	}
179	c.write("- 日本\n")
180	if c.full() || strings.Contains(c.String(), "truncated") {
181		t.Error("unclipped field must not be marked")
182	}
183}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close