feat: add lfs config and tests

Ayman Bagabas created

Enable/disable LFS endpoints
Enable/disable SSH LFS transfer

fix: lfs request validation

chore: add jwt, lfs, and http tests

fix: tests race

feat: more tests

fix: return 403 on bad creds

Change summary

cmd/soft/root.go                      |  43 ---------
server/backend/auth_test.go           |  38 +++++++++
server/config/config.go               |  19 ++++
server/config/context.go              |   2 
server/config/file.go                 |   7 +
server/git/git_test.go                |  56 +++++++++++++
server/lfs/pointer_test.go            |  95 +++++++++++++++++++++++
server/log/log.go                     |  48 +++++++++++
server/ssh/git.go                     |   8 +
server/web/auth.go                    |   3 
server/web/context.go                 |   5 
server/web/git.go                     |  40 +++++++-
server/web/git_lfs.go                 |  29 ++++++
server/web/goget.go                   |   3 
server/web/server.go                  |  15 ++-
testscript/script_test.go             |  35 ++++++++
testscript/testdata/http.txtar        | 119 ++++++++++++++++++++++++++++
testscript/testdata/jwt.txtar         |  14 +++
testscript/testdata/repo-create.txtar |  12 ++
testscript/testdata/token.txtar       |  28 ++++++
20 files changed, 556 insertions(+), 63 deletions(-)

Detailed changes

cmd/soft/root.go 🔗

@@ -7,13 +7,12 @@ import (
 	"io/fs"
 	"os"
 	"runtime/debug"
-	"strings"
-	"time"
 
 	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
+	logr "github.com/charmbracelet/soft-serve/server/log"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/soft-serve/server/store/database"
 	_ "github.com/lib/pq" // postgres driver
@@ -78,7 +77,7 @@ func main() {
 	}
 
 	ctx = config.WithContext(ctx, cfg)
-	logger, f, err := newDefaultLogger(cfg)
+	logger, f, err := logr.NewLogger(cfg)
 	if err != nil {
 		log.Errorf("failed to create logger: %v", err)
 	}
@@ -107,44 +106,6 @@ func main() {
 	}
 }
 
-// newDefaultLogger returns a new logger with default settings.
-func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
-	logger := log.NewWithOptions(os.Stderr, log.Options{
-		ReportTimestamp: true,
-		TimeFormat:      time.DateOnly,
-	})
-
-	switch {
-	case config.IsVerbose():
-		logger.SetReportCaller(true)
-		fallthrough
-	case config.IsDebug():
-		logger.SetLevel(log.DebugLevel)
-	}
-
-	logger.SetTimeFormat(cfg.Log.TimeFormat)
-
-	switch strings.ToLower(cfg.Log.Format) {
-	case "json":
-		logger.SetFormatter(log.JSONFormatter)
-	case "logfmt":
-		logger.SetFormatter(log.LogfmtFormatter)
-	case "text":
-		logger.SetFormatter(log.TextFormatter)
-	}
-
-	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)
-		if err != nil {
-			return nil, nil, err
-		}
-		logger.SetOutput(f)
-	}
-
-	return logger, f, nil
-}
-
 func initBackendContext(cmd *cobra.Command, _ []string) error {
 	ctx := cmd.Context()
 	cfg := config.FromContext(ctx)

server/backend/auth_test.go 🔗

@@ -0,0 +1,38 @@
+package backend
+
+import "testing"
+
+func TestHashPassword(t *testing.T) {
+	hash, err := HashPassword("password")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if hash == "" {
+		t.Fatal("hash is empty")
+	}
+}
+
+func TestVerifyPassword(t *testing.T) {
+	hash, err := HashPassword("password")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !VerifyPassword("password", hash) {
+		t.Fatal("password did not verify")
+	}
+}
+
+func TestGenerateToken(t *testing.T) {
+	token := GenerateToken()
+	if token == "" {
+		t.Fatal("token is empty")
+	}
+}
+
+func TestHashToken(t *testing.T) {
+	token := GenerateToken()
+	hash := HashToken(token)
+	if hash == "" {
+		t.Fatal("hash is empty")
+	}
+}

server/config/config.go 🔗

@@ -95,6 +95,16 @@ type DBConfig struct {
 	DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
 }
 
+// LFSConfig is the configuration for Git LFS.
+type LFSConfig struct {
+	// Enabled is whether or not Git LFS is enabled.
+	Enabled bool `env:"ENABLED" yaml:"enabled"`
+
+	// SSHEnabled is whether or not Git LFS over SSH is enabled.
+	// This is only used if LFS is enabled.
+	SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`
+}
+
 // Config is the configuration for Soft Serve.
 type Config struct {
 	// Name is the name of the server.
@@ -118,6 +128,9 @@ type Config struct {
 	// DB is the database configuration.
 	DB DBConfig `envPrefix:"DB_" yaml:"db"`
 
+	// LFS is the configuration for Git LFS.
+	LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`
+
 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
 
@@ -156,6 +169,8 @@ func (c *Config) Environ() []string {
 		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
 		fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
 		fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
+		fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),
+		fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),
 	}...)
 
 	return envs
@@ -309,6 +324,10 @@ func DefaultConfig() *Config {
 			DataSource: "soft-serve.db" +
 				"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
 		},
+		LFS: LFSConfig{
+			Enabled:    true,
+			SSHEnabled: true,
+		},
 	}
 }
 

server/config/context.go 🔗

@@ -16,5 +16,5 @@ func FromContext(ctx context.Context) *Config {
 		return c
 	}
 
-	return DefaultConfig()
+	return nil
 }

server/config/file.go 🔗

@@ -90,6 +90,13 @@ db:
   # This is driver specific and can be a file path or connection string.
   data_source: "{{ .DB.DataSource }}"
 
+# Git LFS configuration.
+lfs:
+  # Enable Git LFS.
+  enabled: {{ .LFS.Enabled }}
+  # Enable Git SSH transfer.
+  ssh_enabled: {{ .LFS.SSHEnabled }}
+
 # Additional admin keys.
 #initial_admin_keys:
 #  - "ssh-rsa AAAAB3NzaC1yc2..."

server/git/git_test.go 🔗

@@ -0,0 +1,56 @@
+package git
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+)
+
+func TestPktline(t *testing.T) {
+	cases := []struct {
+		name string
+		in   []byte
+		err  error
+		out  []byte
+	}{
+		{
+			name: "empty",
+			in:   []byte{},
+			out:  []byte("0005\n0000"),
+		},
+		{
+			name: "simple",
+			in:   []byte("hello"),
+			out:  []byte("000ahello\n0000"),
+		},
+		{
+			name: "newline",
+			in:   []byte("hello\n"),
+			out:  []byte("000bhello\n\n0000"),
+		},
+		{
+			name: "error",
+			err:  fmt.Errorf("foobar"),
+			out:  []byte("000fERR foobar\n0000"),
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			var out bytes.Buffer
+			if c.err == nil {
+				if err := WritePktline(&out, string(c.in)); err != nil {
+					t.Fatal(err)
+				}
+			} else {
+				if err := WritePktlineErr(&out, c.err); err != nil {
+					t.Fatal(err)
+				}
+			}
+
+			if !bytes.Equal(out.Bytes(), c.out) {
+				t.Errorf("expected %q, got %q", c.out, out.Bytes())
+			}
+		})
+	}
+}

server/lfs/pointer_test.go 🔗

@@ -0,0 +1,95 @@
+package lfs
+
+import (
+	"errors"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+func TestReadPointer(t *testing.T) {
+	cases := []struct {
+		name     string
+		content  string
+		want     Pointer
+		wantErr  error
+		wantErrp interface{}
+	}{
+		{
+			name: "valid pointer",
+			content: `version https://git-lfs.github.com/spec/v1
+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
+size 1234
+`,
+			want: Pointer{
+				Oid:  "1234567890123456789012345678901234567890123456789012345678901234",
+				Size: 1234,
+			},
+		},
+		{
+			name: "invalid prefix",
+			content: `version https://foobar/spec/v2
+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
+size 1234
+`,
+			wantErr: ErrMissingPrefix,
+		},
+		{
+			name: "invalid oid",
+			content: `version https://git-lfs.github.com/spec/v1
+oid sha256:&2345a78$012345678901234567890123456789012345678901234567890123
+size 1234
+`,
+			wantErr: ErrInvalidOIDFormat,
+		},
+		{
+			name: "invalid size",
+			content: `version https://git-lfs.github.com/spec/v1
+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
+size abc
+`,
+			wantErrp: &strconv.NumError{},
+		},
+		{
+			name: "invalid structure",
+			content: `version https://git-lfs.github.com/spec/v1
+`,
+			wantErr: ErrInvalidStructure,
+		},
+		{
+			name:    "empty pointer",
+			wantErr: ErrMissingPrefix,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			p, err := ReadPointerFromBuffer([]byte(tc.content))
+			if err != tc.wantErr && !errors.As(err, &tc.wantErrp) {
+				t.Errorf("ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)", err, err, tc.wantErr, tc.wantErr)
+				return
+			}
+			if err != nil {
+				return
+			}
+
+			if err == nil {
+				if !p.IsValid() {
+					t.Errorf("Expected a valid pointer")
+					return
+				}
+				if p.Oid != strings.ReplaceAll(p.RelativePath(), "/", "") {
+					t.Errorf("Expected oid to be the relative path without slashes")
+					return
+				}
+			}
+
+			if p.Oid != tc.want.Oid {
+				t.Errorf("ReadPointerFromBuffer() oid = %v, want %v", p.Oid, tc.want.Oid)
+			}
+			if p.Size != tc.want.Size {
+				t.Errorf("ReadPointerFromBuffer() size = %v, want %v", p.Size, tc.want.Size)
+			}
+		})
+	}
+}

server/log/log.go 🔗

@@ -0,0 +1,48 @@
+package log
+
+import (
+	"os"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/config"
+)
+
+// NewLogger returns a new logger with default settings.
+func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
+	logger := log.NewWithOptions(os.Stderr, log.Options{
+		ReportTimestamp: true,
+		TimeFormat:      time.DateOnly,
+	})
+
+	switch {
+	case config.IsVerbose():
+		logger.SetReportCaller(true)
+		fallthrough
+	case config.IsDebug():
+		logger.SetLevel(log.DebugLevel)
+	}
+
+	logger.SetTimeFormat(cfg.Log.TimeFormat)
+
+	switch strings.ToLower(cfg.Log.Format) {
+	case "json":
+		logger.SetFormatter(log.JSONFormatter)
+	case "logfmt":
+		logger.SetFormatter(log.LogfmtFormatter)
+	case "text":
+		logger.SetFormatter(log.TextFormatter)
+	}
+
+	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)
+		if err != nil {
+			return nil, nil, err
+		}
+		logger.SetOutput(f)
+	}
+
+	return logger, f, nil
+}

server/ssh/git.go 🔗

@@ -132,6 +132,14 @@ func handleGit(s ssh.Session) {
 
 		return
 	case git.LFSTransferService, git.LFSAuthenticateService:
+		if !cfg.LFS.Enabled {
+			return
+		}
+
+		if service == git.LFSTransferService && !cfg.LFS.SSHEnabled {
+			return
+		}
+
 		if accessLevel < access.ReadWriteAccess {
 			sshFatal(s, git.ErrNotAuthed)
 			return

server/web/auth.go 🔗

@@ -19,6 +19,9 @@ func authenticate(r *http.Request) (proto.User, error) {
 	// Prefer the Authorization header
 	user, err := parseAuthHdr(r)
 	if err != nil || user == nil {
+		if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+			return nil, err
+		}
 		return nil, proto.ErrUserNotFound
 	}
 

server/web/context.go 🔗

@@ -11,12 +11,13 @@ import (
 	"github.com/charmbracelet/soft-serve/server/store"
 )
 
-// NewContextMiddleware returns a new context middleware.
+// NewContextHandler returns a new context middleware.
 // This middleware adds the config, backend, and logger to the request context.
-func NewContextMiddleware(ctx context.Context) func(http.Handler) http.Handler {
+func NewContextHandler(ctx context.Context) func(http.Handler) http.Handler {
 	cfg := config.FromContext(ctx)
 	be := backend.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http")
+	logger.Infof("data path %s", cfg.DataPath)
 	dbx := db.FromContext(ctx)
 	datastore := store.FromContext(ctx)
 	return func(next http.Handler) http.Handler {

server/web/git.go 🔗

@@ -47,6 +47,8 @@ func (g GitRoute) Match(r *http.Request) *http.Request {
 		// This finds the Git objects & packs filenames in the URL.
 		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
 		repo := utils.SanitizeRepo(m[1])
+		// Add repo suffix (.git)
+		r.URL.Path = fmt.Sprintf("%s.git/%s", repo, file)
 
 		var service git.Service
 		var oid string    // LFS object ID
@@ -218,6 +220,7 @@ func askCredentials(w http.ResponseWriter, _ *http.Request) {
 func withAccess(next http.Handler) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		ctx := r.Context()
+		cfg := config.FromContext(ctx)
 		logger := log.FromContext(ctx)
 		be := backend.FromContext(ctx)
 
@@ -288,8 +291,17 @@ func withAccess(next http.Handler) http.HandlerFunc {
 					renderInternalServerError(w)
 					return
 				}
+
+				ctx = proto.WithRepositoryContext(ctx, repo)
+				r = r.WithContext(ctx)
 			}
 		case gitLfsService:
+			if !cfg.LFS.Enabled {
+				logger.Debug("LFS is not enabled, skipping")
+				renderNotFound(w)
+				return
+			}
+
 			switch {
 			case strings.HasPrefix(file, "info/lfs/locks"):
 				switch {
@@ -323,10 +335,20 @@ func withAccess(next http.Handler) http.HandlerFunc {
 				}
 			}
 			if accessLevel < access.ReadOnlyAccess {
-				askCredentials(w, r)
-				renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
-					Message: "credentials needed",
-				})
+				if repo == nil {
+					renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+						Message: "repository not found",
+					})
+				} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+					renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
+						Message: "bad credentials",
+					})
+				} else {
+					askCredentials(w, r)
+					renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
+						Message: "credentials needed",
+					})
+				}
 				return
 			}
 		default:
@@ -334,13 +356,15 @@ func withAccess(next http.Handler) http.HandlerFunc {
 			return
 		}
 
-		// If the repo doesn't exist, return 404
 		if repo == nil {
+			// If the repo doesn't exist, return 404
 			renderNotFound(w)
 			return
-		}
-
-		if accessLevel < access.ReadOnlyAccess {
+		} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+			// return 403 when bad credentials are provided
+			renderForbidden(w)
+			return
+		} else if accessLevel < access.ReadOnlyAccess {
 			askCredentials(w, r)
 			renderUnauthorized(w)
 			return

server/web/git_lfs.go 🔗

@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
@@ -47,6 +48,9 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 	defer r.Body.Close() // nolint: errcheck
 	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
 		logger.Errorf("error decoding json: %s", err)
+		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
+			Message: "validation error in request: " + err.Error(),
+		})
 		return
 	}
 
@@ -69,6 +73,13 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	if len(batchRequest.Objects) == 0 {
+		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
+			Message: "no objects found",
+		})
+		return
+	}
+
 	name := pat.Param(r, "repo")
 	repo := proto.RepositoryFromContext(ctx)
 	if repo == nil {
@@ -169,6 +180,16 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 	case lfs.OperationUpload:
+		// Check authorization
+		accessLevel := access.FromContext(ctx)
+		if accessLevel < access.ReadWriteAccess {
+			askCredentials(w, r)
+			renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
+				Message: "credentials needed",
+			})
+			return
+		}
+
 		// Object upload logic happens in the "basic" API route
 		for _, o := range batchRequest.Objects {
 			if !o.IsValid() {
@@ -368,7 +389,7 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
 	if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
 		logger.Error("error decoding json", "err", err)
 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
-			Message: "invalid json",
+			Message: "invalid request: " + err.Error(),
 		})
 		return
 	}
@@ -446,7 +467,7 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		logger.Error("error decoding json", "err", err)
 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
-			Message: "invalid request",
+			Message: "invalid request: " + err.Error(),
 		})
 		return
 	}
@@ -731,7 +752,7 @@ func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		logger.Error("error decoding request", "err", err)
 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
-			Message: "invalid request",
+			Message: "invalid request: " + err.Error(),
 		})
 		return
 	}
@@ -837,7 +858,7 @@ func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		logger.Error("error decoding request", "err", err)
 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
-			Message: "invalid request",
+			Message: "invalid request: " + err.Error(),
 		})
 		return
 	}

server/web/goget.go 🔗

@@ -31,7 +31,8 @@ var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html
 <body>
 Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
 </body>
-</html>`))
+</html>
+`))
 
 // GoGetHandler handles go get requests.
 type GoGetHandler struct{}

server/web/server.go 🔗

@@ -16,13 +16,9 @@ type Route interface {
 
 // NewRouter returns a new HTTP router.
 // TODO: use gorilla/mux and friends
-func NewRouter(ctx context.Context) *goji.Mux {
+func NewRouter(ctx context.Context) http.Handler {
 	mux := goji.NewMux()
 
-	// Middlewares
-	mux.Use(NewContextMiddleware(ctx))
-	mux.Use(NewLoggingMiddleware)
-
 	// Git routes
 	for _, service := range gitRoutes {
 		mux.Handle(service, withAccess(service))
@@ -31,5 +27,12 @@ func NewRouter(ctx context.Context) *goji.Mux {
 	// go-get handler
 	mux.Handle(pat.Get("/*"), GoGetHandler{})
 
-	return mux
+	// Middlewares
+	mux.Use(NewLoggingMiddleware)
+
+	// Context handler
+	// Adds context to the request
+	ctxHandler := NewContextHandler(ctx)
+
+	return ctxHandler(mux)
 }

testscript/script_test.go 🔗

@@ -14,11 +14,13 @@ import (
 	"time"
 
 	"github.com/charmbracelet/keygen"
+	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/db"
 	"github.com/charmbracelet/soft-serve/server/db/migrate"
+	logr "github.com/charmbracelet/soft-serve/server/log"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/soft-serve/server/store/database"
 	"github.com/charmbracelet/soft-serve/server/test"
@@ -54,6 +56,7 @@ func TestScript(t *testing.T) {
 			"usoft":    cmdSoft(user1.Signer()),
 			"git":      cmdGit(key),
 			"mkfile":   cmdMkfile,
+			"envfile":  cmdEnvfile,
 			"readfile": cmdReadfile,
 			"dos2unix": cmdDos2Unix,
 		},
@@ -72,6 +75,7 @@ func TestScript(t *testing.T) {
 
 			e.Setenv("DATA_PATH", data)
 			e.Setenv("SSH_PORT", fmt.Sprintf("%d", sshPort))
+			e.Setenv("HTTP_PORT", fmt.Sprintf("%d", httpPort))
 			e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
 			e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
 			e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
@@ -89,6 +93,9 @@ func TestScript(t *testing.T) {
 			cfg.HTTP.PublicURL = "http://" + httpListen
 			cfg.Stats.ListenAddr = statsListen
 			cfg.DB.Driver = "sqlite"
+			cfg.LFS.Enabled = true
+			// TODO: run tests with both SSH enabled/disabled
+			cfg.LFS.SSHEnabled = false
 
 			if err := cfg.Validate(); err != nil {
 				return err
@@ -96,6 +103,16 @@ func TestScript(t *testing.T) {
 
 			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
+			}
+
 			// TODO: test postgres
 			dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 			if err != nil {
@@ -229,6 +246,8 @@ func cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string)
 			"GIT_SSH_COMMAND",
 			strings.Join(append([]string{"ssh"}, sshArgs...), " "),
 		)
+		// Disable git prompting for credentials.
+		ts.Setenv("GIT_TERMINAL_PROMPT", "0")
 		args = append([]string{
 			"-c", "user.email=john@example.com",
 			"-c", "user.name=John Doe",
@@ -260,3 +279,19 @@ func check(ts *testscript.TestScript, err error, neg bool) {
 func cmdReadfile(ts *testscript.TestScript, neg bool, args []string) {
 	ts.Stdout().Write([]byte(ts.ReadFile(args[0])))
 }
+
+func cmdEnvfile(ts *testscript.TestScript, neg bool, args []string) {
+	if len(args) < 1 {
+		ts.Fatalf("usage: envfile key=file...")
+	}
+
+	for _, arg := range args {
+		parts := strings.SplitN(arg, "=", 2)
+		if len(parts) != 2 {
+			ts.Fatalf("usage: envfile key=file...")
+		}
+		key := parts[0]
+		file := parts[1]
+		ts.Setenv(key, strings.TrimSpace(ts.ReadFile(file)))
+	}
+}

testscript/testdata/http.txtar 🔗

@@ -0,0 +1,119 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# create access token
+soft token create --expires-in '1h' 'repo2'
+stdout 'ss_*'
+cp stdout tokenfile
+envfile TOKEN=tokenfile
+soft token create --expires-in '1ns' 'repo2'
+stdout 'ss_*'
+cp stdout etokenfile
+envfile ETOKEN=etokenfile
+usoft token create 'repo2'
+stdout 'ss_*'
+cp stdout utokenfile
+envfile UTOKEN=utokenfile
+
+# push & create repo with some files, commits, tags...
+mkdir ./repo2
+git -c init.defaultBranch=master -C repo2 init
+mkfile ./repo2/README.md '# Project\nfoo'
+mkfile ./repo2/foo.png 'foo'
+mkfile ./repo2/bar.png 'bar'
+git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2
+git -C repo2 lfs install --local
+git -C repo2 lfs track '*.png'
+git -C repo2 add -A
+git -C repo2 commit -m 'first'
+git -C repo2 tag v0.1.0
+git -C repo2 push origin HEAD
+git -C repo2 push origin HEAD --tags
+
+# http errors
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111foobar.git/foo/bar
+stdout '404.*'
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111/foobar.git/foo/bar
+stdout '404.*'
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/foo/bar
+stdout '404.*'
+exec curl -s -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/foo
+stdout '404.*'
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*Method Not Allowed.*'
+exec curl -s -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*Not Acceptable.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*validation error.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*no objects found.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","transfers":["foo"]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*unsupported transfer.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"bar","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*unsupported operation.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http1.txt
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*credentials needed.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http1.txt
+
+# set private
+soft repo private repo2 true
+
+# allow access private
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http2.txt
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$ETOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http3.txt
+
+# deny access private
+exec curl -s http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*credentials needed.*'
+exec curl -s http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*credentials needed.*'
+exec curl -s http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http3.txt
+
+# deny access ask for credentials
+# this means the server responded with a 401 and prompted for credentials
+# but we disable git terminal prompting to we get a fatal instead of a 401 "Unauthorized"
+! git clone http://localhost:$HTTP_PORT/repo2 repo2_clone
+cmpenv stderr gitclone.txt
+! git clone http://someuser:somepassword@localhost:$HTTP_PORT/repo2 repo2_clone
+stderr '.*Forbidden.*'
+
+# go-get endpoints not found
+exec curl -s http://localhost:$HTTP_PORT/repo2.git
+stdout '404.*'
+
+# go-get endpoints
+exec curl -s http://localhost:$HTTP_PORT/repo2.git?go-get=1
+cmpenv stdout goget.txt
+
+-- http1.txt --
+{"transfer":"basic","objects":[{"oid":"","size":0,"error":{"code":422,"message":"invalid object"}}],"hash_algo":"sha256"}
+-- http2.txt --
+{"message":"validation error in request: EOF"}
+-- http3.txt --
+{"message":"bad credentials"}
+-- goget.txt --
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+    <meta http-equiv="refresh" content="0; url=https://godoc.org/localhost:$HTTP_PORT/repo2">
+    <meta name="go-import" content="localhost:$HTTP_PORT/repo2 git http://localhost:$HTTP_PORT/repo2">
+</head>
+<body>
+Redirecting to docs at <a href="https://godoc.org/localhost:$HTTP_PORT/repo2">godoc.org/localhost:$HTTP_PORT/repo2</a>...
+</body>
+</html>
+-- gitclone.txt --
+Cloning into 'repo2_clone'...
+fatal: could not read Username for 'http://localhost:$HTTP_PORT': terminal prompts disabled

testscript/testdata/jwt.txtar 🔗

@@ -0,0 +1,14 @@
+# vi: set ft=conf
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# generate jwt token
+soft jwt
+stdout '.*\..*\..*'
+soft jwt repo
+stdout '.*\..*\..*'
+usoft jwt
+stdout '.*\..*\..*'
+usoft jwt repo
+stdout '.*\..*\..*'

testscript/testdata/repo-create.txtar 🔗

@@ -27,6 +27,15 @@ git -C repo1 tag v0.1.0
 git -C repo1 push origin HEAD
 git -C repo1 push origin HEAD --tags
 
+# create lfs files, use ssh git-lfs-transfer
+git -C repo1 lfs install --local
+git -C repo1 lfs track '*.png'
+mkfile ./repo1/foo.png 'foo'
+mkfile ./repo1/bar.png 'bar'
+git -C repo1 add -A
+git -C repo1 commit -m 'lfs'
+git -C repo1 push origin HEAD
+
 # info
 soft repo info repo1
 cmp stdout info.txt
@@ -77,7 +86,10 @@ soft repo branch list repo1
 stdout branch1
 
 -- tree.txt --
+-rw-r--r--	42 B	 .gitattributes
 -rw-r--r--	14 B	 README.md
+-rw-r--r--	126 B	 bar.png
+-rw-r--r--	126 B	 foo.png
 -- readme.md --
 # Project\nfoo
 -- branch_list.1.txt --

testscript/testdata/token.txtar 🔗

@@ -0,0 +1,28 @@
+# vi: set ft=conf
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# generate jwt token
+usoft token create 'test1'
+stdout 'ss_.*'
+stderr 'Access token created'
+usoft token create --expires-in 1y 'test2'
+stdout 'ss_.*'
+stderr 'Access token created'
+usoft token create --expires-in 1ns 'test3'
+stdout 'ss_.*'
+stderr 'Access token created'
+
+# list tokens
+usoft token list
+cp stdout tokens.txt
+grep '1         test1.*       -' tokens.txt
+grep '2         test2.*       1 year from now' tokens.txt
+grep '3         test3.*       expired' tokens.txt
+
+# delete token
+usoft token delete 1
+stderr 'Access token deleted'
+! usoft token delete 1
+stderr 'token not found'