package graph import ( "bytes" "context" "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-ecore/metapat" "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() } // personalToken mints a bearer token sealed with the instance's key but stamped // with an OAuth client id rather than tokens.sr.ht's, carrying grantString in // meta.sr.ht's own vocabulary — "spec.sr.ht/SPECS:RO", or "" for the ungranted // token meta treats as universal. // // That is exactly what a meta.sr.ht personal access token is on this instance: // same format, same key, different issuer. The client id is the ONLY thing that // tells the two planes apart (see sr-ht-ecore/bearer, step 2), which is why // routing can be one local decode and why this fixture needs nothing else to be // a convincing PAT. func personalToken(username, grantString string) string { bt := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), Grants: grantString, ClientID: "00000000-0000-0000-0000-00000000beef", Username: username, } return bt.Encode() } // expiredPersonalToken is a PAT whose expiry is already in the past. It is worth // a fixture of its own because of where it lands: auth.DecodeBearerToken checks // expiry before it reports an issuer, so metapat.PlaneOf answers PlaneUnknown and // this never reaches the meta plane at all — see resolveCaller's routing table. func expiredPersonalToken(username string) string { bt := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(-time.Hour)), ClientID: "00000000-0000-0000-0000-00000000beef", Username: username, } return bt.Encode() } // patBackend is metapat's port over the same fixed user stubUsers answers the // working-token plane with, so both planes resolve one owner to one row and a // test that compares them is comparing planes rather than two fixtures. // // It answers "not revoked" always. Revocation is metapat's own contract and is // tested there against a backend that says otherwise; what this suite is about is // which plane a credential reaches and what it may then read. type patBackend struct{} func (patBackend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error { out.UserID = 1 out.Username = username return nil } func (patBackend) IsRevoked(context.Context, string, [64]byte, string) (bool, error) { return false, nil } // testMetaAuth is the production meta.sr.ht plane over ecore's real // metapat.Validator. Nothing in the signature check, the grant decoding or the // scope comparison is faked — only the profile mirror behind them, which is the // one step that would need a meta.sr.ht. func testMetaAuth(t *testing.T) *authn.MetaAuth { t.Helper() pats, err := metapat.New(metapat.Options{ Service: authn.ConfigSection, Backend: patBackend{}, }) require.NoError(t, err) plane, err := authn.NewMetaAuth(pats, "bigbes", authn.ScopeRead) require.NoError(t, err) return plane } // 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, Meta: testMetaAuth(t), }) 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") }) } // The other plane, and the reason this endpoint has two. A federated query // arrives from api.sr.ht carrying the client's own meta.sr.ht token, because the // gateway forwards one Authorization header to every service it touches — so a // personal access token has to work here, scoped in meta's vocabulary rather than // in tokens.sr.ht's. func TestTheReadScopeIsRequiredOfAPersonalAccessToken(t *testing.T) { cases := []struct { name string grants string status int }{ {"the scope itself", authn.ScopeRead, http.StatusOK}, {"an explicit :RO", authn.ScopeRead + ":RO", http.StatusOK}, {"a write scope covers a read", authn.ScopeRead + ":RW", http.StatusOK}, { // meta mints a token with no grants selected and core-go reads that // as every permission (auth.Grants.HasAll). Refusing it here would // refuse the commonest credential on the instance. "no grants at all is universal, as meta defines it", "", http.StatusOK, }, {"another service's scope", "bench.sr.ht/RESULTS:RO", http.StatusForbidden}, {"meta's own profile scope is not this one", "meta.sr.ht/PROFILE:RO", http.StatusForbidden}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", tc.grants)) }) require.Equal(t, tc.status, r.status, "body %s", r.body) if tc.status != http.StatusForbidden { return } assert.Contains(t, r.body, authn.ScopeRead, "a 403 must name meta's scope, not tokens.sr.ht's grant: a PAT holder "+ "sent looking for spec:read will not find a checkbox for it") assert.NotContains(t, r.body, authn.ActionRead) assert.Empty(t, r.header.Get("WWW-Authenticate"), "a challenge would send the holder re-minting a token that is fine") assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") }) } } // A personal access token reads as the owner's agent, and the emphasis is on // agent: it is a bearer string any process holding it can present, forwarded // through a gateway by whatever client asked, so reading it as authn.KindOwner // would hand the approved branch and the webhook subscriptions to the widest // credential on the instance. The scope says the token may read through spec at // all; who it is stays the ACL's input. func TestAPersonalAccessTokenReadsAsAnAgentAndNotTheOwner(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", authn.ScopeRead)) }) require.Equal(t, http.StatusOK, r.status, "body %s", r.body) assert.Contains(t, string(r.Data), "~bigbes/rfcs") // The principal itself, since a 200 on a read cannot tell an agent from the // owner: both may read, and only one may approve. plane := testMetaAuth(t) p, err := plane.VerifyToken(t.Context(), personalToken("bigbes", authn.ScopeRead)) require.NoError(t, err) assert.True(t, p.IsAgent()) assert.False(t, p.IsOwner(), "a PAT must not reach the webhook mutations") assert.Equal(t, authn.PlaneMeta, p.Plane) assert.NoError(t, p.Authorize(authn.ActionRead), "a PAT can never carry spec:read; asking for it would refuse every one of them") } // A personal access token belonging to another meta.sr.ht account is 403 and is // not admitted as a second identity — the same rule, and the same status, a // foreign working token gets. // // This is the check that matters most on this plane. A PAT is the credential // every account on the instance can mint for itself, so without it the widest // credential in existence would be the one that skipped the narrowest identity // rule, and any user of this instance could read the whole corpus. func TestPersonalAccessTokenOfAnotherOwnerIsRefused(t *testing.T) { h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+personalToken("somebody-else", authn.ScopeRead)) }) assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body) assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content") } // The two vocabularies do not overlap, and this is the test that says so. Each // plane is reached by the client id its token was sealed with, and neither can be // talked into honouring the other's permission — but they refuse it differently, // and the difference is worth pinning because it is not arbitrary. func TestOnePlanesPermissionIsNotTheOthers(t *testing.T) { t.Run("a PAT spelling tokens.sr.ht's grant", func(t *testing.T) { // Well-formed in meta's grammar — a scope this service does not publish — // so it authenticates and is refused on permission: 403. h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", authn.ConfigSection+"/"+authn.ActionRead)) }) assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body) }) t.Run("a working token spelling meta's scope", func(t *testing.T) { // Not even parseable: ecore's grants grammar is ":", and // "spec.sr.ht/SPECS" is not a grant string in it. The credential is // malformed rather than insufficient, so this is 401 with the challenge — // a stricter answer than the one above, arrived at one step earlier. h := newHarness(t, false) r := post(t, h, probeQuery, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+agentToken(authn.ScopeRead)) }) assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body) assert.NotEmpty(t, r.header.Get("WWW-Authenticate")) }) } // 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") }, // Expired, and therefore unreadable rather than refused: DecodeBearerToken // checks expiry before it reports an issuer, so metapat.PlaneOf cannot say // which plane sealed this and resolveCaller sends it to the working one. // Both planes owe it the same 401, which is exactly why that routing // choice is safe — and this asserts the challenge survives it. "an expired personal access token": func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+expiredPersonalToken("bigbes")) }, } 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")) }) } }