package api_test import ( "context" "net/http" "net/http/httptest" "os" "strings" "testing" "testing/fstest" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-spec/api" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // TestMain initialises the one piece of process-global core-go state this file // needs: crypto.InitCrypto holds the key working tokens are signed with, derived // from [webhooks] private-key. The keys are the ones core-go's own tests use. // The rest of api_test does not need it, which is why it never had a TestMain. func TestMain(m *testing.M) { config.FS = fstest.MapFS{ "config.ini": &fstest.MapFile{Data: []byte(` [webhooks] private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc= [sr.ht] network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk= `)}, } crypto.InitCrypto(config.LoadConfig()) os.Exit(m.Run()) } // --------------------------------------------------------------------------- // Fixtures: the agent credential plane, in front of the real REST write route. // --------------------------------------------------------------------------- // users resolves the instance owner to a local row. type users struct{} func (users) LookupUser(_ context.Context, username string) (authn.InstanceUser, error) { return authn.InstanceUser{ID: 1, Username: username}, nil } // sealToken mints a signed working token the way tokens.sr.ht does. func sealToken(grantString string, expires time.Time) string { bt := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(expires), Grants: grantString, ClientID: bearer.TokensClientID, Username: "bigbes", } return bt.Encode() } func liveToken(grantString string) string { return sealToken(grantString, time.Now().Add(time.Hour)) } // planeFixture is the REST write plane behind the real resolver middleware, // with the agent credential plane wired: the tokens.sr.ht validator pointed at // a fake revocation daemon. type planeFixture struct { handler http.Handler writer *fakeWriter } func newPlaneFixture(t *testing.T, daemonStatus int, closeDaemon bool) *planeFixture { t.Helper() daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(daemonStatus) })) origin := daemon.URL if closeDaemon { daemon.Close() // nothing answers there any more } else { t.Cleanup(daemon.Close) } v, err := bearer.New(bearer.Options{Origin: origin, ClientID: "spec.sr.ht", NodeID: "spec-test"}) require.NoError(t, err) resolver, err := authn.NewResolver("bigbes", authn.WithInstancePlane(v, users{})) require.NoError(t, err) w := &fakeWriter{res: service.ProposeResult{ Proposal: service.Proposal{ID: 42, Branch: "proposals/42", BaseRev: "deadbeef", State: core.StateOpen}, URL: "https://spec.srht.bigb.es/~bigbes/rfcs/p/42", }} srv, err := api.New(api.Options{Writer: w, Resolver: resolver}) require.NoError(t, err) return &planeFixture{handler: srv.Handler(), writer: w} } func (f *planeFixture) put(token string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPut, "/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md?title=Storage&message=add+it", strings.NewReader("the document")) req.Header.Set("If-Match", "1f0c1d1a") req.Header.Set("Authorization", "Bearer "+token) req.Header.Set(authn.HeaderAgent, "claude-code/spec-writer") req.Header.Set(authn.HeaderAgentSession, "8fb9c9a4-b078-4af1-89eb-d97c522f9921") rec := httptest.NewRecorder() f.handler.ServeHTTP(rec, req) return rec } // --------------------------------------------------------------------------- // The credential every agent on the instance used to be configured with — an // opaque secret out of agent_token — reaches nothing any more. 401 at the door, // and the write plane never sees the request. func TestPutWithTheOldOpaqueAgentTokenIs401(t *testing.T) { f := newPlaneFixture(t, http.StatusNoContent, false) rec := f.put("live-token") assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Empty(t, f.writer.got.Writes, "a refused credential must not reach the write plane") } // A tokens.sr.ht working token reaches the same route, and arrives carrying the // grants service.Propose will check. func TestPutWithAnInstanceToken(t *testing.T) { f := newPlaneFixture(t, http.StatusNoContent, false) rec := f.put(liveToken("spec:read spec:propose")) require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body) p := f.writer.got.Principal assert.True(t, p.IsAgent()) assert.Equal(t, authn.PlaneInstance, p.Plane) assert.Equal(t, "bigbes", p.Owner, "the agent still acts for the instance owner") assert.Equal(t, 1, p.UserID) assert.NoError(t, p.Authorize(authn.ActionPropose)) } // A narrow token still authenticates here — the resolver runs before the router // and knows no action — and is refused by service.Propose, which is where the // action is known and where the rule is spelled once for both write surfaces. func TestPutWithAnInstanceTokenMissingTheProposeGrant(t *testing.T) { f := newPlaneFixture(t, http.StatusNoContent, false) f.writer.err = service.ErrForbidden rec := f.put(liveToken("spec:read")) assert.Equal(t, http.StatusForbidden, rec.Code) assert.ErrorIs(t, f.writer.got.Principal.Authorize(authn.ActionPropose), authn.ErrMissingGrant) } // A revoked instance token is 401 at the door. There is no second door for it // to be re-tried at any more, which is what removing the local plane bought. func TestPutWithARevokedInstanceTokenIs401(t *testing.T) { f := newPlaneFixture(t, http.StatusNotFound, false) rec := f.put(liveToken("spec:propose id:42")) assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Empty(t, f.writer.got.Writes, "and must not reach the write plane") } // An unreachable tokens.sr.ht is 503 and never 401: refusing every live token // because a daemon that is deliberately off the hot path is restarting is the // outcome the 503 exists to prevent. func TestPutWithAnUnreachableDaemonIs503(t *testing.T) { f := newPlaneFixture(t, http.StatusNoContent, true) rec := f.put(liveToken("spec:propose id:42")) assert.Equal(t, http.StatusServiceUnavailable, rec.Code) assert.Empty(t, f.writer.got.Writes) } // A token from another issuer — a meta.sr.ht PAT — used to fall through to // spec's own store, which did not know it. With one plane it is refused where // it is presented, and still with a 401: bearer.ErrNotOurs is a permanent // refusal, not the 503 an unclassified error would earn. func TestPutWithAForeignTokenIs401(t *testing.T) { f := newPlaneFixture(t, http.StatusNoContent, false) pat := &auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), Grants: "git.sr.ht/OBJECTS:RW", ClientID: "meta.sr.ht", Username: "bigbes", } rec := f.put(pat.Encode()) assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Empty(t, f.writer.got.Writes) }