package api_test import ( "context" "crypto/sha256" "encoding/hex" "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 two credential planes, in front of the real REST write route. // --------------------------------------------------------------------------- // localTokens is spec's own agent_token table, in memory. type localTokens struct { rows map[string]authn.AgentToken calls int } func newLocalTokens() *localTokens { return &localTokens{rows: map[string]authn.AgentToken{}} } func (s *localTokens) add(secret, name string) { sum := sha256.Sum256([]byte(secret)) s.rows[hex.EncodeToString(sum[:])] = authn.AgentToken{ID: 1, Name: name, Hash: sum[:]} } func (s *localTokens) LookupAgentToken(_ context.Context, hash []byte) (authn.AgentToken, error) { s.calls++ tok, ok := s.rows[hex.EncodeToString(hash)] if !ok { return authn.AgentToken{}, authn.ErrUnknownToken } return tok, nil } // 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 both credential planes wired: the tokens.sr.ht validator pointed at a // fake revocation daemon, and the local agent_token store. type planeFixture struct { handler http.Handler writer *fakeWriter local *localTokens } 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) local := newLocalTokens() resolver, err := authn.NewResolver("bigbes", local, 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, local: local} } 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 is configured with today still // reaches the write plane, with the tokens.sr.ht plane wired in front of it. func TestPutWithTheLocalAgentTokenStillWorks(t *testing.T) { f := newPlaneFixture(t, http.StatusNoContent, false) f.local.add("live-token", "laptop") rec := f.put("live-token") 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.PlaneLocal, p.Plane) assert.Equal(t, "laptop", p.TokenName) assert.Equal(t, "claude-code/spec-writer", p.Agent) assert.NoError(t, p.Authorize(authn.ActionPropose), "the local plane carries no grants and is refused none") } // 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)) assert.Zero(t, f.local.calls, "an accepted instance token must not reach the local store") } // 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 and never gets a second chance at // the old one — the same secret is registered locally, so a fall-through would // visibly succeed with a 201. func TestPutWithARevokedInstanceTokenIs401AndDoesNotFallThrough(t *testing.T) { f := newPlaneFixture(t, http.StatusNotFound, false) tok := liveToken("spec:propose id:42") f.local.add(tok, "shadow") rec := f.put(tok) assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Zero(t, f.local.calls, "a revoked instance token must not reach the local store") assert.Empty(t, f.writer.got.Writes, "and must not reach the write plane") } // An unreachable tokens.sr.ht is 503, never 401 and never a downgrade to the // legacy plane: refusing every live instance 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) tok := liveToken("spec:propose id:42") f.local.add(tok, "shadow") rec := f.put(tok) assert.Equal(t, http.StatusServiceUnavailable, rec.Code) assert.Zero(t, f.local.calls) assert.Empty(t, f.writer.got.Writes) } // A token from another issuer — a meta.sr.ht PAT — is not this plane's to // refuse, so it falls through to the local store, which does not know it. func TestPutWithAForeignTokenFallsThroughAndIsRefusedLocally(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.Equal(t, 1, f.local.calls, "the local plane is what refuses a foreign token") }