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") }