diff --git a/cmd/soft/root.go b/cmd/soft/root.go index 9e443b5b39a77ad3028c47ce57c6ddc94146c387..329ec143eab3ce60ac4284920d05e9f301fc7a0d 100644 --- a/cmd/soft/root.go +++ b/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) diff --git a/server/backend/auth_test.go b/server/backend/auth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..db40db3b967952347f00763cdba2fbba6c265606 --- /dev/null +++ b/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") + } +} diff --git a/server/config/config.go b/server/config/config.go index c269c63355ab32780e9a57d8eb46c006d4ba984b..aba8f508e3189c7058d2678018d42852f2177248 100644 --- a/server/config/config.go +++ b/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, + }, } } diff --git a/server/config/context.go b/server/config/context.go index 2a9c47bcf3433870310ce950e0fd0743e8f84b4c..e364fefbabd983ea31bc1e307a27d739ca43d92f 100644 --- a/server/config/context.go +++ b/server/config/context.go @@ -16,5 +16,5 @@ func FromContext(ctx context.Context) *Config { return c } - return DefaultConfig() + return nil } diff --git a/server/config/file.go b/server/config/file.go index a3b78c0160d57909f9f120489ed87a41daf489fc..a4e39eb7cfb063b7b7db7974ff692701a9d72e63 100644 --- a/server/config/file.go +++ b/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..." diff --git a/server/git/git_test.go b/server/git/git_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d95cb6497daed360740ccfb50a5a79c2d2944712 --- /dev/null +++ b/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()) + } + }) + } +} diff --git a/server/lfs/pointer_test.go b/server/lfs/pointer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..df2f2daa2fb5e62d8cee71226d894a23676787c4 --- /dev/null +++ b/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) + } + }) + } +} diff --git a/server/log/log.go b/server/log/log.go new file mode 100644 index 0000000000000000000000000000000000000000..3162d87689e8652c2a107ec0840ecd415fd4986f --- /dev/null +++ b/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 +} diff --git a/server/ssh/git.go b/server/ssh/git.go index 52320ed6b1b502a84de99eccc72aeb86efe0470f..eac3575913dcfc614142b7e6bed5d0e423d1c87e 100644 --- a/server/ssh/git.go +++ b/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 diff --git a/server/web/auth.go b/server/web/auth.go index 2a42daa1686598a5273d1cd41bd5a25268397d96..fb090fa6311e73cc816e7c3bd024834b9be431f6 100644 --- a/server/web/auth.go +++ b/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 } diff --git a/server/web/context.go b/server/web/context.go index a527b9da0a68f65b9daa8240866976b89775dfc3..b0a2480e787d10bbeb6a10cb919dd3abbc070539 100644 --- a/server/web/context.go +++ b/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 { diff --git a/server/web/git.go b/server/web/git.go index 8ee678dbd908d8f8a7d3d1b8bf492fec06e11593..a2158f434edf64031ff5b1aaddb9c7deaf16db4a 100644 --- a/server/web/git.go +++ b/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 diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go index f243ab973e2002b41b0cc97022e645aeb55dfe06..e49ca18abc99a92f255bd19a7dd87661f0874f39 100644 --- a/server/web/git_lfs.go +++ b/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 } diff --git a/server/web/goget.go b/server/web/goget.go index 8ee4609f1ca106a151aa67deb48b0406f0693681..7d9ec1466606b3a8e76d7f37dc9633411e6814d3 100644 --- a/server/web/goget.go +++ b/server/web/goget.go @@ -31,7 +31,8 @@ var repoIndexHTMLTpl = template.Must(template.New("index").Parse(` Redirecting to docs at godoc.org/{{ .ImportRoot }}/{{ .Repo }}... -`)) + +`)) // GoGetHandler handles go get requests. type GoGetHandler struct{} diff --git a/server/web/server.go b/server/web/server.go index cdb854726943a823009720f43a24dcee4124fef2..af344e7a1bd6bfe67178881a78061b7783d5d9dc 100644 --- a/server/web/server.go +++ b/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) } diff --git a/testscript/script_test.go b/testscript/script_test.go index d16c19f3fe304bc64febe5c42fe8008ef7ce8d0a..8c9e3ceae7c4f393c71466a9319a252827e32542 100644 --- a/testscript/script_test.go +++ b/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))) + } +} diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar new file mode 100644 index 0000000000000000000000000000000000000000..e0710514607f58601415fb106aab459a6925d395 --- /dev/null +++ b/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 -- + + + + + + + + +Redirecting to docs at godoc.org/localhost:$HTTP_PORT/repo2... + + +-- gitclone.txt -- +Cloning into 'repo2_clone'... +fatal: could not read Username for 'http://localhost:$HTTP_PORT': terminal prompts disabled diff --git a/testscript/testdata/jwt.txtar b/testscript/testdata/jwt.txtar new file mode 100644 index 0000000000000000000000000000000000000000..07c605e948938d4f293d13c875f274d433887016 --- /dev/null +++ b/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 '.*\..*\..*' diff --git a/testscript/testdata/repo-create.txtar b/testscript/testdata/repo-create.txtar index 31c7028b8faaea888e34fad9f2b6f2211b08dfbf..d81f6b976151dd058c3325b50cb569422d7f81fe 100644 --- a/testscript/testdata/repo-create.txtar +++ b/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 -- diff --git a/testscript/testdata/token.txtar b/testscript/testdata/token.txtar new file mode 100644 index 0000000000000000000000000000000000000000..d5010c74f3881add8789a4d11e1613c525057dee --- /dev/null +++ b/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'