40d76a13ac74589c8395067af64f58d5a3f96b43

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
GitHub <noreply@github.com>
Date

Message

feat: update integration tests (#434)

* 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

Diff

This diff is truncated to protect this page.

   1diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
   2index 7ee3b67d6a8835efefc2fb9f1f70ee0af2dad5b7..8ada1613150b81ce2f1bc5490ca48edc349c6296 100644
   3--- a/.github/workflows/build.yml
   4+++ b/.github/workflows/build.yml
   5@@ -42,5 +42,5 @@ jobs:
   6       - name: Test
   7         run: go test ./...
   8         env:
   9-          DB_DRIVER: postgres
  10-          DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable
  11+          SOFT_SERVE_DB_DRIVER: postgres
  12+          SOFT_SERVE_DB_DATA_SOURCE: postgres://postgres:postgres@localhost/postgres?sslmode=disable
  13diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
  14index 50f4aa9481391a7febf7fa9dbac233c739b56809..4b1b12a83f5624f16d158e9f039f62a8d79c24fc 100644
  15--- a/.github/workflows/coverage.yml
  16+++ b/.github/workflows/coverage.yml
  17@@ -8,7 +8,10 @@ on:
  18 
  19 jobs:
  20   coverage:
  21-    runs-on: ubuntu-latest
  22+    strategy:
  23+      matrix:
  24+        os: [ubuntu-latest] # TODO: add macos & windows
  25+    runs-on: ${{ matrix.os }}
  26     steps:
  27       - uses: actions/checkout@v4
  28 
  29@@ -18,7 +21,24 @@ jobs:
  30           go-version: ^1
  31 
  32       - name: Test
  33-        run: go test -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt ./... -timeout 5m
  34+        run: |
  35+          # We collect coverage data from two sources,
  36+          # 1) unit tests 2) integration tests
  37+          #
  38+          # https://go.dev/testing/coverage/
  39+          # https://dustinspecker.com/posts/go-combined-unit-integration-code-coverage/
  40+          # https://github.com/golang/go/issues/51430#issuecomment-1344711300
  41+          mkdir -p coverage/unit
  42+          mkdir -p coverage/int
  43+
  44+          # Collect unit tests coverage
  45+          go test -failfast -race -timeout 5m -skip=^TestScript -cover ./... -args -test.gocoverdir=$PWD/coverage/unit
  46+
  47+          # Collect integration tests coverage
  48+          GOCOVERDIR=$PWD/coverage/int go test -failfast -race -timeout 5m -run=^TestScript ./...
  49+
  50+          # Convert coverage data to legacy textfmt format to upload
  51+          go tool covdata textfmt -i=coverage/unit,coverage/int -o=coverage.txt
  52       - uses: codecov/codecov-action@v3
  53         with:
  54           file: ./coverage.txt
  55diff --git a/cmd/cmd.go b/cmd/cmd.go
  56index 9aead6369d7faa2932dcea9d4dcf3ef8766336ee..556594e15a49e1de8910e475914d96de5192b6a5 100644
  57--- a/cmd/cmd.go
  58+++ b/cmd/cmd.go
  59@@ -33,7 +33,7 @@ func InitBackendContext(cmd *cobra.Command, _ []string) error {
  60 	ctx = db.WithContext(ctx, dbx)
  61 	dbstore := database.New(ctx, dbx)
  62 	ctx = store.WithContext(ctx, dbstore)
  63-	be := backend.New(ctx, cfg, dbx)
  64+	be := backend.New(ctx, cfg, dbx, dbstore)
  65 	ctx = backend.WithContext(ctx, be)
  66 
  67 	cmd.SetContext(ctx)
  68diff --git a/cmd/soft/serve/serve.go b/cmd/soft/serve/serve.go
  69index 7f81dcf0f741085ff41f03229a786b76091d0eb5..d23ce65de3b5c4c7f1883b6f384edbf646b30824 100644
  70--- a/cmd/soft/serve/serve.go
  71+++ b/cmd/soft/serve/serve.go
  72@@ -3,9 +3,12 @@ package serve
  73 import (
  74 	"context"
  75 	"fmt"
  76+	"net/http"
  77 	"os"
  78 	"os/signal"
  79 	"path/filepath"
  80+	"strconv"
  81+	"sync"
  82 	"syscall"
  83 	"time"
  84 
  85@@ -80,10 +83,26 @@ var (
  86 			}
  87 
  88 			done := make(chan os.Signal, 1)
  89+			doneOnce := sync.OnceFunc(func() { close(done) })
  90+
  91 			lch := make(chan error, 1)
  92+
  93+			// This endpoint is added for testing purposes
  94+			// It allows us to stop the server from the test suite.
  95+			// This is needed since Windows doesn't support signals.
  96+			if testRun, _ := strconv.ParseBool(os.Getenv("SOFT_SERVE_TESTRUN")); testRun {
  97+				h := s.HTTPServer.Server.Handler
  98+				s.HTTPServer.Server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  99+					if r.URL.Path == "/__stop" && r.Method == http.MethodHead {
 100+						doneOnce()
 101+						return
 102+					}
 103+					h.ServeHTTP(w, r)
 104+				})
 105+			}
 106+
 107 			go func() {
 108-				defer close(lch)
 109-				defer close(done)
 110+				defer doneOnce()
 111 				lch <- s.Start()
 112 			}()
 113 
 114diff --git a/cmd/soft/serve/server.go b/cmd/soft/serve/server.go
 115index 223e55a69fb5c907feda0b1bb3f657a0216cb2de..33679091f1f177303b4651e842cdf20b01c0bc81 100644
 116--- a/cmd/soft/serve/server.go
 117+++ b/cmd/soft/serve/server.go
 118@@ -147,7 +147,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
 119 		for _, j := range jobs.List() {
 120 			s.Cron.Remove(j.ID)
 121 		}
 122-		s.Cron.Shutdown()
 123+		s.Cron.Stop()
 124 		return nil
 125 	})
 126 	// defer s.DB.Close() // nolint: errcheck
 127diff --git a/go.mod b/go.mod
 128index 7cd4a0cc406ac7eb761af917c5d4b63d5c15c28d..3d1c87b75243f6994a25f0da16415ca86a0ac6d4 100644
 129--- a/go.mod
 130+++ b/go.mod
 131@@ -41,8 +41,7 @@ require (
 132 	github.com/muesli/roff v0.1.0
 133 	github.com/prometheus/client_golang v1.17.0
 134 	github.com/robfig/cron/v3 v3.0.1
 135-	github.com/rogpeppe/go-internal v1.11.0
 136-	github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086
 137+	github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c
 138 	github.com/spf13/cobra v1.8.0
 139 	go.uber.org/automaxprocs v1.5.3
 140 	golang.org/x/crypto v0.16.0
 141diff --git a/go.sum b/go.sum
 142index 7898d622cd0488074b0763452abce3549d8b061e..b0d9580068e48188bdd52f19e47100c060d835ff 100644
 143--- a/go.sum
 144+++ b/go.sum
 145@@ -167,10 +167,8 @@ github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
 146 github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 147 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 148 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 149-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
 150-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
 151-github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086 h1:mncRSDOqYCng7jOD+Y6+IivdRI6Kzv2BLWYkWkdQfu0=
 152-github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086/go.mod h1:YpdgDXpumPB/+EGmGTYHeiW/0QVFRzBYTNFaxWfPDk4=
 153+github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c h1:fPpdjePK1atuOg28PXfNSqgwf9I/qD1Hlo39JFwKBXk=
 154+github.com/rogpeppe/go-internal v1.11.1-0.20231026093722-fa6a31e0812c/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 155 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 156 github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 157 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 158diff --git a/pkg/access/context_test.go b/pkg/access/context_test.go
 159new file mode 100644
 160index 0000000000000000000000000000000000000000..c4bcf4a60cec848b52f95076d1e804b9886c736b
 161--- /dev/null
 162+++ b/pkg/access/context_test.go
 163@@ -0,0 +1,20 @@
 164+package access
 165+
 166+import (
 167+	"context"
 168+	"testing"
 169+)
 170+
 171+func TestGoodFromContext(t *testing.T) {
 172+	ctx := WithContext(context.TODO(), AdminAccess)
 173+	if ac := FromContext(ctx); ac != AdminAccess {
 174+		t.Errorf("FromContext(ctx) => %d, want %d", ac, AdminAccess)
 175+	}
 176+}
 177+
 178+func TestBadFromContext(t *testing.T) {
 179+	ctx := context.TODO()
 180+	if ac := FromContext(ctx); ac != -1 {
 181+		t.Errorf("FromContext(ctx) => %d, want %d", ac, -1)
 182+	}
 183+}
 184diff --git a/pkg/backend/backend.go b/pkg/backend/backend.go
 185index 2d9eb2a05538e7a2ffa5ebc568f14d5e610f39f9..ba8796b54546620df6b8377704ee5d76b1c10283 100644
 186--- a/pkg/backend/backend.go
 187+++ b/pkg/backend/backend.go
 188@@ -23,14 +23,13 @@ type Backend struct {
 189 }
 190 
 191 // New returns a new Soft Serve backend.
 192-func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend {
 193-	dbstore := store.FromContext(ctx)
 194+func New(ctx context.Context, cfg *config.Config, db *db.DB, st store.Store) *Backend {
 195 	logger := log.FromContext(ctx).WithPrefix("backend")
 196 	b := &Backend{
 197 		ctx:     ctx,
 198 		cfg:     cfg,
 199 		db:      db,
 200-		store:   dbstore,
 201+		store:   st,
 202 		logger:  logger,
 203 		manager: task.NewManager(ctx),
 204 	}
 205diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go
 206new file mode 100644
 207index 0000000000000000000000000000000000000000..db7f8b257539b7c374cca9579c3b3d79d3182706
 208--- /dev/null
 209+++ b/pkg/config/context_test.go
 210@@ -0,0 +1,29 @@
 211+package config
 212+
 213+import (
 214+	"context"
 215+	"reflect"
 216+	"testing"
 217+)
 218+
 219+func TestBadFromContext(t *testing.T) {
 220+	ctx := context.TODO()
 221+	if c := FromContext(ctx); c != nil {
 222+		t.Errorf("FromContext(ctx) => %v, want %v", c, nil)
 223+	}
 224+}
 225+
 226+func TestGoodFromContext(t *testing.T) {
 227+	ctx := WithContext(context.TODO(), &Config{})
 228+	if c := FromContext(ctx); c == nil {
 229+		t.Errorf("FromContext(ctx) => %v, want %v", c, &Config{})
 230+	}
 231+}
 232+
 233+func TestGoodFromContextWithDefaultConfig(t *testing.T) {
 234+	cfg := DefaultConfig()
 235+	ctx := WithContext(context.TODO(), cfg)
 236+	if c := FromContext(ctx); c == nil || !reflect.DeepEqual(c, cfg) {
 237+		t.Errorf("FromContext(ctx) => %v, want %v", c, cfg)
 238+	}
 239+}
 240diff --git a/pkg/config/file_test.go b/pkg/config/file_test.go
 241new file mode 100644
 242index 0000000000000000000000000000000000000000..81efad71ceba05003f8cf85f0b6d8d7415284f13
 243--- /dev/null
 244+++ b/pkg/config/file_test.go
 245@@ -0,0 +1,15 @@
 246+package config
 247+
 248+import "testing"
 249+
 250+func TestNewConfigFile(t *testing.T) {
 251+	for _, cfg := range []*Config{
 252+		nil,
 253+		DefaultConfig(),
 254+		&Config{},
 255+	} {
 256+		if s := newConfigFile(cfg); s == "" {
 257+			t.Errorf("newConfigFile(nil) => %q, want non-empty string", s)
 258+		}
 259+	}
 260+}
 261diff --git a/pkg/config/ssh.go b/pkg/config/ssh.go
 262index 102b391418ac86f1d0f963fc7701591bdd233ce0..151ac7877abdfa39070244198f1de37131a5f8fa 100644
 263--- a/pkg/config/ssh.go
 264+++ b/pkg/config/ssh.go
 265@@ -1,8 +1,28 @@
 266 package config
 267 
 268-import "github.com/charmbracelet/keygen"
 269+import (
 270+	"errors"
 271+
 272+	"github.com/charmbracelet/keygen"
 273+)
 274+
 275+var (
 276+	// ErrNilConfig is returned when a nil config is passed to a function.
 277+	ErrNilConfig = errors.New("nil config")
 278+
 279+	// ErrEmptySSHKeyPath is returned when the SSH key path is empty.
 280+	ErrEmptySSHKeyPath = errors.New("empty SSH key path")
 281+)
 282 
 283 // KeyPair returns the server's SSH key pair.
 284-func (c SSHConfig) KeyPair() (*keygen.SSHKeyPair, error) {
 285-	return keygen.New(c.KeyPath, keygen.WithKeyType(keygen.Ed25519))
 286+func KeyPair(cfg *Config) (*keygen.SSHKeyPair, error) {
 287+	if cfg == nil {
 288+		return nil, ErrNilConfig
 289+	}
 290+
 291+	if cfg.SSH.KeyPath == "" {
 292+		return nil, ErrEmptySSHKeyPath
 293+	}
 294+
 295+	return keygen.New(cfg.SSH.KeyPath, keygen.WithKeyType(keygen.Ed25519))
 296 }
 297diff --git a/pkg/config/ssh_test.go b/pkg/config/ssh_test.go
 298new file mode 100644
 299index 0000000000000000000000000000000000000000..4f68ec149cd2dc69c8656d902be61a773fc8d723
 300--- /dev/null
 301+++ b/pkg/config/ssh_test.go
 302@@ -0,0 +1,26 @@
 303+package config
 304+
 305+import "testing"
 306+
 307+func TestBadSSHKeyPair(t *testing.T) {
 308+	for _, cfg := range []*Config{
 309+		nil,
 310+		{},
 311+	} {
 312+		if _, err := KeyPair(cfg); err == nil {
 313+			t.Errorf("cfg.SSH.KeyPair() => _, nil, want non-nil error")
 314+		}
 315+	}
 316+}
 317+
 318+func TestGoodSSHKeyPair(t *testing.T) {
 319+	cfg := &Config{
 320+		SSH: SSHConfig{
 321+			KeyPath: "testdata/ssh_host_ed25519_key",
 322+		},
 323+	}
 324+
 325+	if _, err := KeyPair(cfg); err != nil {
 326+		t.Errorf("cfg.SSH.KeyPair() => _, %v, want nil error", err)
 327+	}
 328+}
 329diff --git a/pkg/cron/cron_test.go b/pkg/cron/cron_test.go
 330new file mode 100644
 331index 0000000000000000000000000000000000000000..c254191b2e45e889b5d2ce3f10fc1c7f16949c83
 332--- /dev/null
 333+++ b/pkg/cron/cron_test.go
 334@@ -0,0 +1,31 @@
 335+package cron
 336+
 337+import (
 338+	"bytes"
 339+	"context"
 340+	"fmt"
 341+	"testing"
 342+
 343+	"github.com/charmbracelet/log"
 344+)
 345+
 346+func TestCronLogger(t *testing.T) {
 347+	var buf bytes.Buffer
 348+	logger := log.New(&buf)
 349+	logger.SetLevel(log.DebugLevel)
 350+	clogger := cronLogger{logger}
 351+	clogger.Info("foo")
 352+	clogger.Error(fmt.Errorf("bar"), "test")
 353+	if buf.String() != "DEBU foo\nERRO test err=bar\n" {
 354+		t.Errorf("unexpected log output: %s", buf.String())
 355+	}
 356+}
 357+
 358+func TestSchedularAddRemove(t *testing.T) {
 359+	s := NewScheduler(context.TODO())
 360+	id, err := s.AddFunc("* * * * *", func() {})
 361+	if err != nil {
 362+		t.Fatal(err)
 363+	}
 364+	s.Remove(id)
 365+}
 366diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go
 367index eee10ec525754cd7007fd9c77b71ac833e7b8bc4..5d91565cdd58861267ce911267a6d00dc3922c2a 100644
 368--- a/pkg/daemon/daemon.go
 369+++ b/pkg/daemon/daemon.go
 370@@ -150,30 +150,35 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 371 		d.conns.Close(c) // nolint: errcheck
 372 	}()
 373 
 374-	readc := make(chan struct{}, 1)
 375+	errc := make(chan error, 1)
 376+
 377 	s := pktline.NewScanner(c)
 378 	go func() {
 379 		if !s.Scan() {
 380 			if err := s.Err(); err != nil {
 381-				if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
 382-					d.fatal(c, git.ErrTimeout)
 383-				} else {
 384-					d.logger.Debugf("git: error scanning pktline: %v", err)
 385-					d.fatal(c, git.ErrSystemMalfunction)
 386-				}
 387+				errc <- err
 388 			}
 389-			return
 390 		}
 391-		readc <- struct{}{}
 392+		errc <- nil
 393 	}()
 394 
 395 	select {
 396 	case <-ctx.Done():
 397 		if err := ctx.Err(); err != nil {
 398 			d.logger.Debugf("git: connection context error: %v", err)
 399+			d.fatal(c, git.ErrTimeout)
 400 		}
 401 		return
 402-	case <-readc:
 403+	case err := <-errc:
 404+		if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
 405+			d.fatal(c, git.ErrTimeout)
 406+			return
 407+		} else if err != nil {
 408+			d.logger.Debugf("git: error scanning pktline: %v", err)
 409+			d.fatal(c, git.ErrSystemMalfunction)
 410+			return
 411+		}
 412+
 413 		line := s.Bytes()
 414 		split := bytes.SplitN(line, []byte{' '}, 2)
 415 		if len(split) != 2 {
 416diff --git a/pkg/daemon/daemon_test.go b/pkg/daemon/daemon_test.go
 417index 7ebe31b5358b7e5de7813cec41ca4cbdf790734b..88b4fdec7a9e2de2d4c9621b549c57c269f11ddb 100644
 418--- a/pkg/daemon/daemon_test.go
 419+++ b/pkg/daemon/daemon_test.go
 420@@ -8,6 +8,7 @@ import (
 421 	"os"
 422 	"strings"
 423 	"testing"
 424+	"time"
 425 
 426 	"github.com/charmbracelet/soft-serve/pkg/backend"
 427 	"github.com/charmbracelet/soft-serve/pkg/config"
 428@@ -50,7 +51,7 @@ func TestMain(m *testing.M) {
 429 	}
 430 	datastore := database.New(ctx, dbx)
 431 	ctx = store.WithContext(ctx, datastore)
 432-	be := backend.New(ctx, cfg, dbx)
 433+	be := backend.New(ctx, cfg, dbx, datastore)
 434 	ctx = backend.WithContext(ctx, be)
 435 	d, err := NewGitDaemon(ctx)
 436 	if err != nil {
 437@@ -78,9 +79,10 @@ func TestIdleTimeout(t *testing.T) {
 438 	if err != nil {
 439 		t.Fatal(err)
 440 	}
 441+	time.Sleep(time.Second)
 442 	_, err = readPktline(c)
 443-	if err != nil && err.Error() != git.ErrTimeout.Error() {
 444-		t.Fatalf("expected %q error, got %q", git.ErrTimeout, err)
 445+	if err == nil {
 446+		t.Errorf("expected error, got nil")
 447 	}
 448 }
 449 
 450@@ -94,7 +96,7 @@ func TestInvalidRepo(t *testing.T) {
 451 	}
 452 	_, err = readPktline(c)
 453 	if err != nil && err.Error() != git.ErrInvalidRepo.Error() {
 454-		t.Fatalf("expected %q error, got %q", git.ErrInvalidRepo, err)
 455+		t.Errorf("expected %q error, got %q", git.ErrInvalidRepo, err)
 456 	}
 457 }
 458 
 459diff --git a/pkg/db/context_test.go b/pkg/db/context_test.go
 460new file mode 100644
 461index 0000000000000000000000000000000000000000..5da52397583b46773da8c736e9dcdd9922f60b99
 462--- /dev/null
 463+++ b/pkg/db/context_test.go
 464@@ -0,0 +1,28 @@
 465+package db_test
 466+
 467+import (
 468+	"context"
 469+	"testing"
 470+
 471+	"github.com/charmbracelet/soft-serve/pkg/db"
 472+	"github.com/charmbracelet/soft-serve/pkg/db/internal/test"
 473+)
 474+
 475+func TestBadFromContext(t *testing.T) {
 476+	ctx := context.TODO()
 477+	if c := db.FromContext(ctx); c != nil {
 478+		t.Errorf("FromContext(ctx) => %v, want %v", c, nil)
 479+	}
 480+}
 481+
 482+func TestGoodFromContext(t *testing.T) {
 483+	ctx := context.TODO()
 484+	dbx, err := test.OpenSqlite(ctx, t)
 485+	if err != nil {
 486+		t.Fatal(err)
 487+	}
 488+	ctx = db.WithContext(ctx, dbx)
 489+	if c := db.FromContext(ctx); c == nil {
 490+		t.Errorf("FromContext(ctx) => %v, want %v", c, dbx)
 491+	}
 492+}
 493diff --git a/pkg/db/db_test.go b/pkg/db/db_test.go
 494new file mode 100644
 495index 0000000000000000000000000000000000000000..3ca95ad3767ee459251ac7d229da68018105d07d
 496--- /dev/null
 497+++ b/pkg/db/db_test.go
 498@@ -0,0 +1,17 @@
 499+package db
 500+
 501+import (
 502+	"context"
 503+	"strings"
 504+	"testing"
 505+)
 506+
 507+func TestOpenUnknownDriver(t *testing.T) {
 508+	_, err := Open(context.TODO(), "invalid", "")
 509+	if err == nil {
 510+		t.Error("Open(invalid) => nil, want error")
 511+	}
 512+	if !strings.Contains(err.Error(), "unknown driver") {
 513+		t.Errorf("Open(invalid) => %v, want error containing 'unknown driver'", err)
 514+	}
 515+}
 516diff --git a/pkg/db/errors_test.go b/pkg/db/errors_test.go
 517new file mode 100644
 518index 0000000000000000000000000000000000000000..0aba634572abca53a9337a08f022ed333a797d12
 519--- /dev/null
 520+++ b/pkg/db/errors_test.go
 521@@ -0,0 +1,25 @@
 522+package db
 523+
 524+import (
 525+	"database/sql"
 526+	"errors"
 527+	"fmt"
 528+	"testing"
 529+)
 530+
 531+func TestWrapErrorBadNoRows(t *testing.T) {
 532+	for _, e := range []error{
 533+		fmt.Errorf("foo"),
 534+		errors.New("bar"),
 535+	} {
 536+		if err := WrapError(e); err != e {
 537+			t.Errorf("WrapError(%v) => %v, want %v", e, err, e)
 538+		}
 539+	}
 540+}
 541+
 542+func TestWrapErrorGoodNoRows(t *testing.T) {
 543+	if err := WrapError(sql.ErrNoRows); err != ErrRecordNotFound {
 544+		t.Errorf("WrapError(sql.ErrNoRows) => %v, want %v", err, ErrRecordNotFound)
 545+	}
 546+}
 547diff --git a/pkg/db/internal/test/test.go b/pkg/db/internal/test/test.go
 548new file mode 100644
 549index 0000000000000000000000000000000000000000..b40e2ad90ea73be4c6887e41564e1ff5958b0277
 550--- /dev/null
 551+++ b/pkg/db/internal/test/test.go
 552@@ -0,0 +1,29 @@
 553+package test
 554+
 555+import (
 556+	"context"
 557+	"path/filepath"
 558+	"testing"
 559+
 560+	"github.com/charmbracelet/soft-serve/pkg/db"
 561+)
 562+
 563+// OpenSqlite opens a new temp SQLite database for testing.
 564+// It removes the database file when the test is done using tb.Cleanup.
 565+// If ctx is nil, context.TODO() is used.
 566+func OpenSqlite(ctx context.Context, tb testing.TB) (*db.DB, error) {
 567+	if ctx == nil {
 568+		ctx = context.TODO()
 569+	}
 570+	dbpath := filepath.Join(tb.TempDir(), "test.db")
 571+	dbx, err := db.Open(ctx, "sqlite", dbpath)
 572+	if err != nil {
 573+		return nil, err
 574+	}
 575+	tb.Cleanup(func() {
 576+		if err := dbx.Close(); err != nil {
 577+			tb.Error(err)
 578+		}
 579+	})
 580+	return dbx, nil
 581+}
 582diff --git a/pkg/db/migrate/migrate_test.go b/pkg/db/migrate/migrate_test.go
 583new file mode 100644
 584index 0000000000000000000000000000000000000000..bfc9d20f223bbfbb3a6e3fb6bf17894268659380
 585--- /dev/null
 586+++ b/pkg/db/migrate/migrate_test.go
 587@@ -0,0 +1,22 @@
 588+package migrate
 589+
 590+import (
 591+	"context"
 592+	"testing"
 593+
 594+	"github.com/charmbracelet/soft-serve/pkg/config"
 595+	"github.com/charmbracelet/soft-serve/pkg/db/internal/test"
 596+)
 597+
 598+func TestMigrate(t *testing.T) {
 599+	// XXX: we need a config.Config in the context for the migrations to run
 600+	// properly. Some migrations depend on the config being present.
 601+	ctx := config.WithContext(context.TODO(), config.DefaultConfig())
 602+	dbx, err := test.OpenSqlite(ctx, t)
 603+	if err != nil {
 604+		t.Fatal(err)
 605+	}
 606+	if err := Migrate(ctx, dbx); err != nil {
 607+		t.Errorf("Migrate() => %v, want nil error", err)
 608+	}
 609+}
 610diff --git a/pkg/git/git.go b/pkg/git/git.go
 611index e523b90a279dde5590614f20c419b24a135aa42d..d6c014296f1e1b2dfc539c26d316256a3e1996be 100644
 612--- a/pkg/git/git.go
 613+++ b/pkg/git/git.go
 614@@ -2,6 +2,7 @@ package git
 615 
 616 import (
 617 	"context"
 618+	"errors"
 619 	"fmt"
 620 	"io"
 621 	"path/filepath"
 622@@ -13,6 +14,11 @@ import (
 623 	"github.com/go-git/go-git/v5/plumbing/format/pktline"
 624 )
 625 
 626+var (
 627+	// ErrNoBranches is returned when a repo has no branches.
 628+	ErrNoBranches = errors.New("no branches found")
 629+)
 630+
 631 // WritePktline encodes and writes a pktline to the given writer.
 632 func WritePktline(w io.Writer, v ...interface{}) error {
 633 	msg := fmt.Sprintln(v...)
 634@@ -57,18 +63,18 @@ func EnsureWithin(reposDir string, repo string) error {
 635 
 636 // EnsureDefaultBranch ensures the repo has a default branch.
 637 // It will prefer choosing "main" or "master" if available.
 638-func EnsureDefaultBranch(ctx context.Context, scmd ServiceCommand) error {
 639-	r, err := git.Open(scmd.Dir)
 640+func EnsureDefaultBranch(ctx context.Context, repoPath string) error {
 641+	r, err := git.Open(repoPath)
 642 	if err != nil {
 643 		return err
 644 	}
 645 	brs, err := r.Branches()
 646+	if len(brs) == 0 {
 647+		return ErrNoBranches
 648+	}
 649 	if err != nil {
 650 		return err
 651 	}
 652-	if len(brs) == 0 {
 653-		return fmt.Errorf("no branches found")
 654-	}
 655 	// Rename the default branch to the first branch available
 656 	_, err = r.HEAD()
 657 	if err == git.ErrReferenceNotExist {
 658diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go
 659index d95cb6497daed360740ccfb50a5a79c2d2944712..4e4a476d297661781249bdb8b9303a63c22bc580 100644
 660--- a/pkg/git/git_test.go
 661+++ b/pkg/git/git_test.go
 662@@ -2,8 +2,12 @@ package git
 663 
 664 import (
 665 	"bytes"
 666+	"context"
 667+	"errors"
 668 	"fmt"
 669 	"testing"
 670+
 671+	"github.com/charmbracelet/soft-serve/git"
 672 )
 673 
 674 func TestPktline(t *testing.T) {
 675@@ -54,3 +58,40 @@ func TestPktline(t *testing.T) {
 676 		})
 677 	}
 678 }
 679+
 680+func TestEnsureWithinBad(t *testing.T) {
 681+	tmp := t.TempDir()
 682+	for _, f := range []string{
 683+		"..",
 684+		"../../../",
 685+	} {
 686+		if err := EnsureWithin(tmp, f); err == nil {
 687+			t.Errorf("EnsureWithin(%q, %q) => nil, want non-nil error", tmp, f)
 688+		}
 689+	}
 690+}
 691+
 692+func TestEnsureWithinGood(t *testing.T) {
 693+	tmp := t.TempDir()
 694+	for _, f := range []string{
 695+		tmp,
 696+		tmp + "/foo",
 697+		tmp + "/foo/bar",
 698+	} {
 699+		if err := EnsureWithin(tmp, f); err != nil {
 700+			t.Errorf("EnsureWithin(%q, %q) => %v, want nil error", tmp, f, err)
 701+		}
 702+	}
 703+}
 704+
 705+func TestEnsureDefaultBranchEmpty(t *testing.T) {
 706+	tmp := t.TempDir()
 707+	r, err := git.Init(tmp, false)
 708+	if err != nil {
 709+		t.Fatal(err)
 710+	}
 711+
 712+	if err := EnsureDefaultBranch(context.TODO(), r.Path); !errors.Is(err, ErrNoBranches) {
 713+		t.Errorf("EnsureDefaultBranch(%q) => %v, want ErrNoBranches", tmp, err)
 714+	}
 715+}
 716diff --git a/pkg/git/lfs.go b/pkg/git/lfs.go
 717index 5aae027e52acf27d2503193cd0ce773e255c23b0..7dc4b8b353ea480501eab855fee922d2a94f74ee 100644
 718--- a/pkg/git/lfs.go
 719+++ b/pkg/git/lfs.go
 720@@ -21,17 +21,8 @@ import (
 721 	"github.com/charmbracelet/soft-serve/pkg/proto"
 722 	"github.com/charmbracelet/soft-serve/pkg/storage"
 723 	"github.com/charmbracelet/soft-serve/pkg/store"
 724-	"github.com/rubyist/tracerx"
 725 )
 726 
 727-func init() {
 728-	// git-lfs-transfer uses tracerx for logging.
 729-	// use a custom key to avoid conflicts
 730-	// SOFT_SERVE_TRACE=1 to enable tracing git-lfs-transfer in soft-serve
 731-	tracerx.DefaultKey = "SOFT_SERVE"
 732-	tracerx.Prefix = "trace soft-serve-lfs-transfer: "
 733-}
 734-
 735 // lfsTransfer implements transfer.Backend.
 736 type lfsTransfer struct {
 737 	ctx     context.Context
 738diff --git a/pkg/hooks/gen.go b/pkg/hooks/gen.go
 739index 5eb16ebc2fe47baae43332d8575bb6f642feca7d..467b2f263a32263a016c08f39ea9357d19b2f98c 100644
 740--- a/pkg/hooks/gen.go
 741+++ b/pkg/hooks/gen.go
 742@@ -3,7 +3,6 @@ package hooks
 743 import (
 744 	"bytes"
 745 	"context"
 746-	"flag"
 747 	"os"
 748 	"path/filepath"
 749 	"text/template"
 750@@ -30,11 +29,6 @@ const (
 751 // This function should be called by the backend when a repository is created.
 752 // TODO: support context.
 753 func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error {
 754-	// TODO: support git hook tests.
 755-	if flag.Lookup("test.v") != nil {
 756-		log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test")
 757-		return nil
 758-	}
 759 	repo = utils.SanitizeRepo(repo) + ".git"
 760 	hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
 761 	if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
 762diff --git a/pkg/hooks/gen_test.go b/pkg/hooks/gen_test.go
 763new file mode 100644
 764index 0000000000000000000000000000000000000000..d9f03ee39849364157d177d30a13ed85a94b25f1
 765--- /dev/null
 766+++ b/pkg/hooks/gen_test.go
 767@@ -0,0 +1,40 @@
 768+package hooks
 769+
 770+import (
 771+	"context"
 772+	"os"
 773+	"path/filepath"
 774+	"testing"
 775+
 776+	"github.com/charmbracelet/soft-serve/git"
 777+	"github.com/charmbracelet/soft-serve/pkg/config"
 778+)
 779+
 780+func TestGenerateHooks(t *testing.T) {
 781+	tmp := t.TempDir()
 782+	cfg := config.DefaultConfig()
 783+	cfg.DataPath = tmp
 784+	repoPath := filepath.Join(tmp, "repos", "test.git")
 785+	_, err := git.Init(repoPath, true)
 786+	if err != nil {
 787+		t.Fatal(err)
 788+	}
 789+
 790+	if err := GenerateHooks(context.TODO(), cfg, "test.git"); err != nil {
 791+		t.Fatal(err)
 792+	}
 793+
 794+	for _, hn := range []string{
 795+		PreReceiveHook,
 796+		UpdateHook,
 797+		PostReceiveHook,
 798+		PostUpdateHook,
 799+	} {
 800+		if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn)); err != nil {
 801+			t.Fatal(err)
 802+		}
 803+		if _, err := os.Stat(filepath.Join(repoPath, "hooks", hn+".d", "soft-serve")); err != nil {
 804+			t.Fatal(err)
 805+		}
 806+	}
 807+}
 808diff --git a/pkg/jwk/jwk.go b/pkg/jwk/jwk.go
 809index b758f88aa815d6a58e86db64e8f87f02a2564651..f7fe924041a39f1ae76bc6afec95ced6ca81cbec 100644
 810--- a/pkg/jwk/jwk.go
 811+++ b/pkg/jwk/jwk.go
 812@@ -32,7 +32,7 @@ func (p Pair) JWK() jose.JSONWebKey {
 813 
 814 // NewPair creates a new JSON Web Key pair.
 815 func NewPair(cfg *config.Config) (Pair, error) {
 816-	kp, err := cfg.SSH.KeyPair()
 817+	kp, err := config.KeyPair(cfg)
 818 	if err != nil {
 819 		return Pair{}, err
 820 	}
 821diff --git a/pkg/jwk/jwk_test.go b/pkg/jwk/jwk_test.go
 822new file mode 100644
 823index 0000000000000000000000000000000000000000..a8d95fbdb9f79eccbfe99d598a96c726c619c77f
 824--- /dev/null
 825+++ b/pkg/jwk/jwk_test.go
 826@@ -0,0 +1,22 @@
 827+package jwk
 828+
 829+import (
 830+	"errors"
 831+	"testing"
 832+
 833+	"github.com/charmbracelet/soft-serve/pkg/config"
 834+)
 835+
 836+func TestBadNewPair(t *testing.T) {
 837+	_, err := NewPair(nil)
 838+	if !errors.Is(err, config.ErrNilConfig) {
 839+		t.Errorf("NewPair(nil) => %v, want %v", err, config.ErrNilConfig)
 840+	}
 841+}
 842+
 843+func TestGoodNewPair(t *testing.T) {
 844+	cfg := config.DefaultConfig()
 845+	if _, err := NewPair(cfg); err != nil {
 846+		t.Errorf("NewPair(cfg) => _, %v, want nil error", err)
 847+	}
 848+}
 849diff --git a/pkg/log/log.go b/pkg/log/log.go
 850index aa9a6b4181b3b0fcd9458ddd66a460f55b7d6d34..b9134ead76d6d5a96a413cfea5d2202647054374 100644
 851--- a/pkg/log/log.go
 852+++ b/pkg/log/log.go
 853@@ -11,6 +11,9 @@ import (
 854 
 855 // NewLogger returns a new logger with default settings.
 856 func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
 857+	if cfg == nil {
 858+		return nil, nil, config.ErrNilConfig
 859+	}
 860 	logger := log.NewWithOptions(os.Stderr, log.Options{
 861 		ReportTimestamp: true,
 862 		TimeFormat:      time.DateOnly,
 863@@ -37,7 +40,8 @@ func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
 864 
 865 	var f *os.File
 866 	if cfg.Log.Path != "" {
 867-		f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 868+		var err error
 869+		f, err = os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 870 		if err != nil {
 871 			return nil, nil, err
 872 		}
 873diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go
 874new file mode 100644
 875index 0000000000000000000000000000000000000000..131e05809e85eb5b98945c928413ff777e93439a
 876--- /dev/null
 877+++ b/pkg/log/log_test.go
 878@@ -0,0 +1,43 @@
 879+package log
 880+
 881+import (
 882+	"path/filepath"
 883+	"testing"
 884+
 885+	"github.com/charmbracelet/soft-serve/pkg/config"
 886+)
 887+
 888+func TestGoodNewLogger(t *testing.T) {
 889+	for _, c := range []*config.Config{
 890+		config.DefaultConfig(),
 891+		{},
 892+		{Log: config.LogConfig{Path: filepath.Join(t.TempDir(), "logfile.txt")}},
 893+	} {
 894+		_, f, err := NewLogger(c)
 895+		if err != nil {
 896+			t.Errorf("expected nil got %v", err)
 897+		}
 898+		if f != nil {
 899+			if err := f.Close(); err != nil {
 900+				t.Errorf("failed to close logger: %v", err)
 901+			}
 902+		}
 903+	}
 904+}
 905+
 906+func TestBadNewLogger(t *testing.T) {
 907+	for _, c := range []*config.Config{
 908+		nil,
 909+		{Log: config.LogConfig{Path: "\x00"}},
 910+	} {
 911+		_, f, err := NewLogger(c)
 912+		if err == nil {
 913+			t.Errorf("expected error got nil")
 914+		}
 915+		if f != nil {
 916+			if err := f.Close(); err != nil {
 917+				t.Errorf("failed to close logger: %v", err)
 918+			}
 919+		}
 920+	}
 921+}
 922diff --git a/pkg/ssh/cmd/git.go b/pkg/ssh/cmd/git.go
 923index 355d235d42a5d5fa04f77338015363fbd09bb4c9..f6aea60ab150f8995e6cb1b28fa5c39766bf028f 100644
 924--- a/pkg/ssh/cmd/git.go
 925+++ b/pkg/ssh/cmd/git.go
 926@@ -249,7 +249,7 @@ func gitRunE(cmd *cobra.Command, args []string) error {
 927 			return git.ErrSystemMalfunction
 928 		}
 929 
 930-		if err := git.EnsureDefaultBranch(ctx, scmd); err != nil {
 931+		if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {
 932 			logger.Error("failed to ensure default branch", "err", err, "repo", name)
 933 			return git.ErrSystemMalfunction
 934 		}
 935diff --git a/pkg/ssh/session_test.go b/pkg/ssh/session_test.go
 936index 845e178654b591257c3c0184a264a0d4673d3f78..792759c167361c4bd9c10783dda279983e6bc890 100644
 937--- a/pkg/ssh/session_test.go
 938+++ b/pkg/ssh/session_test.go
 939@@ -76,7 +76,7 @@ func setup(tb testing.TB) (*gossh.Session, func() error) {
 940 	}
 941 	dbstore := database.New(ctx, dbx)
 942 	ctx = store.WithContext(ctx, dbstore)
 943-	be := backend.New(ctx, cfg, dbx)
 944+	be := backend.New(ctx, cfg, dbx, dbstore)
 945 	ctx = backend.WithContext(ctx, be)
 946 	return testsession.New(tb, &ssh.Server{
 947 		Handler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) {
 948diff --git a/pkg/web/auth.go b/pkg/web/auth.go
 949index 631660bd3c64611c0ebc234ba9861184f1c54714..96b2bab85fcc94200525088d0bf37bac31942bf8 100644
 950--- a/pkg/web/auth.go
 951+++ b/pkg/web/auth.go
 952@@ -136,7 +136,7 @@ var ErrInvalidToken = errors.New("invalid token")
 953 func parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) {
 954 	cfg := config.FromContext(ctx)
 955 	logger := log.FromContext(ctx).WithPrefix("http.auth")
 956-	kp, err := cfg.SSH.KeyPair()
 957+	kp, err := config.KeyPair(cfg)
 958 	if err != nil {
 959 		return nil, err
 960 	}
 961diff --git a/pkg/web/git.go b/pkg/web/git.go
 962index c03e56afd591285aeb34146f9d0211dd01b9db73..8f1170bce71edda69e81f171415776b4800b1474 100644
 963--- a/pkg/web/git.go
 964+++ b/pkg/web/git.go
 965@@ -441,7 +441,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 966 	}
 967 
 968 	if service == git.ReceivePackService {
 969-		if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
 970+		if err := git.EnsureDefaultBranch(ctx, cmd.Dir); err != nil {
 971 			logger.Errorf("failed to ensure default branch: %s", err)
 972 		}
 973 	}
 974diff --git a/pkg/web/http.go b/pkg/web/http.go
 975index 9e109a0e2b880bf60300f5ac8ecb0d40cbff5225..20fae6bd176edff577947b56e14722f8f53d8819 100644
 976--- a/pkg/web/http.go
 977+++ b/pkg/web/http.go
 978@@ -11,9 +11,10 @@ import (
 979 
 980 // HTTPServer is an http server.
 981 type HTTPServer struct {
 982-	ctx    context.Context
 983-	cfg    *config.Config
 984-	server *http.Server
 985+	ctx context.Context
 986+	cfg *config.Config
 987+
 988+	Server *http.Server
 989 }
 990 
 991 // NewHTTPServer creates a new HTTP server.
 992@@ -23,7 +24,7 @@ func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
 993 	s := &HTTPServer{
 994 		ctx: ctx,
 995 		cfg: cfg,
 996-		server: &http.Server{
 997+		Server: &http.Server{
 998 			Addr:              cfg.HTTP.ListenAddr,
 999 			Handler:           NewRouter(ctx),
1000 			ReadHeaderTimeout: time.Second * 10,
1001@@ -38,18 +39,18 @@ func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
1002 
1003 // Close closes the HTTP server.
1004 func (s *HTTPServer) Close() error {
1005-	return s.server.Close()
1006+	return s.Server.Close()
1007 }
1008 
1009 // ListenAndServe starts the HTTP server.
1010 func (s *HTTPServer) ListenAndServe() error {
1011 	if s.cfg.HTTP.TLSKeyPath != "" && s.cfg.HTTP.TLSCertPath != "" {
1012-		return s.server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
1013+		return s.Server.ListenAndServeTLS(s.cfg.HTTP.TLSCertPath, s.cfg.HTTP.TLSKeyPath)
1014 	}
1015-	return s.server.ListenAndServe()
1016+	return s.Server.ListenAndServe()
1017 }
1018 
1019 // Shutdown gracefully shuts down the HTTP server.
1020 func (s *HTTPServer) Shutdown(ctx context.Context) error {
1021-	return s.server.Shutdown(ctx)
1022+	return s.Server.Shutdown(ctx)
1023 }
1024diff --git a/pkg/webhook/content_type_test.go b/pkg/webhook/content_type_test.go
1025new file mode 100644
1026index 0000000000000000000000000000000000000000..1aff46e810bf9a555d63f88c0f554a105f81dbaa
1027--- /dev/null
1028+++ b/pkg/webhook/content_type_test.go
1029@@ -0,0 +1,117 @@
1030+package webhook
1031+
1032+import "testing"
1033+
1034+func TestParseContentType(t *testing.T) {
1035+	tests := []struct {
1036+		name string
1037+		s    string
1038+		want ContentType
1039+		err  error
1040+	}{
1041+		{
1042+			name: "JSON",
1043+			s:    "application/json",
1044+			want: ContentTypeJSON,
1045+		},
1046+		{
1047+			name: "Form",
1048+			s:    "application/x-www-form-urlencoded",
1049+			want: ContentTypeForm,
1050+		},
1051+		{
1052+			name: "Invalid",
1053+			s:    "application/invalid",
1054+			err:  ErrInvalidContentType,
1055+			want: -1,
1056+		},
1057+	}
1058+
1059+	for _, tt := range tests {
1060+		t.Run(tt.name, func(t *testing.T) {
1061+			got, err := ParseContentType(tt.s)
1062+			if err != tt.err {
1063+				t.Errorf("ParseContentType() error = %v, wantErr %v", err, tt.err)
1064+				return
1065+			}
1066+			if got != tt.want {
1067+				t.Errorf("ParseContentType() got = %v, want %v", got, tt.want)
1068+			}
1069+		})
1070+	}
1071+}
1072+
1073+func TestUnmarshalText(t *testing.T) {
1074+	tests := []struct {
1075+		name    string
1076+		text    []byte
1077+		want    ContentType
1078+		wantErr bool
1079+	}{
1080+		{
1081+			name: "JSON",
1082+			text: []byte("application/json"),
1083+			want: ContentTypeJSON,
1084+		},
1085+		{
1086+			name: "Form",
1087+			text: []byte("application/x-www-form-urlencoded"),
1088+			want: ContentTypeForm,
1089+		},
1090+		{
1091+			name:    "Invalid",
1092+			text:    []byte("application/invalid"),
1093+			wantErr: true,
1094+		},
1095+	}
1096+
1097+	for _, tt := range tests {
1098+		t.Run(tt.name, func(t *testing.T) {
1099+			c := new(ContentType)
1100+			if err := c.UnmarshalText(tt.text); (err != nil) != tt.wantErr {
1101+				t.Errorf("ContentType.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr)
1102+			}
1103+			if *c != tt.want {
1104+				t.Errorf("ContentType.UnmarshalText() got = %v, want %v", *c, tt.want)
1105+			}
1106+		})
1107+	}
1108+}
1109+
1110+func TestMarshalText(t *testing.T) {
1111+	tests := []struct {
1112+		name    string
1113+		c       ContentType
1114+		want    []byte
1115+		wantErr bool
1116+	}{
1117+		{
1118+			name: "JSON",
1119+			c:    ContentTypeJSON,
1120+			want: []byte("application/json"),
1121+		},
1122+		{
1123+			name: "Form",
1124+			c:    ContentTypeForm,
1125+			want: []byte("application/x-www-form-urlencoded"),
1126+		},
1127+		{
1128+			name:    "Invalid",
1129diff --git a/pkg/webhook/push.go b/pkg/webhook/push.go
1130index 8af2c13982fcfa24f3e9e0168bd7ba857efa3bf6..6ef6062cdd9e4acd499ec09421e18fb273535802 100644
1131--- a/pkg/webhook/push.go
1132+++ b/pkg/webhook/push.go
1133@@ -2,6 +2,7 @@ package webhook
1134 
1135 import (
1136 	"context"
1137+	"errors"
1138 	"fmt"
1139 
1140 	gitm "github.com/aymanbagabas/git-module"
1141@@ -75,7 +76,11 @@ func NewPushEvent(ctx context.Context, user proto.User, repo proto.Repository, r
1142 	}
1143 
1144 	payload.Repository.DefaultBranch, err = proto.RepositoryDefaultBranch(repo)
1145-	if err != nil {
1146+	// XXX: we check for ErrReferenceNotExist here because we don't want to
1147+	// return an error if the repo is an empty repo.
1148+	// This means that the repo doesn't have a default branch yet and this is
1149+	// the first push to it.
1150+	if err != nil && !errors.Is(err, git.ErrReferenceNotExist) {
1151 		return PushEvent{}, err
1152 	}
1153 
1154diff --git a/testscript/script_test.go b/testscript/script_test.go
1155index 94c6408bc2810ac0aede32b5eee2fd2f55c2dc2b..f7dd22eec65b699667e357bf2bbd348e9cae17fd 100644
1156--- a/testscript/script_test.go
1157+++ b/testscript/script_test.go
1158@@ -3,41 +3,65 @@ package testscript
1159 import (
1160 	"bytes"
1161 	"context"
1162-	"database/sql"
1163+	"encoding/json"
1164 	"flag"
1165 	"fmt"
1166 	"io"
1167+	"math/rand"
1168 	"net"
1169 	"net/http"
1170 	"net/url"
1171 	"os"
1172+	"os/exec"
1173 	"path/filepath"
1174+	"runtime"
1175 	"strings"
1176-	"sync"
1177 	"testing"
1178 	"time"
1179 
1180 	"github.com/charmbracelet/keygen"
1181-	"github.com/charmbracelet/log"
1182-	"github.com/charmbracelet/soft-serve/cmd/soft/serve"
1183-	"github.com/charmbracelet/soft-serve/pkg/backend"
1184 	"github.com/charmbracelet/soft-serve/pkg/config"
1185 	"github.com/charmbracelet/soft-serve/pkg/db"
1186-	"github.com/charmbracelet/soft-serve/pkg/db/migrate"
1187-	logr "github.com/charmbracelet/soft-serve/pkg/log"
1188-	"github.com/charmbracelet/soft-serve/pkg/store"
1189-	"github.com/charmbracelet/soft-serve/pkg/store/database"
1190 	"github.com/charmbracelet/soft-serve/pkg/test"
1191 	"github.com/rogpeppe/go-internal/testscript"
1192 	"github.com/spf13/cobra"
1193 	"golang.org/x/crypto/ssh"
1194 )
1195 
1196-var update = flag.Bool("update", false, "update script files")
1197+var (
1198+	update  = flag.Bool("update", false, "update script files")
1199+	binPath string
1200+)
1201+
1202+func TestMain(m *testing.M) {
1203+	tmp, err := os.MkdirTemp("", "soft-serve*")
1204+	if err != nil {
1205+		fmt.Fprintf(os.Stderr, "failed to create temporary directory: %s", err)
1206+		os.Exit(1)
1207+	}
1208+	defer os.RemoveAll(tmp)
1209+
1210+	binPath = filepath.Join(tmp, "soft")
1211+	if runtime.GOOS == "windows" {
1212+		binPath += ".exe"
1213+	}
1214+
1215+	// Build the soft binary with -cover flag.
1216+	cmd := exec.Command("go", "build", "-race", "-cover", "-o", binPath, filepath.Join("..", "cmd", "soft"))
1217+	if err := cmd.Run(); err != nil {
1218+		fmt.Fprintf(os.Stderr, "failed to build soft-serve binary: %s", err)
1219+		os.Exit(1)
1220+	}
1221+
1222+	// Run tests
1223+	os.Exit(m.Run())
1224+
1225+	// Add binPath to PATH
1226+	os.Setenv("PATH", fmt.Sprintf("%s%c%s", os.Getenv("PATH"), os.PathListSeparator, filepath.Dir(binPath)))
1227+}
1228 
1229 func TestScript(t *testing.T) {
1230 	flag.Parse()
1231-	var lock sync.Mutex
1232 
1233 	mkkey := func(name string) (string, *keygen.SSHKeyPair) {
1234 		path := filepath.Join(t.TempDir(), name)
1235@@ -53,21 +77,27 @@ func TestScript(t *testing.T) {
1236 	_, user1 := mkkey("user1")
1237 
1238 	testscript.Run(t, testscript.Params{
1239-		Dir:           "./testdata/",
1240-		UpdateScripts: *update,
1241+		Dir:                 "./testdata/",
1242+		UpdateScripts:       *update,
1243+		RequireExplicitExec: true,
1244 		Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
1245-			"soft":     cmdSoft(admin1.Signer()),
1246-			"usoft":    cmdSoft(user1.Signer()),
1247-			"git":      cmdGit(key),
1248-			"curl":     cmdCurl,
1249-			"mkfile":   cmdMkfile,
1250-			"envfile":  cmdEnvfile,
1251-			"readfile": cmdReadfile,
1252-			"dos2unix": cmdDos2Unix,
1253+			"soft":          cmdSoft(admin1.Signer()),
1254+			"usoft":         cmdSoft(user1.Signer()),
1255+			"git":           cmdGit(key),
1256+			"curl":          cmdCurl,
1257+			"mkfile":        cmdMkfile,
1258diff --git a/testscript/testdata/help.txtar b/testscript/testdata/help.txtar
1259index 257ad244799114f510f9c470075575d8d0ba411d..d6756ca02fde1607ffa23add93e1b95adc8de89f 100644
1260--- a/testscript/testdata/help.txtar
1261+++ b/testscript/testdata/help.txtar
1262@@ -1,9 +1,18 @@
1263 # vi: set ft=conf
1264 [windows] dos2unix help.txt
1265 
1266+# start soft serve
1267+exec soft serve --sync-hooks &
1268+# wait for server to start
1269+waitforserver
1270+
1271 soft --help
1272 cmpenv stdout help.txt
1273 
1274+# stop the server
1275+[windows] stopserver
1276+[windows] ! stderr .
1277+
1278 -- help.txt --
1279 Soft Serve is a self-hostable Git server for the command line.
1280 
1281diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar
1282index f2af369af97c6658c0bc9cffc02983ef61ee4ce1..662e9b011695f478763f7b9767691b265510e2a1 100644
1283--- a/testscript/testdata/http.txtar
1284+++ b/testscript/testdata/http.txtar
1285@@ -6,6 +6,11 @@
1286 # convert crlf to lf on windows
1287 [windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
1288 
1289+# start soft serve
1290+exec soft serve &
1291+# wait for server to start
1292+waitforserver
1293+
1294 # create user
1295 soft user create user1 --key "$USER1_AUTHORIZED_KEY"
1296 
1297@@ -133,6 +138,10 @@ stdout '404.*'
1298 curl http://$TOKEN@localhost:$HTTP_PORT/repo2.git?go-get=1
1299 cmpenv stdout goget.txt
1300 
1301+# stop the server
1302+[windows] stopserver
1303+[windows] ! stderr .
1304+
1305 -- http1.txt --
1306 {"transfer":"basic","objects":[{"oid":"","size":0,"error":{"code":422,"message":"invalid object"}}],"hash_algo":"sha256"}
1307 -- http2.txt --
1308diff --git a/testscript/testdata/jwt.txtar b/testscript/testdata/jwt.txtar
1309index 07c605e948938d4f293d13c875f274d433887016..7a257044ecf0ead8e1676c51b23276b99f171a0f 100644
1310--- a/testscript/testdata/jwt.txtar
1311+++ b/testscript/testdata/jwt.txtar
1312@@ -1,5 +1,10 @@
1313 # vi: set ft=conf
1314 
1315+# start soft serve
1316+exec soft serve &
1317+# wait for server to start
1318+waitforserver
1319+
1320 # create user
1321 soft user create user1 --key "$USER1_AUTHORIZED_KEY"
1322 
1323@@ -12,3 +17,7 @@ usoft jwt
1324 stdout '.*\..*\..*'
1325 usoft jwt repo
1326 stdout '.*\..*\..*'
1327+
1328+# stop the server
1329+[windows] stopserver
1330+[windows] ! stderr .
1331diff --git a/testscript/testdata/mirror.txtar b/testscript/testdata/mirror.txtar
1332index e1db9214d4f96288b5561b0ed8c41d413b5d66ff..d3cc1915de47e519a678468fedcf749277e5dc82 100644
1333--- a/testscript/testdata/mirror.txtar
1334+++ b/testscript/testdata/mirror.txtar
1335@@ -3,6 +3,11 @@
1336 # convert crlf to lf on windows
1337 [windows] dos2unix info1.txt info2.txt tree.txt
1338 
1339+# start soft serve
1340+exec soft serve &
1341+# wait for server to start
1342+waitforserver
1343+
1344 # import a repo
1345 soft repo import --mirror charmbracelet/catwalk https://github.com/charmbracelet/catwalk.git
1346 
1347@@ -74,6 +79,10 @@ cmp stdout info2.txt
1348 soft repo blob charmbracelet/test LICENSE
1349 stdout '.*Creative Commons.*'
1350 
1351+# stop the server
1352+[windows] stopserver
1353+[windows] ! stderr .
1354+
1355 
1356 -- info1.txt --
1357 Project Name:
1358diff --git a/testscript/testdata/repo-blob.txtar b/testscript/testdata/repo-blob.txtar
1359index 770bd2d067f5de2096b8a0b37c36f073a515e577..040160b17a9b9a29c020cb07502a534b5f2d0db6 100644
1360--- a/testscript/testdata/repo-blob.txtar
1361+++ b/testscript/testdata/repo-blob.txtar
1362@@ -3,6 +3,11 @@
1363 # convert crlf to lf on windows
1364 [windows] dos2unix blob1.txt blob2.txt blob3.txt
1365 
1366+# start soft serve
1367+exec soft serve &
1368+# wait for server to start
1369+waitforserver
1370+
1371 # create a repo
1372 soft repo create repo1
1373 
1374@@ -43,6 +48,9 @@ stderr 'revision does not exist'
1375 ! stdout .
1376 stderr 'revision does not exist'
1377 
1378+# stop the server
1379+[windows] stopserver
1380+
1381 -- blob1.txt --
1382 # Hello\n\nwelcome
1383 -- blob2.txt --
1384diff --git a/testscript/testdata/repo-collab.txtar b/testscript/testdata/repo-collab.txtar
1385index 8f7d501b1922831d34cf3cbf72b44cb47507262c..d692831bbe59933d6069331182407e069308bc99 100644
1386--- a/testscript/testdata/repo-collab.txtar
1387+++ b/testscript/testdata/repo-collab.txtar
1388@@ -1,4 +1,10 @@
1389 # vi: set ft=conf
1390+
1391+# start soft serve
1392+exec soft serve &
1393+# wait for server to start
1394+waitforserver
1395+
1396 # setup
1397 soft repo import test https://github.com/charmbracelet/catwalk.git
1398 soft user create foo --key "$USER1_AUTHORIZED_KEY"
1399@@ -16,3 +22,7 @@ stdout 'foo'
1400 soft repo collab remove test foo
1401 soft repo collab list test
1402 ! stdout .
1403+
1404+# stop the server
1405+[windows] stopserver
1406+[windows] ! stderr .
1407diff --git a/testscript/testdata/repo-commit.txtar b/testscript/testdata/repo-commit.txtar
1408index dbf5c1bf80f5db690a1c30d4157834ca7a1e2316..70ca3da125876d954a739e262542166a37f11754 100644
1409--- a/testscript/testdata/repo-commit.txtar
1410+++ b/testscript/testdata/repo-commit.txtar
1411@@ -3,6 +3,11 @@
1412 # convert crlf to lf on windows
1413 [windows] dos2unix commit1.txt
1414 
1415+# start soft serve
1416+exec soft serve &
1417+# wait for server to start
1418+waitforserver
1419+
1420 # create a repo
1421 soft repo import basic1 https://github.com/git-fixtures/basic
1422 
1423@@ -10,6 +15,10 @@ soft repo import basic1 https://github.com/git-fixtures/basic
1424 soft repo commit basic1 b8e471f58bcbca63b07bda20e428190409c2db47
1425 cmp stdout commit1.txt
1426 
1427+# stop the server
1428+[windows] stopserver
1429+[windows] ! stderr .
1430+
1431 -- commit1.txt --
1432 commit b8e471f58bcbca63b07bda20e428190409c2db47
1433 Author: Daniel Ripolles
1434diff --git a/testscript/testdata/repo-create.txtar b/testscript/testdata/repo-create.txtar
1435index 794294a16e195b7509babc6bc2e26ed30980f3c5..7b7f12d77f17eae7c66b02704e79a16d19638b17 100644
1436--- a/testscript/testdata/repo-create.txtar
1437+++ b/testscript/testdata/repo-create.txtar
1438@@ -3,6 +3,11 @@
1439 # convert crlf to lf on windows
1440 [windows] dos2unix readme.md branch_list.1.txt info.txt
1441 
1442+# start soft serve
1443+exec soft serve &
1444+# wait for server to start
1445+waitforserver
1446+
1447 # create a repo
1448 soft repo create repo1 -d 'description' -H -p -n 'repo11'
1449 stderr 'Created repository repo1.*'
1450@@ -114,6 +119,10 @@ stdout 'repo2'
1451 usoft repo delete repo2
1452 ! exists $DATA_PATH/repos/repo2.git
1453 
1454+# stop the server
1455+[windows] stopserver
1456+[windows] ! stderr .
1457+
1458 
1459 -- readme.md --
1460 # Project\nfoo
1461diff --git a/testscript/testdata/repo-delete.txtar b/testscript/testdata/repo-delete.txtar
1462index 2941dd48c252029887d21f7c81570b2968d6f1c8..403c96043cd4ec46e448b17954ad346e7cceeca4 100644
1463--- a/testscript/testdata/repo-delete.txtar
1464+++ b/testscript/testdata/repo-delete.txtar
1465@@ -1,5 +1,10 @@
1466 # vi: set ft=conf
1467 
1468+# start soft serve
1469+exec soft serve &
1470+# wait for server to start
1471+waitforserver
1472+
1473 soft repo create repo1
1474 soft repo create repo-to-delete
1475 soft repo delete repo-to-delete
1476@@ -7,3 +12,7 @@ soft repo delete repo-to-delete
1477 stderr '.*not found.*'
1478 soft repo list
1479 stdout 'repo1'
1480+
1481+# stop the server
1482+[windows] stopserver
1483+[windows] ! stderr .
1484diff --git a/testscript/testdata/repo-import.txtar b/testscript/testdata/repo-import.txtar
1485index d85cdf3790187d2144f21087b39db2e07e9453dc..84d4334b4505990626c3f2d19cae19062b6963ae 100644
1486--- a/testscript/testdata/repo-import.txtar
1487+++ b/testscript/testdata/repo-import.txtar
1488@@ -3,6 +3,11 @@
1489 # convert crlf to lf on windows
1490 [windows] dos2unix repo3.txt
1491 
1492+# start soft serve
1493+exec soft serve &
1494+# wait for server to start
1495+waitforserver
1496+
1497 # import private
1498 soft repo import --private repo1 https://github.com/charmbracelet/catwalk.git
1499 soft repo private repo1
1500@@ -18,6 +23,10 @@ soft repo import --name 'repo33' --description 'descriptive' repo3 https://githu
1501 soft repo info repo3
1502 cmp stdout repo3.txt
1503 
1504+# stop the server
1505+[windows] stopserver
1506+[windows] ! stderr .
1507+
1508 -- repo3.txt --
1509 Project Name: repo33
1510 Repository: repo3
1511diff --git a/testscript/testdata/repo-perms.txtar b/testscript/testdata/repo-perms.txtar
1512index 66341b9fecea85d043d3722bcc11aa89c2189fca..1cc371e93c404a92d6c21d31ed5f8cbd275b1d2a 100644
1513--- a/testscript/testdata/repo-perms.txtar
1514+++ b/testscript/testdata/repo-perms.txtar
1515@@ -3,6 +3,11 @@
1516 # convert crlf to lf on windows
1517 [windows] dos2unix info.txt
1518 
1519+# start soft serve
1520+exec soft serve &
1521+# wait for server to start
1522+waitforserver
1523+
1524 # create a repo & user1 with admin
1525 soft repo create repo1 -p
1526 soft user create user1 -k "$USER1_AUTHORIZED_KEY"
1527@@ -77,6 +82,10 @@ usoft repo delete repo1
1528 usoft repo list
1529 ! stdout .
1530 
1531+# stop the server
1532+[windows] stopserver
1533+[windows] ! stderr .
1534+
1535 -- info.txt --
1536 Project Name: proj
1537 Repository: repo1
1538diff --git a/testscript/testdata/repo-push.txtar b/testscript/testdata/repo-push.txtar
1539new file mode 100644
1540index 0000000000000000000000000000000000000000..cadb735b95349f71161515f034d420a4cb8f2100
1541--- /dev/null
1542+++ b/testscript/testdata/repo-push.txtar
1543@@ -0,0 +1,18 @@
1544+# vi: set ft=conf
1545+
1546+# start soft serve
1547+exec soft serve &
1548+# wait for server to start
1549+waitforserver
1550+
1551+# create a repo
1552+soft repo create repo-empty -d 'description' -H -p -n 'repo-empty'
1553+
1554+# clone repo
1555+git clone ssh://localhost:$SSH_PORT/repo-empty repo-empty
1556+
1557+# push repo
1558+! git -C repo-empty push origin HEAD
1559+
1560+# stop the server
1561+[windows] stopserver