feat: update integration tests (#434)

Ayman Bagabas created

* feat: add more unittests

* fix(tests): use the soft binary to run integration tests

* fix(ci): upload coverage data

* fix: daemon test idle timeout

* fix: daemon flaky test

* chore: add more webhook unit tests

* fix(test): enable webhook integration tests

* fix(tests): readd sync lock

* fix(ci): collect coverage for both unit and integration tests

* fix(ci): coverage test

* fix(ci): remove macos and windows

* fix: return the opened logger file

* fix: daemon idle test

* fix: testscript on windows

* fix: run soft-serve in txtar background

* fix(ci): collecting coverage data

* fix: coverage data

* fix: remove unused

* fix: add browse test

* feat: add stop server endpoint

* fix(tests): run integration tests on windows

* fix(tests): skip daemon idle timeout flaky test

* fix(tests): attempt to fix daemon idle test

Change summary

.github/workflows/build.yml               |   4 
.github/workflows/coverage.yml            |  24 ++
cmd/cmd.go                                |   2 
cmd/soft/serve/serve.go                   |  23 ++
cmd/soft/serve/server.go                  |   2 
go.mod                                    |   3 
go.sum                                    |   6 
pkg/access/context_test.go                |  20 +
pkg/backend/backend.go                    |   5 
pkg/config/context_test.go                |  29 ++
pkg/config/file_test.go                   |  15 +
pkg/config/ssh.go                         |  26 ++
pkg/config/ssh_test.go                    |  26 ++
pkg/cron/cron_test.go                     |  31 +++
pkg/daemon/daemon.go                      |  25 +
pkg/daemon/daemon_test.go                 |  10 
pkg/db/context_test.go                    |  28 ++
pkg/db/db_test.go                         |  17 +
pkg/db/errors_test.go                     |  25 ++
pkg/db/internal/test/test.go              |  29 ++
pkg/db/migrate/migrate_test.go            |  22 ++
pkg/git/git.go                            |  16 +
pkg/git/git_test.go                       |  41 ++++
pkg/git/lfs.go                            |   9 
pkg/hooks/gen.go                          |   6 
pkg/hooks/gen_test.go                     |  40 +++
pkg/jwk/jwk.go                            |   2 
pkg/jwk/jwk_test.go                       |  22 ++
pkg/log/log.go                            |   6 
pkg/log/log_test.go                       |  43 ++++
pkg/ssh/cmd/git.go                        |   2 
pkg/ssh/session_test.go                   |   2 
pkg/web/auth.go                           |   2 
pkg/web/git.go                            |   2 
pkg/web/http.go                           |  17 
pkg/webhook/content_type_test.go          | 117 +++++++++++
pkg/webhook/push.go                       |   7 
testscript/script_test.go                 | 256 ++++++++++++++----------
testscript/testdata/help.txtar            |   9 
testscript/testdata/http.txtar            |   9 
testscript/testdata/jwt.txtar             |   9 
testscript/testdata/mirror.txtar          |   9 
testscript/testdata/repo-blob.txtar       |   8 
testscript/testdata/repo-collab.txtar     |  10 
testscript/testdata/repo-commit.txtar     |   9 
testscript/testdata/repo-create.txtar     |   9 
testscript/testdata/repo-delete.txtar     |   9 
testscript/testdata/repo-import.txtar     |   9 
testscript/testdata/repo-perms.txtar      |   9 
testscript/testdata/repo-push.txtar       |  18 +
testscript/testdata/repo-tree.txtar       |   8 
testscript/testdata/repo-webhooks.txtar   |  23 +
testscript/testdata/set-username.txtar    |   9 
testscript/testdata/settings.txtar        |   9 
testscript/testdata/soft-browse.txtar     |  29 ++
testscript/testdata/ssh.txtar             |   9 
testscript/testdata/token.txtar           |   8 
testscript/testdata/user_management.txtar |   9 
58 files changed, 996 insertions(+), 187 deletions(-)

Detailed changes

.github/workflows/build.yml 🔗

@@ -42,5 +42,5 @@ jobs:
       - name: Test
         run: go test ./...
         env:
-          DB_DRIVER: postgres
-          DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable
+          SOFT_SERVE_DB_DRIVER: postgres
+          SOFT_SERVE_DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable

.github/workflows/coverage.yml 🔗

@@ -8,7 +8,10 @@ on:
 
 jobs:
   coverage:
-    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        os: [ubuntu-latest] # TODO: add macos & windows
+    runs-on: ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v4
 
@@ -18,7 +21,24 @@ jobs:
           go-version: ^1
 
       - name: Test
-        run: go test -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt ./... -timeout 5m
+        run: |
+          # We collect coverage data from two sources,
+          # 1) unit tests 2) integration tests
+          #
+          # https://go.dev/testing/coverage/
+          # https://dustinspecker.com/posts/go-combined-unit-integration-code-coverage/
+          # https://github.com/golang/go/issues/51430#issuecomment-1344711300
+          mkdir -p coverage/unit
+          mkdir -p coverage/int
+
+          # Collect unit tests coverage
+          go test -failfast -race -timeout 5m -skip=^TestScript -cover ./... -args -test.gocoverdir=$PWD/coverage/unit
+
+          # Collect integration tests coverage
+          GOCOVERDIR=$PWD/coverage/int go test -failfast -race -timeout 5m -run=^TestScript ./...
+
+          # Convert coverage data to legacy textfmt format to upload
+          go tool covdata textfmt -i=coverage/unit,coverage/int -o=coverage.txt
       - uses: codecov/codecov-action@v3
         with:
           file: ./coverage.txt

cmd/cmd.go 🔗

@@ -33,7 +33,7 @@ func InitBackendContext(cmd *cobra.Command, _ []string) error {
 	ctx = db.WithContext(ctx, dbx)
 	dbstore := database.New(ctx, dbx)
 	ctx = store.WithContext(ctx, dbstore)
-	be := backend.New(ctx, cfg, dbx)
+	be := backend.New(ctx, cfg, dbx, dbstore)
 	ctx = backend.WithContext(ctx, be)
 
 	cmd.SetContext(ctx)

cmd/soft/serve/serve.go 🔗

@@ -3,9 +3,12 @@ package serve
 import (
 	"context"
 	"fmt"
+	"net/http"
 	"os"
 	"os/signal"
 	"path/filepath"
+	"strconv"
+	"sync"
 	"syscall"
 	"time"
 
@@ -80,10 +83,26 @@ var (
 			}
 
 			done := make(chan os.Signal, 1)
+			doneOnce := sync.OnceFunc(func() { close(done) })
+
 			lch := make(chan error, 1)
+
+			// This endpoint is added for testing purposes
+			// It allows us to stop the server from the test suite.
+			// This is needed since Windows doesn't support signals.
+			if testRun, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_TESTRUN")); testRun {
+				h := s.HTTPServer.Server.Handler
+				s.HTTPServer.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+					if r.URL.Path == "/__stop" && r.Method == http.MethodHead {
+						doneOnce()
+						return
+					}
+					h.ServeHTTP(w, r)
+				})
+			}
+
 			go func() {
-				defer close(lch)
-				defer close(done)
+				defer doneOnce()
 				lch <- s.Start()
 			}()
 

cmd/soft/serve/server.go 🔗

@@ -147,7 +147,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
 		for _, j := range jobs.List() {
 			s.Cron.Remove(j.ID)
 		}
-		s.Cron.Shutdown()
+		s.Cron.Stop()
 		return nil
 	})
 	// defer s.DB.Close() // nolint: errcheck

go.mod 🔗

@@ -41,8 +41,7 @@ require (
 	github.com/muesli/roff v0.1.0
 	github.com/prometheus/client_golang v1.17.0
 	github.com/robfig/cron/v3 v3.0.1
-	github.com/rogpeppe/go-internal v1.11.0
-	github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086
+	github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c
 	github.com/spf13/cobra v1.8.0
 	go.uber.org/automaxprocs v1.5.3
 	golang.org/x/crypto v0.16.0

go.sum 🔗

@@ -167,10 +167,8 @@ github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
 github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
-github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086 h1:mncRSDOqYCng7jOD+Y6+IivdRI6Kzv2BLWYkWkdQfu0=
-github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086/go.mod h1:YpdgDXpumPB/+EGmGTYHeiW/0QVFRzBYTNFaxWfPDk4=
+github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c h1:fPpdjePK1atuOg28PXfNSqgwf9I/qD1Hlo39JFwKBXk=
+github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=

pkg/access/context_test.go 🔗

@@ -0,0 +1,20 @@
+package access
+
+import (
+	"context"
+	"testing"
+)
+
+func TestGoodFromContext(t *testing.T) {
+	ctx := WithContext(context.TODO(), AdminAccess)
+	if ac := FromContext(ctx); ac != AdminAccess {
+		t.Errorf("FromContext(ctx) => %d, want %d", ac, AdminAccess)
+	}
+}
+
+func TestBadFromContext(t *testing.T) {
+	ctx := context.TODO()
+	if ac := FromContext(ctx); ac != -1 {
+		t.Errorf("FromContext(ctx) => %d, want %d", ac, -1)
+	}
+}

pkg/backend/backend.go 🔗

@@ -23,14 +23,13 @@ type Backend struct {
 }
 
 // New returns a new Soft Serve backend.
-func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend {
-	dbstore := store.FromContext(ctx)
+func New(ctx context.Context, cfg *config.Config, db *db.DB, st store.Store) *Backend {
 	logger := log.FromContext(ctx).WithPrefix("backend")
 	b := &Backend{
 		ctx:     ctx,
 		cfg:     cfg,
 		db:      db,
-		store:   dbstore,
+		store:   st,
 		logger:  logger,
 		manager: task.NewManager(ctx),
 	}

pkg/config/context_test.go 🔗

@@ -0,0 +1,29 @@
+package config
+
+import (
+	"context"
+	"reflect"
+	"testing"
+)
+
+func TestBadFromContext(t *testing.T) {
+	ctx := context.TODO()
+	if c := FromContext(ctx); c != nil {
+		t.Errorf("FromContext(ctx) => %v, want %v", c, nil)
+	}
+}
+
+func TestGoodFromContext(t *testing.T) {
+	ctx := WithContext(context.TODO(), &Config{})
+	if c := FromContext(ctx); c == nil {
+		t.Errorf("FromContext(ctx) => %v, want %v", c, &Config{})
+	}
+}
+
+func TestGoodFromContextWithDefaultConfig(t *testing.T) {
+	cfg := DefaultConfig()
+	ctx := WithContext(context.TODO(), cfg)
+	if c := FromContext(ctx); c == nil || !reflect.DeepEqual(c, cfg) {
+		t.Errorf("FromContext(ctx) => %v, want %v", c, cfg)
+	}
+}

pkg/config/file_test.go 🔗

@@ -0,0 +1,15 @@
+package config
+
+import "testing"
+
+func TestNewConfigFile(t *testing.T) {
+	for _, cfg := range []*Config{
+		nil,
+		DefaultConfig(),
+		&Config{},
+	} {
+		if s := newConfigFile(cfg); s == "" {
+			t.Errorf("newConfigFile(nil) => %q, want non-empty string", s)
+		}
+	}
+}

pkg/config/ssh.go 🔗

@@ -1,8 +1,28 @@
 package config
 
-import "github.com/charmbracelet/keygen"
+import (
+	"errors"
+
+	"github.com/charmbracelet/keygen"
+)
+
+var (
+	// ErrNilConfig is returned when a nil config is passed to a function.
+	ErrNilConfig = errors.New("nil config")
+
+	// ErrEmptySSHKeyPath is returned when the SSH key path is empty.
+	ErrEmptySSHKeyPath = errors.New("empty SSH key path")
+)
 
 // KeyPair returns the server's SSH key pair.
-func (c SSHConfig) KeyPair() (*keygen.SSHKeyPair, error) {
-	return keygen.New(c.KeyPath, keygen.WithKeyType(keygen.Ed25519))
+func KeyPair(cfg *Config) (*keygen.SSHKeyPair, error) {
+	if cfg == nil {
+		return nil, ErrNilConfig
+	}
+
+	if cfg.SSH.KeyPath == "" {
+		return nil, ErrEmptySSHKeyPath
+	}
+
+	return keygen.New(cfg.SSH.KeyPath, keygen.WithKeyType(keygen.Ed25519))
 }

pkg/config/ssh_test.go 🔗

@@ -0,0 +1,26 @@
+package config
+
+import "testing"
+
+func TestBadSSHKeyPair(t *testing.T) {
+	for _, cfg := range []*Config{
+		nil,
+		{},
+	} {
+		if _, err := KeyPair(cfg); err == nil {
+			t.Errorf("cfg.SSH.KeyPair() => _, nil, want non-nil error")
+		}
+	}
+}
+
+func TestGoodSSHKeyPair(t *testing.T) {
+	cfg := &Config{
+		SSH: SSHConfig{
+			KeyPath: "testdata/ssh_host_ed25519_key",
+		},
+	}
+
+	if _, err := KeyPair(cfg); err != nil {
+		t.Errorf("cfg.SSH.KeyPair() => _, %v, want nil error", err)
+	}
+}

pkg/cron/cron_test.go 🔗

@@ -0,0 +1,31 @@
+package cron
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/charmbracelet/log"
+)
+
+func TestCronLogger(t *testing.T) {
+	var buf bytes.Buffer
+	logger := log.New(&buf)
+	logger.SetLevel(log.DebugLevel)
+	clogger := cronLogger{logger}
+	clogger.Info("foo")
+	clogger.Error(fmt.Errorf("bar"), "test")
+	if buf.String() != "DEBU foo\nERRO test err=bar\n" {
+		t.Errorf("unexpected log output: %s", buf.String())
+	}
+}
+
+func TestSchedularAddRemove(t *testing.T) {
+	s := NewScheduler(context.TODO())
+	id, err := s.AddFunc("* * * * *", func() {})
+	if err != nil {
+		t.Fatal(err)
+	}
+	s.Remove(id)
+}

pkg/daemon/daemon.go 🔗

@@ -150,30 +150,35 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 		d.conns.Close(c) // nolint: errcheck
 	}()
 
-	readc := make(chan struct{}, 1)
+	errc := make(chan error, 1)
+
 	s := pktline.NewScanner(c)
 	go func() {
 		if !s.Scan() {
 			if err := s.Err(); err != nil {
-				if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
-					d.fatal(c, git.ErrTimeout)
-				} else {
-					d.logger.Debugf("git: error scanning pktline: %v", err)
-					d.fatal(c, git.ErrSystemMalfunction)
-				}
+				errc <- err
 			}
-			return
 		}
-		readc <- struct{}{}
+		errc <- nil
 	}()
 
 	select {
 	case <-ctx.Done():
 		if err := ctx.Err(); err != nil {
 			d.logger.Debugf("git: connection context error: %v", err)
+			d.fatal(c, git.ErrTimeout)
 		}
 		return
-	case <-readc:
+	case err := <-errc:
+		if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
+			d.fatal(c, git.ErrTimeout)
+			return
+		} else if err != nil {
+			d.logger.Debugf("git: error scanning pktline: %v", err)
+			d.fatal(c, git.ErrSystemMalfunction)
+			return
+		}
+
 		line := s.Bytes()
 		split := bytes.SplitN(line, []byte{' '}, 2)
 		if len(split) != 2 {

pkg/daemon/daemon_test.go 🔗

@@ -8,6 +8,7 @@ import (
 	"os"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/charmbracelet/soft-serve/pkg/backend"
 	"github.com/charmbracelet/soft-serve/pkg/config"
@@ -50,7 +51,7 @@ func TestMain(m *testing.M) {
 	}
 	datastore := database.New(ctx, dbx)
 	ctx = store.WithContext(ctx, datastore)
-	be := backend.New(ctx, cfg, dbx)
+	be := backend.New(ctx, cfg, dbx, datastore)
 	ctx = backend.WithContext(ctx, be)
 	d, err := NewGitDaemon(ctx)
 	if err != nil {
@@ -78,9 +79,10 @@ func TestIdleTimeout(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
+	time.Sleep(time.Second)
 	_, err = readPktline(c)
-	if err != nil && err.Error() != git.ErrTimeout.Error() {
-		t.Fatalf("expected %q error, got %q", git.ErrTimeout, err)
+	if err == nil {
+		t.Errorf("expected error, got nil")
 	}
 }
 
@@ -94,7 +96,7 @@ func TestInvalidRepo(t *testing.T) {
 	}
 	_, err = readPktline(c)
 	if err != nil && err.Error() != git.ErrInvalidRepo.Error() {
-		t.Fatalf("expected %q error, got %q", git.ErrInvalidRepo, err)
+		t.Errorf("expected %q error, got %q", git.ErrInvalidRepo, err)
 	}
 }
 

pkg/db/context_test.go 🔗

@@ -0,0 +1,28 @@
+package db_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/pkg/db"
+	"github.com/charmbracelet/soft-serve/pkg/db/internal/test"
+)
+
+func TestBadFromContext(t *testing.T) {
+	ctx := context.TODO()
+	if c := db.FromContext(ctx); c != nil {
+		t.Errorf("FromContext(ctx) => %v, want %v", c, nil)
+	}
+}
+
+func TestGoodFromContext(t *testing.T) {
+	ctx := context.TODO()
+	dbx, err := test.OpenSqlite(ctx, t)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ctx = db.WithContext(ctx, dbx)
+	if c := db.FromContext(ctx); c == nil {
+		t.Errorf("FromContext(ctx) => %v, want %v", c, dbx)
+	}
+}

pkg/db/db_test.go 🔗

@@ -0,0 +1,17 @@
+package db
+
+import (
+	"context"
+	"strings"
+	"testing"
+)
+
+func TestOpenUnknownDriver(t *testing.T) {
+	_, err := Open(context.TODO(), "invalid", "")
+	if err == nil {
+		t.Error("Open(invalid) => nil, want error")
+	}
+	if !strings.Contains(err.Error(), "unknown driver") {
+		t.Errorf("Open(invalid) => %v, want error containing 'unknown driver'", err)
+	}
+}

pkg/db/errors_test.go 🔗

@@ -0,0 +1,25 @@
+package db
+
+import (
+	"database/sql"
+	"errors"
+	"fmt"
+	"testing"
+)
+
+func TestWrapErrorBadNoRows(t *testing.T) {
+	for _, e := range []error{
+		fmt.Errorf("foo"),
+		errors.New("bar"),
+	} {
+		if err := WrapError(e); err != e {
+			t.Errorf("WrapError(%v) => %v, want %v", e, err, e)
+		}
+	}
+}
+
+func TestWrapErrorGoodNoRows(t *testing.T) {
+	if err := WrapError(sql.ErrNoRows); err != ErrRecordNotFound {
+		t.Errorf("WrapError(sql.ErrNoRows) => %v, want %v", err, ErrRecordNotFound)
+	}
+}

pkg/db/internal/test/test.go 🔗

@@ -0,0 +1,29 @@
+package test
+
+import (
+	"context"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/pkg/db"
+)
+
+// OpenSqlite opens a new temp SQLite database for testing.
+// It removes the database file when the test is done using tb.Cleanup.
+// If ctx is nil, context.TODO() is used.
+func OpenSqlite(ctx context.Context, tb testing.TB) (*db.DB, error) {
+	if ctx == nil {
+		ctx = context.TODO()
+	}
+	dbpath := filepath.Join(tb.TempDir(), "test.db")
+	dbx, err := db.Open(ctx, "sqlite", dbpath)
+	if err != nil {
+		return nil, err
+	}
+	tb.Cleanup(func() {
+		if err := dbx.Close(); err != nil {
+			tb.Error(err)
+		}
+	})
+	return dbx, nil
+}

pkg/db/migrate/migrate_test.go 🔗

@@ -0,0 +1,22 @@
+package migrate
+
+import (
+	"context"
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/db/internal/test"
+)
+
+func TestMigrate(t *testing.T) {
+	// XXX: we need a config.Config in the context for the migrations to run
+	// properly. Some migrations depend on the config being present.
+	ctx := config.WithContext(context.TODO(), config.DefaultConfig())
+	dbx, err := test.OpenSqlite(ctx, t)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := Migrate(ctx, dbx); err != nil {
+		t.Errorf("Migrate() => %v, want nil error", err)
+	}
+}

pkg/git/git.go 🔗

@@ -2,6 +2,7 @@ package git
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"path/filepath"
@@ -13,6 +14,11 @@ import (
 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 )
 
+var (
+	// ErrNoBranches is returned when a repo has no branches.
+	ErrNoBranches = errors.New("no branches found")
+)
+
 // WritePktline encodes and writes a pktline to the given writer.
 func WritePktline(w io.Writer, v ...interface{}) error {
 	msg := fmt.Sprintln(v...)
@@ -57,18 +63,18 @@ func EnsureWithin(reposDir string, repo string) error {
 
 // EnsureDefaultBranch ensures the repo has a default branch.
 // It will prefer choosing "main" or "master" if available.
-func EnsureDefaultBranch(ctx context.Context, scmd ServiceCommand) error {
-	r, err := git.Open(scmd.Dir)
+func EnsureDefaultBranch(ctx context.Context, repoPath string) error {
+	r, err := git.Open(repoPath)
 	if err != nil {
 		return err
 	}
 	brs, err := r.Branches()
+	if len(brs) == 0 {
+		return ErrNoBranches
+	}
 	if err != nil {
 		return err
 	}
-	if len(brs) == 0 {
-		return fmt.Errorf("no branches found")
-	}
 	// Rename the default branch to the first branch available
 	_, err = r.HEAD()
 	if err == git.ErrReferenceNotExist {

pkg/git/git_test.go 🔗

@@ -2,8 +2,12 @@ package git
 
 import (
 	"bytes"
+	"context"
+	"errors"
 	"fmt"
 	"testing"
+
+	"github.com/charmbracelet/soft-serve/git"
 )
 
 func TestPktline(t *testing.T) {
@@ -54,3 +58,40 @@ func TestPktline(t *testing.T) {
 		})
 	}
 }
+
+func TestEnsureWithinBad(t *testing.T) {
+	tmp := t.TempDir()
+	for _, f := range []string{
+		"..",
+		"../../../",
+	} {
+		if err := EnsureWithin(tmp, f); err == nil {
+			t.Errorf("EnsureWithin(%q, %q) => nil, want non-nil error", tmp, f)
+		}
+	}
+}
+
+func TestEnsureWithinGood(t *testing.T) {
+	tmp := t.TempDir()
+	for _, f := range []string{
+		tmp,
+		tmp + "/foo",
+		tmp + "/foo/bar",
+	} {
+		if err := EnsureWithin(tmp, f); err != nil {
+			t.Errorf("EnsureWithin(%q, %q) => %v, want nil error", tmp, f, err)
+		}
+	}
+}
+
+func TestEnsureDefaultBranchEmpty(t *testing.T) {
+	tmp := t.TempDir()
+	r, err := git.Init(tmp, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err := EnsureDefaultBranch(context.TODO(), r.Path); !errors.Is(err, ErrNoBranches) {
+		t.Errorf("EnsureDefaultBranch(%q) => %v, want ErrNoBranches", tmp, err)
+	}
+}

pkg/git/lfs.go 🔗

@@ -21,17 +21,8 @@ import (
 	"github.com/charmbracelet/soft-serve/pkg/proto"
 	"github.com/charmbracelet/soft-serve/pkg/storage"
 	"github.com/charmbracelet/soft-serve/pkg/store"
-	"github.com/rubyist/tracerx"
 )
 
-func init() {
-	// git-lfs-transfer uses tracerx for logging.
-	// use a custom key to avoid conflicts
-	// SOFT_SERVE_TRACE=1 to enable tracing git-lfs-transfer in soft-serve
-	tracerx.DefaultKey = "SOFT_SERVE"
-	tracerx.Prefix = "trace soft-serve-lfs-transfer: "
-}
-
 // lfsTransfer implements transfer.Backend.
 type lfsTransfer struct {
 	ctx     context.Context

pkg/hooks/gen.go 🔗

@@ -3,7 +3,6 @@ package hooks
 import (
 	"bytes"
 	"context"
-	"flag"
 	"os"
 	"path/filepath"
 	"text/template"
@@ -30,11 +29,6 @@ const (
 // This function should be called by the backend when a repository is created.
 // TODO: support context.
 func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error {
-	// TODO: support git hook tests.
-	if flag.Lookup("test.v") != nil {
-		log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test")
-		return nil
-	}
 	repo = utils.SanitizeRepo(repo) + ".git"
 	hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
 	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {

pkg/hooks/gen_test.go 🔗

@@ -0,0 +1,40 @@
+package hooks
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+)
+
+func TestGenerateHooks(t *testing.T) {
+	tmp := t.TempDir()
+	cfg := config.DefaultConfig()
+	cfg.DataPath = tmp
+	repoPath := filepath.Join(tmp, "repos", "test.git")
+	_, err := git.Init(repoPath, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err := GenerateHooks(context.TODO(), cfg, "test.git"); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, hn := range []string{
+		PreReceiveHook,
+		UpdateHook,
+		PostReceiveHook,
+		PostUpdateHook,
+	} {
+		if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn)); err != nil {
+			t.Fatal(err)
+		}
+		if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn+".d", "soft-serve")); err != nil {
+			t.Fatal(err)
+		}
+	}
+}

pkg/jwk/jwk.go 🔗

@@ -32,7 +32,7 @@ func (p Pair) JWK() jose.JSONWebKey {
 
 // NewPair creates a new JSON Web Key pair.
 func NewPair(cfg *config.Config) (Pair, error) {
-	kp, err := cfg.SSH.KeyPair()
+	kp, err := config.KeyPair(cfg)
 	if err != nil {
 		return Pair{}, err
 	}

pkg/jwk/jwk_test.go 🔗

@@ -0,0 +1,22 @@
+package jwk
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/pkg/config"
+)
+
+func TestBadNewPair(t *testing.T) {
+	_, err := NewPair(nil)
+	if !errors.Is(err, config.ErrNilConfig) {
+		t.Errorf("NewPair(nil) => %v, want %v", err, config.ErrNilConfig)
+	}
+}
+
+func TestGoodNewPair(t *testing.T) {
+	cfg := config.DefaultConfig()
+	if _, err := NewPair(cfg); err != nil {
+		t.Errorf("NewPair(cfg) => _, %v, want nil error", err)
+	}
+}

pkg/log/log.go 🔗

@@ -11,6 +11,9 @@ import (
 
 // NewLogger returns a new logger with default settings.
 func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
+	if cfg == nil {
+		return nil, nil, config.ErrNilConfig
+	}
 	logger := log.NewWithOptions(os.Stderr, log.Options{
 		ReportTimestamp: true,
 		TimeFormat:      time.DateOnly,
@@ -37,7 +40,8 @@ func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
 
 	var f *os.File
 	if cfg.Log.Path != "" {
-		f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+		var err error
+		f, err = os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 		if err != nil {
 			return nil, nil, err
 		}

pkg/log/log_test.go 🔗

@@ -0,0 +1,43 @@
+package log
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/charmbracelet/soft-serve/pkg/config"
+)
+
+func TestGoodNewLogger(t *testing.T) {
+	for _, c := range []*config.Config{
+		config.DefaultConfig(),
+		{},
+		{Log: config.LogConfig{Path: filepath.Join(t.TempDir(), "logfile.txt")}},
+	} {
+		_, f, err := NewLogger(c)
+		if err != nil {
+			t.Errorf("expected nil got %v", err)
+		}
+		if f != nil {
+			if err := f.Close(); err != nil {
+				t.Errorf("failed to close logger: %v", err)
+			}
+		}
+	}
+}
+
+func TestBadNewLogger(t *testing.T) {
+	for _, c := range []*config.Config{
+		nil,
+		{Log: config.LogConfig{Path: "\x00"}},
+	} {
+		_, f, err := NewLogger(c)
+		if err == nil {
+			t.Errorf("expected error got nil")
+		}
+		if f != nil {
+			if err := f.Close(); err != nil {
+				t.Errorf("failed to close logger: %v", err)
+			}
+		}
+	}
+}

pkg/ssh/cmd/git.go 🔗

@@ -249,7 +249,7 @@ func gitRunE(cmd *cobra.Command, args []string) error {
 			return git.ErrSystemMalfunction
 		}
 
-		if err := git.EnsureDefaultBranch(ctx, scmd); err != nil {
+		if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {
 			logger.Error("failed to ensure default branch", "err", err, "repo", name)
 			return git.ErrSystemMalfunction
 		}

pkg/ssh/session_test.go 🔗

@@ -76,7 +76,7 @@ func setup(tb testing.TB) (*gossh.Session, func() error) {
 	}
 	dbstore := database.New(ctx, dbx)
 	ctx = store.WithContext(ctx, dbstore)
-	be := backend.New(ctx, cfg, dbx)
+	be := backend.New(ctx, cfg, dbx, dbstore)
 	ctx = backend.WithContext(ctx, be)
 	return testsession.New(tb, &ssh.Server{
 		Handler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) {

pkg/web/auth.go 🔗

@@ -136,7 +136,7 @@ var ErrInvalidToken = errors.New("invalid token")
 func parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) {
 	cfg := config.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http.auth")
-	kp, err := cfg.SSH.KeyPair()
+	kp, err := config.KeyPair(cfg)
 	if err != nil {
 		return nil, err
 	}

pkg/web/git.go 🔗

@@ -441,7 +441,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if service == git.ReceivePackService {
-		if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
+		if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {
 			logger.Errorf("failed to ensure default branch: %s", err)
 		}
 	}

pkg/web/http.go 🔗

@@ -11,9 +11,10 @@ import (
 
 // HTTPServer is an http server.
 type HTTPServer struct {
-	ctx    context.Context
-	cfg    *config.Config
-	server *http.Server
+	ctx context.Context
+	cfg *config.Config
+
+	Server *http.Server
 }
 
 // NewHTTPServer creates a new HTTP server.
@@ -23,7 +24,7 @@ func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
 	s := &HTTPServer{
 		ctx: ctx,
 		cfg: cfg,
-		server: &http.Server{
+		Server: &http.Server{
 			Addr:              cfg.HTTP.ListenAddr,
 			Handler:           NewRouter(ctx),
 			ReadHeaderTimeout: time.Second * 10,
@@ -38,18 +39,18 @@ func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
 
 // Close closes the HTTP server.
 func (s *HTTPServer) Close() error {
-	return s.server.Close()
+	return s.Server.Close()
 }
 
 // ListenAndServe starts the HTTP server.
 func (s *HTTPServer) ListenAndServe() error {
 	if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" {
-		return s.server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
+		return s.Server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
 	}
-	return s.server.ListenAndServe()
+	return s.Server.ListenAndServe()
 }
 
 // Shutdown gracefully shuts down the HTTP server.
 func (s *HTTPServer) Shutdown(ctx context.Context) error {
-	return s.server.Shutdown(ctx)
+	return s.Server.Shutdown(ctx)
 }

pkg/webhook/content_type_test.go 🔗

@@ -0,0 +1,117 @@
+package webhook
+
+import "testing"
+
+func TestParseContentType(t *testing.T) {
+	tests := []struct {
+		name string
+		s    string
+		want ContentType
+		err  error
+	}{
+		{
+			name: "JSON",
+			s:    "application/json",
+			want: ContentTypeJSON,
+		},
+		{
+			name: "Form",
+			s:    "application/x-www-form-urlencoded",
+			want: ContentTypeForm,
+		},
+		{
+			name: "Invalid",
+			s:    "application/invalid",
+			err:  ErrInvalidContentType,
+			want: -1,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseContentType(tt.s)
+			if err != tt.err {
+				t.Errorf("ParseContentType() error = %v, wantErr %v", err, tt.err)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("ParseContentType() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestUnmarshalText(t *testing.T) {
+	tests := []struct {
+		name    string
+		text    []byte
+		want    ContentType
+		wantErr bool
+	}{
+		{
+			name: "JSON",
+			text: []byte("application/json"),
+			want: ContentTypeJSON,
+		},
+		{
+			name: "Form",
+			text: []byte("application/x-www-form-urlencoded"),
+			want: ContentTypeForm,
+		},
+		{
+			name:    "Invalid",
+			text:    []byte("application/invalid"),
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			c := new(ContentType)
+			if err := c.UnmarshalText(tt.text); (err != nil) != tt.wantErr {
+				t.Errorf("ContentType.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if *c != tt.want {
+				t.Errorf("ContentType.UnmarshalText() got = %v, want %v", *c, tt.want)
+			}
+		})
+	}
+}
+
+func TestMarshalText(t *testing.T) {
+	tests := []struct {
+		name    string
+		c       ContentType
+		want    []byte
+		wantErr bool
+	}{
+		{
+			name: "JSON",
+			c:    ContentTypeJSON,
+			want: []byte("application/json"),
+		},
+		{
+			name: "Form",
+			c:    ContentTypeForm,
+			want: []byte("application/x-www-form-urlencoded"),
+		},
+		{
+			name:    "Invalid",
+			c:       ContentType(-1),
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			b, err := tt.c.MarshalText()
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ContentType.MarshalText() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if string(b) != string(tt.want) {
+				t.Errorf("ContentType.MarshalText() got = %v, want %v", string(b), string(tt.want))
+			}
+		})
+	}
+}

pkg/webhook/push.go 🔗

@@ -2,6 +2,7 @@ package webhook
 
 import (
 	"context"
+	"errors"
 	"fmt"
 
 	gitm "github.com/aymanbagabas/git-module"
@@ -75,7 +76,11 @@ func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, r
 	}
 
 	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
-	if err != nil {
+	// XXX: we check for ErrReferenceNotExist here because we don't want to
+	// return an error if the repo is an empty repo.
+	// This means that the repo doesn't have a default branch yet and this is
+	// the first push to it.
+	if err != nil && !errors.Is(err, git.ErrReferenceNotExist) {
 		return PushEvent{}, err
 	}
 

testscript/script_test.go 🔗

@@ -3,41 +3,65 @@ package testscript
 import (
 	"bytes"
 	"context"
-	"database/sql"
+	"encoding/json"
 	"flag"
 	"fmt"
 	"io"
+	"math/rand"
 	"net"
 	"net/http"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
+	"runtime"
 	"strings"
-	"sync"
 	"testing"
 	"time"
 
 	"github.com/charmbracelet/keygen"
-	"github.com/charmbracelet/log"
-	"github.com/charmbracelet/soft-serve/cmd/soft/serve"
-	"github.com/charmbracelet/soft-serve/pkg/backend"
 	"github.com/charmbracelet/soft-serve/pkg/config"
 	"github.com/charmbracelet/soft-serve/pkg/db"
-	"github.com/charmbracelet/soft-serve/pkg/db/migrate"
-	logr "github.com/charmbracelet/soft-serve/pkg/log"
-	"github.com/charmbracelet/soft-serve/pkg/store"
-	"github.com/charmbracelet/soft-serve/pkg/store/database"
 	"github.com/charmbracelet/soft-serve/pkg/test"
 	"github.com/rogpeppe/go-internal/testscript"
 	"github.com/spf13/cobra"
 	"golang.org/x/crypto/ssh"
 )
 
-var update = flag.Bool("update", false, "update script files")
+var (
+	update  = flag.Bool("update", false, "update script files")
+	binPath string
+)
+
+func TestMain(m *testing.M) {
+	tmp, err := os.MkdirTemp("", "soft-serve*")
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "failed to create temporary directory: %s", err)
+		os.Exit(1)
+	}
+	defer os.RemoveAll(tmp)
+
+	binPath = filepath.Join(tmp, "soft")
+	if runtime.GOOS == "windows" {
+		binPath += ".exe"
+	}
+
+	// Build the soft binary with -cover flag.
+	cmd := exec.Command("go", "build", "-race", "-cover", "-o", binPath, filepath.Join("..", "cmd", "soft"))
+	if err := cmd.Run(); err != nil {
+		fmt.Fprintf(os.Stderr, "failed to build soft-serve binary: %s", err)
+		os.Exit(1)
+	}
+
+	// Run tests
+	os.Exit(m.Run())
+
+	// Add binPath to PATH
+	os.Setenv("PATH", fmt.Sprintf("%s%c%s", os.Getenv("PATH"), os.PathListSeparator, filepath.Dir(binPath)))
+}
 
 func TestScript(t *testing.T) {
 	flag.Parse()
-	var lock sync.Mutex
 
 	mkkey := func(name string) (string, *keygen.SSHKeyPair) {
 		path := filepath.Join(t.TempDir(), name)
@@ -53,21 +77,27 @@ func TestScript(t *testing.T) {
 	_, user1 := mkkey("user1")
 
 	testscript.Run(t, testscript.Params{
-		Dir:           "./testdata/",
-		UpdateScripts: *update,
+		Dir:                 "./testdata/",
+		UpdateScripts:       *update,
+		RequireExplicitExec: true,
 		Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
-			"soft":     cmdSoft(admin1.Signer()),
-			"usoft":    cmdSoft(user1.Signer()),
-			"git":      cmdGit(key),
-			"curl":     cmdCurl,
-			"mkfile":   cmdMkfile,
-			"envfile":  cmdEnvfile,
-			"readfile": cmdReadfile,
-			"dos2unix": cmdDos2Unix,
+			"soft":          cmdSoft(admin1.Signer()),
+			"usoft":         cmdSoft(user1.Signer()),
+			"git":           cmdGit(key),
+			"curl":          cmdCurl,
+			"mkfile":        cmdMkfile,
+			"envfile":       cmdEnvfile,
+			"readfile":      cmdReadfile,
+			"dos2unix":      cmdDos2Unix,
+			"new-webhook":   cmdNewWebhook,
+			"waitforserver": cmdWaitforserver,
+			"stopserver":    cmdStopserver,
 		},
 		Setup: func(e *testscript.Env) error {
-			data := t.TempDir()
+			// Add binPath to PATH
+			e.Setenv("PATH", fmt.Sprintf("%s%c%s", filepath.Dir(binPath), os.PathListSeparator, e.Getenv("PATH")))
 
+			data := t.TempDir()
 			sshPort := test.RandomPort()
 			sshListen := fmt.Sprintf("localhost:%d", sshPort)
 			gitPort := test.RandomPort()
@@ -87,6 +117,20 @@ func TestScript(t *testing.T) {
 			e.Setenv("SSH_KNOWN_HOSTS_FILE", filepath.Join(t.TempDir(), "known_hosts"))
 			e.Setenv("SSH_KNOWN_CONFIG_FILE", filepath.Join(t.TempDir(), "config"))
 
+			// This is used to set up test specific configuration and http endpoints
+			e.Setenv("SOFT_SERVE_TESTRUN", "1")
+
+			// Soft Serve debug environment variables
+			for _, env := range []string{
+				"SOFT_SERVE_DEBUG",
+				"SOFT_SERVE_VERBOSE",
+			} {
+				if v, ok := os.LookupEnv(env); ok {
+					e.Setenv(env, v)
+				}
+			}
+
+			// TODO: test different configs
 			cfg := config.DefaultConfig()
 			cfg.DataPath = data
 			cfg.Name = serverName
@@ -97,20 +141,16 @@ func TestScript(t *testing.T) {
 			cfg.HTTP.ListenAddr = httpListen
 			cfg.HTTP.PublicURL = "http://" + httpListen
 			cfg.Stats.ListenAddr = statsListen
-			cfg.DB.Driver = "sqlite"
 			cfg.LFS.Enabled = true
-			cfg.LFS.SSHEnabled = true
-
-			dbDriver := os.Getenv("DB_DRIVER")
-			if dbDriver != "" {
-				cfg.DB.Driver = dbDriver
-			}
+			// cfg.LFS.SSHEnabled = true
 
-			dbDsn := os.Getenv("DB_DATA_SOURCE")
-			if dbDsn != "" {
-				cfg.DB.DataSource = dbDsn
+			// Parse os SOFT_SERVE environment variables
+			if err := cfg.ParseEnv(); err != nil {
+				return err
 			}
 
+			// Override the database data source if we're using postgres
+			// so we can create a temporary database for the tests.
 			if cfg.DB.Driver == "postgres" {
 				err, cleanup := setupPostgres(e.T(), cfg)
 				if err != nil {
@@ -121,73 +161,12 @@ func TestScript(t *testing.T) {
 				}
 			}
 
-			if err := cfg.Validate(); err != nil {
-				return err
-			}
-
-			ctx := config.WithContext(context.Background(), cfg)
-
-			logger, f, err := logr.NewLogger(cfg)
-			if err != nil {
-				log.Errorf("failed to create logger: %v", err)
-			}
-
-			ctx = log.WithContext(ctx, logger)
-			if f != nil {
-				defer f.Close() // nolint: errcheck
-			}
-
-			dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
-			if err != nil {
-				return fmt.Errorf("open database: %w", err)
-			}
-
-			if err := migrate.Migrate(ctx, dbx); err != nil {
-				return fmt.Errorf("migrate database: %w", err)
-			}
-
-			ctx = db.WithContext(ctx, dbx)
-			datastore := database.New(ctx, dbx)
-			ctx = store.WithContext(ctx, datastore)
-			be := backend.New(ctx, cfg, dbx)
-			ctx = backend.WithContext(ctx, be)
-
-			lock.Lock()
-			srv, err := serve.NewServer(ctx)
-			if err != nil {
-				lock.Unlock()
-				return err
-			}
-			lock.Unlock()
-
-			go func() {
-				if err := srv.Start(); err != nil {
-					e.T().Fatal(err)
-				}
-			}()
-
-			e.Defer(func() {
-				defer dbx.Close() // nolint: errcheck
-				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-				defer cancel()
-				lock.Lock()
-				defer lock.Unlock()
-				if err := srv.Shutdown(ctx); err != nil {
-					e.T().Fatal(err)
-				}
-			})
-
-			// wait until the server is up
-			for {
-				conn, _ := net.DialTimeout(
-					"tcp",
-					net.JoinHostPort("localhost", fmt.Sprintf("%d", sshPort)),
-					time.Second,
-				)
-				if conn != nil {
-					conn.Close()
-					break
+			for _, env := range cfg.Environ() {
+				parts := strings.SplitN(env, "=", 2)
+				if len(parts) != 2 {
+					e.T().Fatal("invalid environment variable", env)
 				}
+				e.Setenv(parts[0], parts[1])
 			}
 
 			return nil
@@ -318,6 +297,29 @@ func cmdEnvfile(ts *testscript.TestScript, neg bool, args []string) {
 	}
 }
 
+func cmdNewWebhook(ts *testscript.TestScript, neg bool, args []string) {
+	type webhookSite struct {
+		UUID string `json:"uuid"`
+	}
+
+	if len(args) != 1 {
+		ts.Fatalf("usage: new-webhook <env-name>")
+	}
+
+	const whSite = "https://webhook.site"
+	req, err := http.NewRequest(http.MethodPost, whSite+"/token", nil)
+	check(ts, err, neg)
+
+	resp, err := http.DefaultClient.Do(req)
+	check(ts, err, neg)
+
+	defer resp.Body.Close()
+	var site webhookSite
+	check(ts, json.NewDecoder(resp.Body).Decode(&site), neg)
+
+	ts.Setenv(args[0], whSite+"/"+site.UUID)
+}
+
 func cmdCurl(ts *testscript.TestScript, neg bool, args []string) {
 	var verbose bool
 	var headers []string
@@ -405,11 +407,35 @@ func cmdCurl(ts *testscript.TestScript, neg bool, args []string) {
 	check(ts, cmd.Execute(), neg)
 }
 
+func cmdWaitforserver(ts *testscript.TestScript, neg bool, args []string) {
+	// wait until the server is up
+	for {
+		conn, _ := net.DialTimeout(
+			"tcp",
+			net.JoinHostPort("localhost", fmt.Sprintf("%s", ts.Getenv("SSH_PORT"))),
+			time.Second,
+		)
+		if conn != nil {
+			conn.Close()
+			break
+		}
+	}
+}
+
+func cmdStopserver(ts *testscript.TestScript, neg bool, args []string) {
+	// stop the server
+	resp, err := http.DefaultClient.Head(fmt.Sprintf("%s/__stop", ts.Getenv("SOFT_SERVE_HTTP_PUBLIC_URL")))
+	check(ts, err, neg)
+	defer resp.Body.Close()
+	time.Sleep(time.Second * 2) // Allow some time for the server to stop
+}
+
 func setupPostgres(t testscript.T, cfg *config.Config) (error, func()) {
 	// Indicates postgres
 	// Create a disposable database
-	dbName := fmt.Sprintf("softserve_test_%d", time.Now().UnixNano())
-	dbDsn := os.Getenv("DB_DATA_SOURCE")
+	rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
+	dbName := fmt.Sprintf("softserve_test_%d", rnd.Int63())
+	dbDsn := cfg.DB.DataSource
 	if dbDsn == "" {
 		cfg.DB.DataSource = "postgres://postgres@localhost:5432/postgres?sslmode=disable"
 	}
@@ -419,7 +445,17 @@ func setupPostgres(t testscript.T, cfg *config.Config) (error, func()) {
 		return err, nil
 	}
 
-	connInfo := fmt.Sprintf("host=%s sslmode=disable", dbUrl.Hostname())
+	scheme := dbUrl.Scheme
+	if scheme == "" {
+		scheme = "postgres"
+	}
+
+	host := dbUrl.Hostname()
+	if host == "" {
+		host = "localhost"
+	}
+
+	connInfo := fmt.Sprintf("host=%s sslmode=disable", host)
 	username := dbUrl.User.Username()
 	if username != "" {
 		connInfo += fmt.Sprintf(" user=%s", username)
@@ -431,6 +467,7 @@ func setupPostgres(t testscript.T, cfg *config.Config) (error, func()) {
 		username = fmt.Sprintf("%s@", username)
 	} else {
 		connInfo += " user=postgres"
+		username = "postgres@"
 	}
 
 	port := dbUrl.Port()
@@ -440,32 +477,31 @@ func setupPostgres(t testscript.T, cfg *config.Config) (error, func()) {
 	}
 
 	cfg.DB.DataSource = fmt.Sprintf("%s://%s%s%s/%s?sslmode=disable",
-		dbUrl.Scheme,
+		scheme,
 		username,
-		dbUrl.Hostname(),
+		host,
 		port,
 		dbName,
 	)
 
 	// Create the database
-	db, err := sql.Open(cfg.DB.Driver, connInfo)
+	dbx, err := db.Open(context.TODO(), cfg.DB.Driver, connInfo)
 	if err != nil {
 		return err, nil
 	}
 
-	if _, err := db.Exec("CREATE DATABASE " + dbName); err != nil {
+	if _, err := dbx.Exec("CREATE DATABASE " + dbName); err != nil {
 		return err, nil
 	}
 
 	return nil, func() {
-		db, err := sql.Open(cfg.DB.Driver, connInfo)
+		dbx, err := db.Open(context.TODO(), cfg.DB.Driver, connInfo)
 		if err != nil {
-			t.Log("failed to open database", dbName, err)
-			return
+			t.Fatal("failed to open database", dbName, err)
 		}
 
-		if _, err := db.Exec("DROP DATABASE " + dbName); err != nil {
-			t.Log("failed to drop database", dbName, err)
+		if _, err := dbx.Exec("DROP DATABASE " + dbName); err != nil {
+			t.Fatal("failed to drop database", dbName, err)
 		}
 	}
 }

testscript/testdata/help.txtar 🔗

@@ -1,9 +1,18 @@
 # vi: set ft=conf
 [windows] dos2unix help.txt
 
+# start soft serve
+exec soft serve --sync-hooks &
+# wait for server to start
+waitforserver
+
 soft --help
 cmpenv stdout help.txt
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- help.txt --
 Soft Serve is a self-hostable Git server for the command line.
 

testscript/testdata/http.txtar 🔗

@@ -6,6 +6,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create user
 soft user create user1 --key "$USER1_AUTHORIZED_KEY"
 
@@ -133,6 +138,10 @@ stdout '404.*'
 curl http://$TOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1
 cmpenv stdout goget.txt
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- http1.txt --
 {"transfer":"basic","objects":[{"oid":"","size":0,"error":{"code":422,"message":"invalid object"}}],"hash_algo":"sha256"}
 -- http2.txt --

testscript/testdata/jwt.txtar 🔗

@@ -1,5 +1,10 @@
 # vi: set ft=conf
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create user
 soft user create user1 --key "$USER1_AUTHORIZED_KEY"
 
@@ -12,3 +17,7 @@ usoft jwt
 stdout '.*\..*\..*'
 usoft jwt repo
 stdout '.*\..*\..*'
+
+# stop the server
+[windows] stopserver
+[windows] ! stderr .

testscript/testdata/mirror.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix info1.txt info2.txt tree.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # import a repo
 soft repo import --mirror charmbracelet/catwalk https://github.com/charmbracelet/catwalk.git
 
@@ -74,6 +79,10 @@ cmp stdout info2.txt
 soft repo blob charmbracelet/test LICENSE
 stdout '.*Creative Commons.*'
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 
 -- info1.txt --
 Project Name:

testscript/testdata/repo-blob.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix blob1.txt blob2.txt blob3.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a repo
 soft repo create repo1
 
@@ -43,6 +48,9 @@ stderr 'revision does not exist'
 ! stdout .
 stderr 'revision does not exist'
 
+# stop the server
+[windows] stopserver
+
 -- blob1.txt --
 # Hello\n\nwelcome
 -- blob2.txt --

testscript/testdata/repo-collab.txtar 🔗

@@ -1,4 +1,10 @@
 # vi: set ft=conf
+
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # setup
 soft repo import test https://github.com/charmbracelet/catwalk.git
 soft user create foo --key "$USER1_AUTHORIZED_KEY"
@@ -16,3 +22,7 @@ stdout 'foo'
 soft repo collab remove test foo
 soft repo collab list test
 ! stdout .
+
+# stop the server
+[windows] stopserver
+[windows] ! stderr .

testscript/testdata/repo-commit.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix commit1.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a repo
 soft repo import basic1 https://github.com/git-fixtures/basic
 
@@ -10,6 +15,10 @@ soft repo import basic1 https://github.com/git-fixtures/basic
 soft repo commit basic1 b8e471f58bcbca63b07bda20e428190409c2db47
 cmp stdout commit1.txt
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- commit1.txt --
 commit b8e471f58bcbca63b07bda20e428190409c2db47
 Author: Daniel Ripolles

testscript/testdata/repo-create.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix readme.md branch_list.1.txt info.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a repo
 soft repo create repo1 -d 'description' -H -p -n 'repo11'
 stderr 'Created repository repo1.*'
@@ -114,6 +119,10 @@ stdout 'repo2'
 usoft repo delete repo2
 ! exists $DATA_PATH/repos/repo2.git
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 
 -- readme.md --
 # Project\nfoo

testscript/testdata/repo-delete.txtar 🔗

@@ -1,5 +1,10 @@
 # vi: set ft=conf
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 soft repo create repo1
 soft repo create repo-to-delete
 soft repo delete repo-to-delete
@@ -7,3 +12,7 @@ soft repo delete repo-to-delete
 stderr '.*not found.*'
 soft repo list
 stdout 'repo1'
+
+# stop the server
+[windows] stopserver
+[windows] ! stderr .

testscript/testdata/repo-import.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix repo3.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # import private
 soft repo import --private repo1 https://github.com/charmbracelet/catwalk.git
 soft repo private repo1
@@ -18,6 +23,10 @@ soft repo import --name 'repo33' --description 'descriptive' repo3 https://githu
 soft repo info repo3
 cmp stdout repo3.txt
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- repo3.txt --
 Project Name: repo33
 Repository: repo3

testscript/testdata/repo-perms.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix info.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a repo & user1 with admin
 soft repo create repo1 -p
 soft user create user1 -k "$USER1_AUTHORIZED_KEY"
@@ -77,6 +82,10 @@ usoft repo delete repo1
 usoft repo list
 ! stdout .
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- info.txt --
 Project Name: proj
 Repository: repo1

testscript/testdata/repo-push.txtar 🔗

@@ -0,0 +1,18 @@
+# vi: set ft=conf
+
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
+# create a repo
+soft repo create repo-empty -d 'description' -H -p -n 'repo-empty'
+
+# clone repo
+git clone ssh://localhost:$SSH_PORT/repo-empty repo-empty
+
+# push repo
+! git -C repo-empty push origin HEAD
+
+# stop the server
+[windows] stopserver

testscript/testdata/repo-tree.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix tree1.txt tree2.txt tree3.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a repo
 soft repo create repo1
 
@@ -42,6 +47,9 @@ stderr 'file not found'
 ! stdout .
 stderr 'revision does not exist'
 
+# stop the server
+[windows] stopserver
+
 -- tree1.txt --
 drwxrwxrwx	-	 folder
 -rw-r--r--	-	 .hidden

testscript/testdata/repo-webhooks.txtar 🔗

@@ -1,27 +1,34 @@
 # vi: set ft=conf
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a repo
 soft repo create repo-123
 stderr 'Created repository repo-123.*'
 stdout ssh://localhost:$SSH_PORT/repo-123.git
 
 # create webhook
-soft repo webhook create repo-123 https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change
+new-webhook WH_REPO_123
+soft repo webhook create repo-123 $WH_REPO_123 -e branch_tag_create -e branch_tag_delete -e collaborator -e push -e repository -e repository_visibility_change
 
 # list webhooks
 soft repo webhook list repo-123
-stdout '1.*https://webhook.site/794fa12b-08d4-4362-a0a9-a6f995f22e17.*'
+stdout '1.*webhook.site/.*'
 
-# clone repo
+# clone repo and commit files
 git clone ssh://localhost:$SSH_PORT/repo-123 repo-123
-
-# create files
 mkfile ./repo-123/README.md 'foobar'
 git -C repo-123 add -A
 git -C repo-123 commit -m 'first'
 git -C repo-123 push origin HEAD
 
 # list webhook deliveries
-# TODO: enable this test when githooks tests are fixed
-# soft repo webhook deliver list repo-123 1
-# stdout '.*https://webhook.site/.*'
+soft repo webhook deliver list repo-123 1
+stdout '✅.*push.*'
+
+# stop the server
+[windows] stopserver
+[windows] ! stderr .

testscript/testdata/set-username.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix info1.txt info2.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # get original username
 soft info
 cmpenv stdout info1.txt
@@ -12,6 +17,10 @@ soft set-username test
 soft info
 cmpenv stdout info2.txt
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- info1.txt --
 Username: admin
 Admin: true

testscript/testdata/settings.txtar 🔗

@@ -1,4 +1,10 @@
 # vi: set ft=conf
+
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # check default allow-keyless
 soft settings allow-keyless true
 soft settings allow-keyless
@@ -35,3 +41,6 @@ stdout 'admin-access.*'
 ! stdout .
 stderr .
 
+# stop the server
+[windows] stopserver
+

testscript/testdata/soft-browse.txtar 🔗

@@ -0,0 +1,29 @@
+# vi: set ft=conf
+
+[windows] skip
+
+# clone repo
+git clone https://github.com/charmbracelet/catwalk.git catwalk
+
+# run soft browse
+ttyin input.txt
+exec soft browse ./catwalk
+
+# cd and run soft
+cd catwalk
+ttyin ../input.txt
+exec soft
+
+-- input.txt --
+jjkkdduu
+
+jjkkdduu
+
+jjkkdduu
+
+jjkkdduu
+
+jjkkdduu
+
+qqq
+

testscript/testdata/ssh.txtar 🔗

@@ -2,6 +2,11 @@
 
 [windows] dos2unix argserr1.txt argserr2.txt argserr3.txt invalidrepoerr.txt notauthorizederr.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create a user
 soft user create foo --key "$USER1_AUTHORIZED_KEY"
 
@@ -87,6 +92,10 @@ stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
 usoft git-lfs-authenticate repo2p upload
 stdout '.*header.*Bearer.*href.*expires_in.*expires_at.*'
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 -- argserr1.txt --
 Error: accepts 1 arg(s), received 0
 -- argserr2.txt --

testscript/testdata/token.txtar 🔗

@@ -1,5 +1,10 @@
 # vi: set ft=conf
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # create user
 soft user create user1 --key "$USER1_AUTHORIZED_KEY"
 
@@ -26,3 +31,6 @@ usoft token delete 1
 stderr 'Access token deleted'
 ! usoft token delete 1
 stderr 'token not found'
+
+# stop the server
+[windows] stopserver

testscript/testdata/user_management.txtar 🔗

@@ -3,6 +3,11 @@
 # convert crlf to lf on windows
 [windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt
 
+# start soft serve
+exec soft serve &
+# wait for server to start
+waitforserver
+
 # add key to admin
 soft user add-pubkey admin "$ADMIN2_AUTHORIZED_KEY"
 soft user info admin
@@ -63,6 +68,10 @@ soft user delete foo2
 soft user list
 cmpenv stdout list1.txt
 
+# stop the server
+[windows] stopserver
+[windows] ! stderr .
+
 
 -- info.txt --
 Username: admin