package hooks
import (
"context"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
const testOwner = "bigbes"
var testSpace = core.SpaceRef{Owner: testOwner, Name: "rfcs"}
// instanceToken mints a signed tokens.sr.ht working token the way the daemon
// does. It is a real credential checked by the real sr-ht-ecore validator,
// because the point of routing the push path through authn.Resolver is that it
// runs the identical check the HTTP surfaces run; a stub validator here would
// only assert that the wiring calls something.
func instanceToken(grantString string) string { return tokenFor(testOwner, grantString) }
// foreignToken is a working token belonging to somebody who is not the instance
// owner: valid, and refused all the same.
func foreignToken(grantString string) string { return tokenFor("someone", grantString) }
func tokenFor(username, grantString string) string {
bt := &auth.BearerToken{
Version: auth.TokenVersion,
Expires: auth.ToTimestamp(time.Now().Add(time.Hour)),
Grants: grantString,
ClientID: bearer.TokensClientID,
Username: username,
}
return bt.Encode()
}
// stubUsers resolves the owner an instance token names to a local row.
type stubUsers struct{}
func (stubUsers) LookupUser(_ context.Context, username string) (authn.InstanceUser, error) {
return authn.InstanceUser{ID: 1, Username: username}, nil
}
// fakeDaemonOrigin starts a stand-in for tokens.sr.ht's revocation endpoint —
// 204 is live, 404 is revoked — and returns its origin. A dead one, with
// nothing answering, is what a restarting daemon looks like from here.
func fakeDaemonOrigin(t *testing.T, status int, dead bool) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
if dead {
srv.Close()
return srv.URL
}
t.Cleanup(srv.Close)
return srv.URL
}
// fakeBackend is a Backend that answers from a script instead of a database.
// It exists so the receive path — hooks, socket, protocol, message rendering —
// can be driven by a real `git push` on a machine with no Postgres.
type fakeBackend struct {
root string
resolver *authn.Resolver
// validate is the scripted answer, and the recorder: every request reaches
// it. A nil validate accepts everything.
validate func(context.Context, service.PushRequest) error
seen []service.PushRequest
}
// newFakeBackend builds a backend whose resolver carries the real tokens.sr.ht
// validator, pointed at a live fake daemon.
func newFakeBackend(t *testing.T, root string) *fakeBackend {
t.Helper()
return newFakeBackendAgainst(t, root, fakeDaemonOrigin(t, http.StatusNoContent, false))
}
func newFakeBackendAgainst(t *testing.T, root, origin string) *fakeBackend {
t.Helper()
v, err := bearer.New(bearer.Options{
Origin: origin,
ClientID: service.ConfigSection,
NodeID: "hooks-test",
})
if err != nil {
t.Fatalf("bearer.New: %v", err)
}
resolver, err := authn.NewResolver(testOwner, authn.WithInstancePlane(v, stubUsers{}))
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
return &fakeBackend{root: root, resolver: resolver}
}
func (b *fakeBackend) ReposRoot() string { return b.root }
func (b *fakeBackend) Resolver() *authn.Resolver { return b.resolver }
func (b *fakeBackend) ValidatePush(ctx context.Context, req service.PushRequest) error {
b.seen = append(b.seen, req)
if b.validate == nil {
return nil
}
return b.validate(ctx, req)
}
// compile-time proof that the real service satisfies the same interface the
// tests fake. If service/ ever changes one of these signatures, this fails
// here rather than in the daemon.
var _ Backend = (*service.Service)(nil)
// discardLogger keeps test output readable; the server logs every request.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
// shortTempDir makes a directory outside t.TempDir().
//
// A unix socket path is capped at ~104 bytes on darwin and 108 on Linux, and
// t.TempDir() embeds the test's name, which is long enough to blow that cap.
func shortTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "sh")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(dir); err != nil {
t.Errorf("clean up %s: %v", dir, err)
}
})
resolved, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatalf("EvalSymlinks(%s): %v", dir, err)
}
return resolved
}
// startServer runs a Server over a fake backend and returns it plus the
// notifications it received.
func startServer(t *testing.T, backend *fakeBackend, opts ...func(*Options)) (*Server, *[]core.SpaceRef) {
t.Helper()
var landed []core.SpaceRef
o := Options{
Backend: backend,
Socket: SocketPath(backend.root),
Log: discardLogger(),
OnPush: func(_ context.Context, space core.SpaceRef, _ []RefUpdate) error {
landed = append(landed, space)
return nil
},
Timeout: 20 * time.Second,
}
for _, fn := range opts {
fn(&o)
}
srv, err := NewServer(o)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
if err := srv.Listen(); err != nil {
t.Fatalf("Listen: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- srv.Serve(ctx) }()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("Serve: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("Serve did not stop")
}
if err := srv.Close(); err != nil {
t.Errorf("Close: %v", err)
}
})
return srv, &landed
}
// envOf turns a map into a Lookup.
func envOf(kv map[string]string) Lookup {
return func(k string) (string, bool) {
v, ok := kv[k]
return v, ok
}
}
// ownerEnv is the environment the forced-command wrapper sets for a push by
// the instance owner.
func ownerEnv(extra map[string]string) map[string]string {
env := map[string]string{EnvPrincipal: string(PrincipalOwner)}
for k, v := range extra {
env[k] = v
}
return env
}
// rejection builds the structured refusal service.ValidatePush returns, so a
// test can assert on the text a human actually reads.
func rejection(ref string, skippable bool, problems ...service.PushProblem) *service.PushRejection {
return &service.PushRejection{
Space: testSpace,
Ref: ref,
Problems: problems,
Skippable: skippable,
}
}
// bareRepo makes a bare repository at <root>/~owner/name using the git binary,
// so the layout under test is the real one.
func bareRepo(t *testing.T, root string, ref core.SpaceRef) string {
t.Helper()
dir := filepath.Join(root, "~"+ref.Owner, ref.Name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("MkdirAll %s: %v", dir, err)
}
gitMust(t, "", "init", "--quiet", "--bare", "--initial-branch=main", dir)
return dir
}
// errFakeStore is a backend outage: not a policy refusal, and must never be
// reported as one.
var errFakeStore = errors.New("fake store is down")
// mentions asserts that text a human will read says a particular thing. The
// rejection text is the entire user interface of a failed push, so several
// tests assert on its content rather than only on the exit code.
func mentions(t *testing.T, what, haystack string, needles ...string) {
t.Helper()
for _, needle := range needles {
if !strings.Contains(haystack, needle) {
t.Errorf("%s does not mention %q:\n%s", what, needle, haystack)
}
}
}
func mentionsNot(t *testing.T, what, haystack string, needles ...string) {
t.Helper()
for _, needle := range needles {
if strings.Contains(haystack, needle) {
t.Errorf("%s must not mention %q:\n%s", what, needle, haystack)
}
}
}
// Small wrappers so tests read as prose rather than as os calls.
func mkdirAll(dir string) error { return os.MkdirAll(dir, 0o755) }
func writeFile(path, body string) error { return os.WriteFile(path, []byte(body), 0o644) }
func dialUnix(socket string) (net.Conn, error) {
return net.DialTimeout("unix", socket, 5*time.Second)
}