package graph
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
)
// The webhook chapter of this schema is owner-only, and an agent token is not
// the owner however wide its grant set.
//
// This is the property the conversion had to preserve and briefly did not.
// Before /query moved to the anonymous router it sat behind core-go's
// auth.Middleware and the daemon's ownerOnly wrapper, which refused every caller
// whose username was not the instance owner. The move admits tokens.sr.ht
// working tokens, and for a while the resolvers asked only whether the caller
// was AUTH_INTERNAL — which coreauth answers "yes" for an agent too. A token
// carrying nothing but spec:read could therefore create and delete
// subscriptions: a read grant buying a mutation.
// webhookFields is every field guarded by webhookAuthorized. The two mutations
// are the dangerous half, but the queries are listed with them on purpose: they
// serve a subscription's URL, its stored query and its delivery bodies, and they
// were behind the same wall before the conversion.
var webhookFields = map[string]string{
"createUserWebhook": `mutation { createUserWebhook(config: {url: "https://example.invalid/h", ` +
`events: [PROPOSAL_OPENED], query: "query { webhook { uuid } }"}) { id } }`,
"deleteUserWebhook": `mutation { deleteUserWebhook(id: 1) { id } }`,
"userWebhooks": `{ userWebhooks { results { id url query } } }`,
"userWebhook": `{ userWebhook(id: 1) { id url query } }`,
}
// An agent reaches /query — that is what the conversion was for — and is refused
// every webhook field, whether its token names the read grant or the universal
// one.
//
// The refusal has to come from webhookAuthorized and not from a missing
// database: the harness wires no core-go database context at all, so a resolver
// that got past the ACL would panic in database.WithTx rather than answer an
// error. That is why the assertions check the message.
func TestAgentTokenIsRefusedEveryWebhookField(t *testing.T) {
for _, grantString := range []string{authn.ActionRead, "*"} {
t.Run("token granting "+grantString, func(t *testing.T) {
for name, q := range webhookFields {
t.Run(name, func(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, q, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+agentToken(grantString))
})
// The gate admits the caller: this is an authorization
// failure inside the schema, not a refusal at the door.
require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
assert.Contains(t, r.errText(), "Access denied",
"an agent must not reach the webhook surface")
assert.NotContains(t, r.body, "example.invalid",
"the refusal leaked a subscription URL")
})
}
})
}
}
// The same agent still reads. The point of the fix is that the webhook chapter
// closed, not that the endpoint did — a token carrying spec:read is exactly the
// credential /query was converted to accept.
func TestTheSameAgentTokenStillReads(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, probeQuery, readToken)
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 owner half of the rule, at the only level where it can be observed.
//
// There is no HTTP test for it and there cannot be one: authn produces
// authn.KindOwner from the unified-login cookie alone, and this endpoint reads
// no cookie, so no request it accepts resolves to the owner. The guard is
// written against the right predicate anyway — see webhookAuthorized — and this
// is what pins it, so that a credential plane which does yield the owner is
// admitted the moment it exists.
func TestWebhookAuthorizedAdmitsTheOwnerAndNobodyElse(t *testing.T) {
const ownerUserID = 42
cases := []struct {
name string
principal authn.Principal
wantErr bool
}{
{
"the owner",
authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"},
false,
},
{
// The case the fix is about. coreauth maps this to AUTH_INTERNAL
// exactly like the owner, so a check that read only the AuthContext
// would admit it.
"an agent on a working token with the universal grant",
authn.Principal{
Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneInstance,
Grants: mustGrants(t, "*"), UserID: ownerUserID,
},
true,
},
{
"an agent a local process asserted",
authn.Principal{Kind: authn.KindAgent, Owner: "bigbes"},
true,
},
{
"anonymous",
authn.Anonymous(),
true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := authn.WithPrincipal(context.Background(), tc.principal)
ctx = coreauth.Context(ctx, tc.principal, ownerUserID)
err := webhookAuthorized(ctx)
if tc.wantErr {
require.Error(t, err)
assert.True(t, coreerrors.Is(err, coreerrors.ErrAccessDenied),
"want ERR_ACCESS_DENIED, got %v", err)
return
}
assert.NoError(t, err)
})
}
}
// coreauth maps the owner and an agent to the same AuthMethod, which is why the
// principal and not the AuthContext is what webhookAuthorized asks. Asserted
// rather than described: if this ever stops being true, the comment explaining
// why the guard is written the way it is stops being true with it.
func TestCoreauthCannotTellTheOwnerFromAnAgent(t *testing.T) {
const ownerUserID = 7
owner := coreauth.Derive(authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}, ownerUserID)
agent := coreauth.Derive(authn.Principal{Kind: authn.KindAgent, Owner: "bigbes"}, ownerUserID)
assert.Equal(t, auth.AUTH_INTERNAL, owner.AuthMethod)
assert.Equal(t, owner.AuthMethod, agent.AuthMethod,
"if these ever differ, webhookAuthorized could read the AuthContext again")
assert.Equal(t, owner.UserID, agent.UserID)
}
// A refusal must name the field it refused, and two refusals in one process must
// not share an error object.
//
// coreerrors.ErrAccessDenied is a package-level *gqlerror.Error and gqlgen
// assigns the field path onto whatever error it is handed, so returning that
// singleton makes every later refusal in the daemon report the path of an
// earlier one — a wrong answer, a cross-request leak of which field somebody
// else asked for, and a data race on a global. Found by driving a live daemon:
// a refused deleteUserWebhook came back as "path":["createUserWebhook"].
func TestARefusalNamesItsOwnField(t *testing.T) {
h := newHarness(t, false)
// createUserWebhook first, deliberately: it is the path that used to be
// stamped onto the shared object and inherited by everything after it.
first := post(t, h, webhookFields["createUserWebhook"], readToken)
require.Contains(t, first.errText(), "Access denied", "body %s", first.body)
for _, field := range []string{"deleteUserWebhook", "userWebhooks", "userWebhook"} {
t.Run(field, func(t *testing.T) {
r := post(t, h, webhookFields[field], readToken)
require.Contains(t, r.errText(), "Access denied", "body %s", r.body)
assert.Contains(t, r.body, `"path":["`+field+`"]`,
"the refusal reported another field's path")
assert.NotContains(t, r.body, `"path":["createUserWebhook"]`,
"a shared error object carried the first refusal's path")
})
}
}
// The one webhook field with no ACL of its own. It reads the payload of the
// delivery being rendered, and outside a webhook context there is none, so it
// answers an error rather than anything about this instance's subscriptions.
// Left ungated deliberately; asserted so that "it leaks nothing" is measured.
func TestWebhookPayloadFieldLeaksNothingOverHTTP(t *testing.T) {
h := newHarness(t, false)
r := post(t, h, `{ webhook { uuid event } }`, readToken)
require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
assert.Contains(t, r.errText(), "without an active webhook context")
}