~bigbes/sr-ht-spec

ref: 01999c70f928a0963f60dc1670f68e7201576b1b sr-ht-spec/mcpsrv/cache_test.go -rw-r--r-- 6.3 KiB
01999c70 — Eugene Blikh graph: keep webhook management with the owner, not with any agent 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package mcpsrv_test

import (
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv"
)

// What a cache is told about /mcp, stated at the endpoint rather than at the
// wrapper (cache_internal_test.go has the wrapper).
//
// Every answer here depends entirely on the credential the request carried and
// says nothing about it in its URL, and some of those answers are the whole
// approved corpus. A shared cache that stored one and replayed it to the next
// caller would be handing one principal's read to another.

const (
	wantCacheControl = "private, no-store, no-transform"
	wantVary         = "Cookie, Authorization"
)

// mount builds the chain cmd/specsrht builds, minus the resolver middleware:
// Gate outside, Handler inside, with a principal planted directly so the test
// does not need a tokens.sr.ht plane.
func mount(t *testing.T, p authn.Principal) *httptest.Server {
	t.Helper()
	r, s := newFixture()
	h, err := mcpsrv.Handler(mcpsrv.Backend{Docs: r, Index: s}, "test", "https://spec.srht.bigb.es")
	require.NoError(t, err)

	withPrincipal := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			next.ServeHTTP(w, req.WithContext(authn.WithPrincipal(req.Context(), p)))
		})
	}

	router := chi.NewRouter()
	router.Handle("/mcp", withPrincipal(mcpsrv.Gate(h)))
	srv := httptest.NewServer(router)
	t.Cleanup(srv.Close)
	return srv
}

func post(t *testing.T, srv *httptest.Server, host, accept, body string) *http.Response {
	t.Helper()
	req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL+"/mcp", strings.NewReader(body))
	require.NoError(t, err)
	req.Host = host
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", accept)

	resp, err := srv.Client().Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { _ = resp.Body.Close() })
	return resp
}

const initialize = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` +
	`"protocolVersion":"2025-06-18","capabilities":{},` +
	`"clientInfo":{"name":"test-client","version":"test"}}}`

// The answer an agent actually gets, on the transport it actually uses: the SDK
// replies to a POST with an event stream, which is the response that never calls
// WriteHeader and commits through Flush instead. This is the case a wrapper
// hooking WriteHeader alone would miss while passing every other test here.
func TestTheHeadersLandOnAStreamedAnswer(t *testing.T) {
	srv := mount(t, authn.Principal{Kind: authn.KindOwner})

	resp := post(t, srv, "spec.srht.bigb.es", "application/json, text/event-stream", initialize)

	require.Equal(t, http.StatusOK, resp.StatusCode)
	require.Equal(t, "text/event-stream", strings.Split(resp.Header.Get("Content-Type"), ";")[0],
		"this test is worth nothing unless the answer really was a stream")

	assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control"))
	assert.Equal(t, wantVary, resp.Header.Get("Vary"))

	body, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	require.Contains(t, string(body), mcpsrv.ServerName, "and it really was this server answering")
}

// The SDK writes `no-cache, no-transform` on its own responses. no-cache still
// permits a shared cache to store the body and merely revalidate, which is the
// one thing no-store forbids — so the wrapper has to win, and it can only do
// that by writing at commit time rather than on the way in.
func TestTheSDKsCacheControlDoesNotSurvive(t *testing.T) {
	srv := mount(t, authn.Principal{Kind: authn.KindOwner})

	resp := post(t, srv, "spec.srht.bigb.es", "application/json, text/event-stream", initialize)

	require.Equal(t, []string{wantCacheControl}, resp.Header.Values("Cache-Control"),
		"exactly one directive set, and it is ours")
	assert.NotContains(t, resp.Header.Get("Cache-Control"), "no-cache")
}

// A refusal is an answer too. The Host allowlist writes this one before the SDK
// is reached at all, and it is inside the wrapper for that reason.
func TestTheHeadersLandOnAHostRefusal(t *testing.T) {
	srv := mount(t, authn.Principal{Kind: authn.KindOwner})

	resp := post(t, srv, "evil.example.com", "application/json, text/event-stream", initialize)

	require.Equal(t, http.StatusForbidden, resp.StatusCode)
	assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control"))
	assert.Equal(t, wantVary, resp.Header.Get("Vary"))
}

// Gate's 401 is written outside the wrapper — Gate runs inside the resolver
// middleware and Handler runs inside Gate — so Gate sets the two headers itself.
// A cache free to keep this 401 would refuse a credential this service never
// saw.
func TestTheHeadersLandOnAGateRefusal(t *testing.T) {
	srv := mount(t, authn.Anonymous())

	resp := post(t, srv, "spec.srht.bigb.es", "application/json, text/event-stream", initialize)

	require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
	assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control"))
	assert.Equal(t, wantVary, resp.Header.Get("Vary"))
}

// Vary names the cookie because on this service the cookie genuinely decides the
// answer, which is the one thing that separates this endpoint from dolt.sr.ht's.
//
// dolt varies on Authorization alone and is right to: its /mcp is bearer-only,
// so naming Cookie there would promise a cache a dependency the surface never
// reads. Here authn.Resolver.Resolve falls through to the unified-login cookie
// whenever no bearer token is present, and an owner cookie resolves to KindOwner
// — which is exactly what Gate admits. The two requests below differ in nothing
// but the principal the cookie plane would have produced, and they get different
// statuses; that difference is what Vary: Cookie is for.
func TestTheCookiePlaneReallyDoesDecideTheAnswer(t *testing.T) {
	asOwner := post(t, mount(t, authn.Principal{Kind: authn.KindOwner}),
		"spec.srht.bigb.es", "application/json, text/event-stream", initialize)
	asAnonymous := post(t, mount(t, authn.Anonymous()),
		"spec.srht.bigb.es", "application/json, text/event-stream", initialize)

	require.Equal(t, http.StatusOK, asOwner.StatusCode)
	require.Equal(t, http.StatusUnauthorized, asAnonymous.StatusCode)
	require.NotEqual(t, asOwner.StatusCode, asAnonymous.StatusCode,
		"if these ever agree, Vary: Cookie has stopped being a statement about this endpoint")
}