package graph import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-spec/authn" ) // The credential plane of /query, end to end through the real handler chain. // // grant_test.go exercises the gate as a unit, over a principal somebody handed // it. These go in at the door instead — an HTTP request carrying a credential — // because what changed in the conversion is which credentials reach the gate at // all, and a test written against a Principal cannot see that. // probeQuery is a cheap read: it needs no fixture beyond the fake reader and it // is refused before parsing when the caller has no authority, so the status is // the whole answer. const probeQuery = `{ spaces { ref } }` // request builds the POST the harness would send, and hands back the recorder // as well, for the assertions that are about a header rather than a body. func request(t *testing.T, q string, credential func(*http.Request)) (*http.Request, *httptest.ResponseRecorder) { t.Helper() body, err := json.Marshal(map[string]any{"query": q}) require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") if credential != nil { credential(req) } return req, httptest.NewRecorder() } // metaPAT mints a bearer token sealed with the instance's key but stamped with // an OAuth client id rather than tokens.sr.ht's. That is exactly what a // meta.sr.ht personal access token is on this instance — same format, same key, // different issuer — and the client id is the only thing that tells the two // apart (see sr-ht-ecore/bearer, step 2). func metaPAT() string { bt := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), Grants: "", ClientID: "00000000-0000-0000-0000-00000000beef", Username: "bigbes", } return bt.Encode() } // foreignToken mints a working token belonging to somebody who is not the // instance owner. func foreignToken(grantString string) string { bt := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), Grants: grantString, ClientID: bearer.TokensClientID, Username: "somebody-else", } return bt.Encode() } // A tokens.sr.ht working token carrying spec:read reads. This is the credential // the whole conversion is for: the same one /mcp and the REST write plane take, // so a token that works against one surface of this service works against all of // them. func TestWorkingTokenWithTheReadGrantReads(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionRead)) }) require.Equal(t, http.StatusOK, r.status, "body %s", r.body) assert.Empty(t, r.errText()) assert.Contains(t, string(r.Data), "~bigbes/rfcs") } // The universal grant covers spec:read like any other action, so a token minted // with "*" reads too. It is asserted separately because "*" is not a member of // the set and a grant check written as a set lookup would refuse it. func TestUniversalGrantReads(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+agentToken("*")) }) require.Equal(t, http.StatusOK, r.status, "body %s", r.body) assert.Empty(t, r.errText()) } // A working token that verifies but was not minted for reading is 403 and not // 401: the credential is good and the holder is known, so retrying with it is // pointless and what they need is a wider grant. The refusal names the grant, // because a client that is not told which one it lacks cannot ask for it. func TestWorkingTokenWithoutTheReadGrantIsRefused(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionPropose)) }) require.Equal(t, http.StatusForbidden, r.status, "body %s", r.body) assert.Contains(t, r.body, authn.ActionRead) assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") } // The unified-login cookie is not a credential on this endpoint, and the owner's // own cookie is refused along with everybody else's. That is the half of the // conversion a status code alone would not prove, so it is asserted twice: on // the bare handler, and mounted under the very middleware that would resolve the // cookie into the owner principal. The second case is the one that matters — // resolveCaller overwrites the principal rather than inheriting it, so no // arrangement of middleware above the mount point can promote a browser session // into read authority here. func TestCookieIsNotACredentialHere(t *testing.T) { t.Run("bare handler", func(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") }) assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body) assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") }) t.Run("under a router that does resolve the cookie", func(t *testing.T) { resolver := testResolver(t) srv, err := New(Options{Reader: newFakeReader(), Searcher: &fakeSearcher{}, Resolver: resolver}) require.NoError(t, err) // The cookie plane, installed above the endpoint. It resolves the // owner's cookie to authn.KindOwner, which CanRead admits. h := harness{handler: resolver.Middleware()(srv)} r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") }) assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body) assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") }) } // A meta.sr.ht personal access token is refused, and this is a deliberate // difference from dolt.sr.ht rather than an oversight. // // spec.sr.ht authenticates through one issuer (authn's package comment) and // publishes no OAuth scope for meta to grant against — cmd/specsrht serves // api-meta.json with an empty scope list — so there is nothing a PAT could be // scoped *for* here. bearer classifies it ErrNotOurs and authn calls that // permanent, hence 401 with the challenge rather than a 503. // // It is the credential this endpoint used to take, when core-go's // auth.Middleware stood in front of it, and the test exists to pin the change // rather than to celebrate it: reopening that plane means giving spec.sr.ht a // meta scope first. func TestMetaPersonalAccessTokenIsRefused(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+metaPAT()) }) assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body) assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") } // A working token belonging to another meta.sr.ht account is 403, not 401 and // not admitted as a second identity: the token verifies and the holder is who // they say they are, there is simply nothing on this single-owner instance to // grant them. This is the ownerOnly rule the conversion had to keep, moved from // a comparison against auth.AuthContext.Username to authn's own. func TestWorkingTokenOfAnotherOwnerIsRefused(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+foreignToken(authn.ActionRead)) }) assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body) assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") } // Every 401 carries the challenge, naming the scheme and this service's config // section as the realm. RFC 9110 requires it, and the caller here is always a // machine holding a bearer token: without it nothing tells the client which // credential was refused. func TestEveryUnauthorizedCarriesTheBearerChallenge(t *testing.T) { h := newHarness(t, false) cases := map[string]func(*http.Request){ "no credential": nil, "owner's cookie": func(req *http.Request) { login(req, "bigbes") }, "a forged token": func(req *http.Request) { req.Header.Set("Authorization", "Bearer not-a-real-token") }, "a meta PAT": func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+metaPAT()) }, } for name, credential := range cases { t.Run(name, func(t *testing.T) { req, rec := request(t, probeQuery, credential) h.handler.ServeHTTP(rec, req) require.Equal(t, http.StatusUnauthorized, rec.Code, "body %s", rec.Body.String()) assert.Equal(t, authn.Challenge(), rec.Header().Get("WWW-Authenticate")) }) } }