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