.editorconfig 🔗
@@ -0,0 +1,13 @@
+root = true
+
+[*]
+charset=utf-8
+end_of_line=lf
+insert_final_newline=true
+trim_trailing_whitespace=true
+indent_size=2
+indent_style=space
+
+[*.go]
+indent_size=4
+indent_style=tab
Carlos Alexandro Becker and Ayman Bagabas created
* test: playing with testscript
* fix: multiple servers
* fix: -update flag
* test: user management tests
* test: fix config
* fix: ensure perms after clone
* fix: race condition
* fix: shutdown
* test: set-username
* test: repo collab
* test: repo mirror and other ops
* test: import repo
* test: repo create
* test: disable hooks on testscript
* test: random port: prevent port reuse
* test: wait for server
* fix: git with no user info
* test: no idea whats going on on windows
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* test: create keys on runtime
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* chore: organizing repo
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix: remove if for windows
* Revert "fix: remove if for windows"
This reverts commit 5776fde194d675d25336967e5f89c8fd9a5e7b4f.
* chore: trying something out
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* refactor: use crypto/ssh instead of ssh binary
* fix: neg only the actual cmd
* fix: unix2dos on windows tests
* fix: unix2dos
* fix: skip hooks on tests
* fix: trainling whitespace
* chore: editorconfig and gitattributes
* test: maybe its not really needed?
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* Revert "test: maybe its not really needed?"
This reverts commit a704c5fbf17dad7bd34646950074b125a093ae31.
* fix: improve \r\n handling
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix: crlf
* chore: trigger
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix: disable unix2docs
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* test: validate repo
* chore: debug
* fix(test): convert dos crlf to lf (#311)
* fix(test): convert dos crlf to lf
* use temp files
* chore: log
* fix: ssh config
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix: config
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* fix(ci): use build workflow from main
* fix: editorconfig
* fix: editorconfig
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
* feat(test): add SanitizeRepo tests
* fix(test): sanitizerepo test
---------
Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Ayman Bagabas <ayman.bagabas@gmail.com>
.editorconfig | 13 +
.gitattributes | 2
.github/workflows/build.yml | 2
go.mod | 4
go.sum | 8
server/backend/sqlite/user.go | 3
server/cmd/repo.go | 14 +
server/hooks/hooks.go | 18 +
server/jobs.go | 1
server/server.go | 7
server/test/test.go | 22 ++
server/utils/utils.go | 6
server/utils/utils_test.go | 56 ++++++
testscript/script_test.go | 229 +++++++++++++++++++++++++
testscript/testdata/mirror.txtar | 96 ++++++++++
testscript/testdata/repo-collab.txtar | 18 +
testscript/testdata/repo-create.txtar | 78 ++++++++
testscript/testdata/repo-import.txt | 30 +++
testscript/testdata/set-username.txtar | 24 ++
testscript/testdata/settings.txtar | 31 +++
testscript/testdata/user_management.txtar | 105 +++++++++++
21 files changed, 744 insertions(+), 23 deletions(-)
@@ -0,0 +1,13 @@
+root = true
+
+[*]
+charset=utf-8
+end_of_line=lf
+insert_final_newline=true
+trim_trailing_whitespace=true
+indent_size=2
+indent_style=space
+
+[*.go]
+indent_size=4
+indent_style=tab
@@ -0,0 +1,2 @@
+# To prevent CRLF breakages on Windows for fragile files, like testdata.
+* -text
@@ -25,4 +25,4 @@ jobs:
run: go test -failfast -race -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt ./... -timeout 5m
- uses: codecov/codecov-action@v3
with:
- file: ./coverage.txt
+ file: ./coverage.txt
@@ -31,6 +31,7 @@ require (
github.com/muesli/roff v0.1.0
github.com/prometheus/client_golang v1.15.1
github.com/robfig/cron/v3 v3.0.1
+ github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97
github.com/spf13/cobra v1.7.0
go.uber.org/automaxprocs v1.5.2
goji.io v2.0.2+incompatible
@@ -73,12 +74,11 @@ require (
github.com/prometheus/procfs v0.9.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.2.0 // indirect
- github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sahilm/fuzzy v0.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/yuin/goldmark v1.5.2 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
- golang.org/x/mod v0.8.0 // indirect
+ golang.org/x/mod v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/term v0.8.0 // indirect
@@ -153,8 +153,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97 h1:3RPlVWzZ/PDqmVuf/FKHARG5EMid/tl7cv54Sw/QRVY=
+github.com/rogpeppe/go-internal v1.10.1-0.20230524175051-ec119421bb97/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
@@ -185,8 +185,8 @@ goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMI
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
-golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
+golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
@@ -42,7 +42,8 @@ func (u *User) PublicKeys() []ssh.PublicKey {
if err := tx.Select(&keyStrings, `SELECT public_key
FROM public_key
INNER JOIN user ON user.id = public_key.user_id
- WHERE user.username = ?;`, u.username); err != nil {
+ WHERE user.username = ?
+ ORDER BY public_key.id asc;`, u.username); err != nil {
return err
}
@@ -1,6 +1,11 @@
package cmd
-import "github.com/spf13/cobra"
+import (
+ "fmt"
+ "strings"
+
+ "github.com/spf13/cobra"
+)
func repoCommand() *cobra.Command {
cmd := &cobra.Command{
@@ -53,9 +58,12 @@ func repoCommand() *cobra.Command {
branches, _ := r.Branches()
tags, _ := r.Tags()
- cmd.Println("Project Name:", rr.ProjectName())
+
+ // project name and description are optional, handle trailing
+ // whitespace to avoid breaking tests.
+ cmd.Println(strings.TrimSpace(fmt.Sprint("Project Name: ", rr.ProjectName())))
cmd.Println("Repository:", rr.Name())
- cmd.Println("Description:", rr.Description())
+ cmd.Println(strings.TrimSpace(fmt.Sprint("Description: ", rr.Description())))
cmd.Println("Private:", rr.IsPrivate())
cmd.Println("Hidden:", rr.IsHidden())
cmd.Println("Mirror:", rr.IsMirror())
@@ -3,6 +3,7 @@ package hooks
import (
"bytes"
"context"
+ "flag"
"fmt"
"os"
"path/filepath"
@@ -28,8 +29,13 @@ const (
// - post-update
//
// This function should be called by the backend when a repository is created.
-// TODO: support context
-func GenerateHooks(ctx context.Context, cfg *config.Config, repo string) error {
+// TODO: support context.
+func GenerateHooks(_ context.Context, cfg *config.Config, repo string) error {
+ // TODO: support git hook tests.
+ if flag.Lookup("test.v") != nil {
+ log.WithPrefix("backend.hooks").Warn("refusing to set up hooks when in test")
+ return nil
+ }
repo = utils.SanitizeRepo(repo) + ".git"
hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
@@ -136,10 +142,9 @@ done
`
)
-var (
- // hooksTmpl is the soft-serve hook that will be run by the git hooks
- // inside the hooks directory.
- hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
+// hooksTmpl is the soft-serve hook that will be run by the git hooks
+// inside the hooks directory.
+var hooksTmpl = template.Must(template.New("hooks").Parse(`#!/usr/bin/env bash
# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
if [ -z "$SOFT_SERVE_REPO_NAME" ]; then
echo "Warning: SOFT_SERVE_REPO_NAME not defined. Skipping hooks."
@@ -149,4 +154,3 @@ fi
{{ $env }} \{{ end }}
{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
`))
-)
@@ -51,7 +51,6 @@ func (s *Server) mirrorJob() func() {
if _, err := cmd.RunInDir(r.Path); err != nil {
logger.Error("error running git remote update", "repo", name, "err", err)
}
-
})
}
}
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "io"
"net/http"
"github.com/charmbracelet/log"
@@ -158,6 +159,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
s.Cron.Stop()
return nil
})
+ if closer, ok := s.Backend.(io.Closer); ok {
+ defer closer.Close() // nolint: errcheck
+ }
return errg.Wait()
}
@@ -172,5 +176,8 @@ func (s *Server) Close() error {
s.Cron.Stop()
return nil
})
+ if closer, ok := s.Backend.(io.Closer); ok {
+ defer closer.Close() // nolint: errcheck
+ }
return errg.Wait()
}
@@ -1,11 +1,29 @@
package test
-import "net"
+import (
+ "net"
+ "sync"
+)
+
+var (
+ used = map[int]struct{}{}
+ lock sync.Mutex
+)
// RandomPort returns a random port number.
// This is mainly used for testing.
func RandomPort() int {
addr, _ := net.Listen("tcp", ":0") //nolint:gosec
_ = addr.Close()
- return addr.Addr().(*net.TCPAddr).Port
+ port := addr.Addr().(*net.TCPAddr).Port
+ lock.Lock()
+
+ if _, ok := used[port]; ok {
+ lock.Unlock()
+ return RandomPort()
+ }
+
+ used[port] = struct{}{}
+ lock.Unlock()
+ return port
}
@@ -2,7 +2,7 @@ package utils
import (
"fmt"
- "path/filepath"
+ "path"
"strings"
"unicode"
)
@@ -10,7 +10,9 @@ import (
// SanitizeRepo returns a sanitized version of the given repository name.
func SanitizeRepo(repo string) string {
repo = strings.TrimPrefix(repo, "/")
- repo = filepath.Clean(repo)
+ // We're using path instead of filepath here because this is not OS dependent
+ // looking at you Windows
+ repo = path.Clean(repo)
repo = strings.TrimSuffix(repo, ".git")
return repo
}
@@ -0,0 +1,56 @@
+package utils
+
+import "testing"
+
+func TestValidateRepo(t *testing.T) {
+ t.Run("valid", func(t *testing.T) {
+ for _, repo := range []string{
+ "lower",
+ "Upper",
+ "with-dash",
+ "with/slash",
+ "withnumb3r5",
+ "with.dot",
+ "with_underline",
+ } {
+ t.Run(repo, func(t *testing.T) {
+ if err := ValidateRepo(repo); err != nil {
+ t.Errorf("expected no error, got %v", err)
+ }
+ })
+ }
+ })
+ t.Run("invalid", func(t *testing.T) {
+ for _, repo := range []string{
+ "with$",
+ "with@",
+ "with!",
+ } {
+ t.Run(repo, func(t *testing.T) {
+ if err := ValidateRepo(repo); err == nil {
+ t.Error("expected an error, got nil")
+ }
+ })
+ }
+ })
+}
+
+func TestSanitizeRepo(t *testing.T) {
+ cases := []struct {
+ in, out string
+ }{
+ {"lower", "lower"},
+ {"Upper", "Upper"},
+ {"with/slash", "with/slash"},
+ {"with.dot", "with.dot"},
+ {"/with_forward_slash", "with_forward_slash"},
+ {"withgitsuffix.git", "withgitsuffix"},
+ }
+ for _, c := range cases {
+ t.Run(c.in, func(t *testing.T) {
+ if got := SanitizeRepo(c.in); got != c.out {
+ t.Errorf("expected %q, got %q", c.out, got)
+ }
+ })
+ }
+}
@@ -0,0 +1,229 @@
+package testscript
+
+import (
+ "bytes"
+ "context"
+ "flag"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/keygen"
+ "github.com/charmbracelet/soft-serve/server"
+ "github.com/charmbracelet/soft-serve/server/config"
+ "github.com/charmbracelet/soft-serve/server/test"
+ "github.com/rogpeppe/go-internal/testscript"
+ "golang.org/x/crypto/ssh"
+)
+
+var update = flag.Bool("update", false, "update script files")
+
+func TestScript(t *testing.T) {
+ flag.Parse()
+ var lock sync.Mutex
+
+ mkkey := func(name string) (string, *keygen.SSHKeyPair) {
+ path := filepath.Join(t.TempDir(), name)
+ pair, err := keygen.New(path, keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite())
+ if err != nil {
+ t.Fatal(err)
+ }
+ return path, pair
+ }
+
+ key, admin1 := mkkey("admin1")
+ _, admin2 := mkkey("admin2")
+ _, user1 := mkkey("user1")
+
+ testscript.Run(t, testscript.Params{
+ Dir: "./testdata/",
+ UpdateScripts: *update,
+ Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
+ "soft": cmdSoft(admin1.Signer()),
+ "git": cmdGit(key),
+ "mkreadme": cmdMkReadme,
+ "dos2unix": cmdDos2Unix,
+ },
+ Setup: func(e *testscript.Env) error {
+ sshPort := test.RandomPort()
+ e.Setenv("SSH_PORT", fmt.Sprintf("%d", sshPort))
+ e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
+ e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
+ e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
+ e.Setenv("SSH_KNOWN_HOSTS_FILE", filepath.Join(t.TempDir(), "known_hosts"))
+ e.Setenv("SSH_KNOWN_CONFIG_FILE", filepath.Join(t.TempDir(), "config"))
+ data := t.TempDir()
+ cfg := config.Config{
+ Name: "Test Soft Serve",
+ DataPath: data,
+ InitialAdminKeys: []string{admin1.AuthorizedKey()},
+ SSH: config.SSHConfig{
+ ListenAddr: fmt.Sprintf("localhost:%d", sshPort),
+ PublicURL: fmt.Sprintf("ssh://localhost:%d", sshPort),
+ KeyPath: filepath.Join(data, "ssh", "soft_serve_host_ed25519"),
+ ClientKeyPath: filepath.Join(data, "ssh", "soft_serve_client_ed25519"),
+ },
+ Git: config.GitConfig{
+ ListenAddr: fmt.Sprintf("localhost:%d", test.RandomPort()),
+ IdleTimeout: 3,
+ MaxConnections: 32,
+ },
+ HTTP: config.HTTPConfig{
+ ListenAddr: fmt.Sprintf("localhost:%d", test.RandomPort()),
+ PublicURL: fmt.Sprintf("http://localhost:%d", test.RandomPort()),
+ },
+ Stats: config.StatsConfig{
+ ListenAddr: fmt.Sprintf("localhost:%d", test.RandomPort()),
+ },
+ Log: config.LogConfig{
+ Format: "text",
+ TimeFormat: time.DateTime,
+ },
+ }
+ ctx := config.WithContext(context.Background(), &cfg)
+
+ // prevent race condition in lipgloss...
+ // this will probably be autofixed when we start using the colors
+ // from the ssh session instead of the server.
+ // XXX: take another look at this soon
+ lock.Lock()
+ srv, err := server.NewServer(ctx)
+ if err != nil {
+ return err
+ }
+ lock.Unlock()
+
+ go func() {
+ if err := srv.Start(); err != nil {
+ e.T().Fatal(err)
+ }
+ }()
+
+ e.Defer(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ e.T().Fatal(err)
+ }
+ })
+
+ // wait until the server is up
+ for {
+ conn, _ := net.DialTimeout(
+ "tcp",
+ net.JoinHostPort("localhost", fmt.Sprintf("%d", sshPort)),
+ time.Second,
+ )
+ if conn != nil {
+ conn.Close()
+ break
+ }
+ }
+
+ return nil
+ },
+ })
+}
+
+func cmdSoft(key ssh.Signer) func(ts *testscript.TestScript, neg bool, args []string) {
+ return func(ts *testscript.TestScript, neg bool, args []string) {
+ cli, err := ssh.Dial(
+ "tcp",
+ net.JoinHostPort("localhost", ts.Getenv("SSH_PORT")),
+ &ssh.ClientConfig{
+ User: "admin",
+ Auth: []ssh.AuthMethod{ssh.PublicKeys(key)},
+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
+ },
+ )
+ ts.Check(err)
+ defer cli.Close()
+
+ sess, err := cli.NewSession()
+ ts.Check(err)
+ defer sess.Close()
+
+ sess.Stdout = ts.Stdout()
+ sess.Stderr = ts.Stderr()
+
+ check(ts, sess.Run(strings.Join(args, " ")), neg)
+ }
+}
+
+// P.S. Windows sucks!
+func cmdDos2Unix(ts *testscript.TestScript, neg bool, args []string) {
+ if neg {
+ ts.Fatalf("unsupported: ! dos2unix")
+ }
+ if len(args) < 1 {
+ ts.Fatalf("usage: dos2unix paths...")
+ }
+ for _, arg := range args {
+ filename := ts.MkAbs(arg)
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ ts.Fatalf("%s: %v", filename, err)
+ }
+
+ // Replace all '\r\n' with '\n'.
+ data = bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'})
+
+ if err := os.WriteFile(filename, data, 0o644); err != nil {
+ ts.Fatalf("%s: %v", filename, err)
+ }
+ }
+}
+
+var sshConfig = `
+Host *
+ UserKnownHostsFile %q
+ StrictHostKeyChecking no
+ IdentityAgent none
+ IdentitiesOnly yes
+ ServerAliveInterval 60
+`
+
+func cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string) {
+ return func(ts *testscript.TestScript, neg bool, args []string) {
+ ts.Check(os.WriteFile(
+ ts.Getenv("SSH_KNOWN_CONFIG_FILE"),
+ []byte(fmt.Sprintf(sshConfig, ts.Getenv("SSH_KNOWN_HOSTS_FILE"))),
+ 0o600,
+ ))
+ sshArgs := []string{
+ "-F", filepath.ToSlash(ts.Getenv("SSH_KNOWN_CONFIG_FILE")),
+ "-i", filepath.ToSlash(key),
+ }
+ ts.Setenv(
+ "GIT_SSH_COMMAND",
+ strings.Join(append([]string{"ssh"}, sshArgs...), " "),
+ )
+ args = append([]string{
+ "-c", "user.email=john@example.com",
+ "-c", "user.name=John Doe",
+ }, args...)
+ check(ts, ts.Exec("git", args...), neg)
+ }
+}
+
+func cmdMkReadme(ts *testscript.TestScript, neg bool, args []string) {
+ if len(args) != 1 {
+ ts.Fatalf("usage: mkreadme path")
+ }
+ content := []byte("# example\ntest project")
+ check(ts, os.WriteFile(ts.MkAbs(args[0]), content, 0o644), neg)
+}
+
+func check(ts *testscript.TestScript, err error, neg bool) {
+ if neg && err == nil {
+ ts.Fatalf("expected error, got nil")
+ }
+ if !neg {
+ ts.Check(err)
+ }
+}
@@ -0,0 +1,96 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix info1.txt info2.txt tree.txt
+
+# import a repo
+soft repo import --mirror charmbracelet/catwalk https://github.com/charmbracelet/catwalk.git
+
+# check repo info
+soft repo info charmbracelet/catwalk
+cmp stdout info1.txt
+
+# check repo list
+soft repo list
+stdout charmbracelet/catwalk
+
+# is-mirror?
+soft repo is-mirror charmbracelet/catwalk
+stdout true
+
+# set project name
+soft repo project-name charmbracelet/catwalk catwalk
+soft repo list
+stdout catwalk
+
+
+# check description
+soft repo description charmbracelet/catwalk
+! stdout .
+
+# set description
+soft repo description charmbracelet/catwalk "testing repo"
+soft repo description charmbracelet/catwalk
+stdout 'testing repo'
+
+# rename
+soft repo rename charmbracelet/catwalk charmbracelet/test
+soft repo list
+stdout charmbracelet/test # TODO: shouldn't this still show the project-name?
+
+# check its not private
+soft repo private charmbracelet/test
+stdout false
+
+# make it private
+soft repo private charmbracelet/test true
+soft repo private charmbracelet/test
+stdout true
+
+# check its not hidden
+soft repo hidden charmbracelet/test
+stdout false
+
+# make it hidden
+soft repo hidden charmbracelet/test true
+soft repo hidden charmbracelet/test
+stdout true
+
+# print tree
+soft repo tree charmbracelet/test
+cmp stdout tree.txt
+
+# check repo info again
+soft repo info charmbracelet/test
+cmp stdout info2.txt
+
+# get a file
+soft repo blob charmbracelet/test LICENSE
+stdout '.*Creative Commons.*'
+
+
+-- info1.txt --
+Project Name:
+Repository: charmbracelet/catwalk
+Description:
+Private: false
+Hidden: false
+Mirror: true
+Default Branch: main
+Branches:
+ - main
+-- info2.txt --
+Project Name: catwalk
+Repository: charmbracelet/test
+Description: testing repo
+Private: true
+Hidden: true
+Mirror: true
+Default Branch: main
+Branches:
+ - main
+-- tree.txt --
+drwxrwxrwx - 30k
+drwxrwxrwx - 50k
+-rw-r--r-- 19 kB LICENSE
+-rw-r--r-- 1.1 kB README.md
@@ -0,0 +1,18 @@
+# vi: set ft=conf
+# setup
+soft repo import test https://github.com/charmbracelet/catwalk.git
+soft user create foo --key "$USER1_AUTHORIZED_KEY"
+
+# list collabs
+soft repo collab list test
+! stdout .
+
+# add collab
+soft repo collab add test foo
+soft repo collab list test
+stdout 'foo'
+
+# remove collab
+soft repo collab remove test foo
+soft repo collab list test
+! stdout .
@@ -0,0 +1,78 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix tree.txt readme.md branch_list.1.txt
+
+# create a repo
+soft repo create repo1 -d 'description' -H -p -n 'repo11'
+soft repo hidden repo1
+stdout true
+soft repo private repo1
+stdout true
+soft repo description repo1
+stdout 'description'
+
+# clone repo
+git clone ssh://localhost:$SSH_PORT/repo1 repo1
+
+# create some files, commits, tags...
+mkreadme ./repo1/README.md
+git -C repo1 add -A
+git -C repo1 commit -m 'first'
+git -C repo1 tag v0.1.0
+git -C repo1 push origin HEAD
+git -C repo1 push origin HEAD --tags
+
+# list tags
+soft repo tag list repo1
+stdout 'v0.1.0'
+
+# delete tag
+soft repo tag delete repo1 v0.1.0
+soft repo tag list repo1
+! stdout .
+
+# print tree
+soft repo tree repo1
+cmp stdout tree.txt
+
+# cat blob
+soft repo blob repo1 README.md
+cmp stdout readme.md
+
+# cat blob that doesn't exist
+! soft repo blob repo1 README.txt
+! stdout .
+stderr '.*revision does not exist.*'
+
+# check main branch
+soft repo branch default repo1
+stdout master
+
+# create a new branch
+git -C repo1 checkout -b branch1
+git -C repo1 push origin branch1
+soft repo branch list repo1
+cmp stdout branch_list.1.txt
+
+# change default branch
+soft repo branch default repo1 branch1
+soft repo branch default repo1
+stdout branch1
+
+# cannot delete main branch
+! soft repo branch delete repo1 branch1
+
+# delete other branch
+soft repo branch delete repo1 master
+soft repo branch list repo1
+stdout branch1
+
+-- tree.txt --
+-rw-r--r-- 22 B README.md
+-- readme.md --
+# example
+test project
+-- branch_list.1.txt --
+branch1
+master
@@ -0,0 +1,30 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix repo3.txt
+
+# import private
+soft repo import --private repo1 https://github.com/charmbracelet/catwalk.git
+soft repo private repo1
+stdout 'true'
+
+# import hidden
+soft repo import --hidden repo2 https://github.com/charmbracelet/catwalk.git
+soft repo hidden repo2
+stdout 'true'
+
+# import with name and description
+soft repo import --name 'repo33' --description 'descriptive' repo3 https://github.com/charmbracelet/catwalk.git
+soft repo info repo3
+cmp stdout repo3.txt
+
+-- repo3.txt --
+Project Name: repo33
+Repository: repo3
+Description: descriptive
+Private: false
+Hidden: false
+Mirror: false
+Default Branch: main
+Branches:
+ - main
@@ -0,0 +1,24 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix info1.txt info2.txt
+
+# get original username
+soft info
+cmpenv stdout info1.txt
+
+# set another username
+soft set-username test
+soft info
+cmpenv stdout info2.txt
+
+-- info1.txt --
+Username: admin
+Admin: true
+Public keys:
+ $ADMIN1_AUTHORIZED_KEY
+-- info2.txt --
+Username: test
+Admin: true
+Public keys:
+ $ADMIN1_AUTHORIZED_KEY
@@ -0,0 +1,31 @@
+# vi: set ft=conf
+# check default allow-keyless
+soft settings allow-keyless true
+soft settings allow-keyless
+stdout 'true.*'
+
+# change allow-keyless and check
+soft settings allow-keyless false
+soft settings allow-keyless
+stdout 'false.*'
+
+# check default anon-access
+soft settings anon-access
+stdout 'read-only.*'
+
+# chaneg anon-access to all available options, and check them
+soft settings anon-access no-access
+soft settings anon-access
+stdout 'no-access.*'
+
+soft settings anon-access read-only
+soft settings anon-access
+stdout 'read-only.*'
+
+soft settings anon-access read-write
+soft settings anon-access
+stdout 'read-write.*'
+
+soft settings anon-access admin-access
+soft settings anon-access
+stdout 'admin-access.*'
@@ -0,0 +1,105 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt
+
+# add key to admin
+soft user add-pubkey admin "$ADMIN2_AUTHORIZED_KEY"
+soft user info admin
+soft info
+cmpenv stdout info.txt
+
+
+# list admin pubkeys
+soft pubkey list
+cmpenv stdout admin_key_list1.txt
+
+# remove key
+soft pubkey remove $ADMIN2_AUTHORIZED_KEY
+soft pubkey list
+cmpenv stdout admin_key_list2.txt
+
+# add key back key
+soft pubkey add $ADMIN2_AUTHORIZED_KEY
+soft pubkey list
+cmpenv stdout admin_key_list1.txt
+
+# list users
+soft user list
+cmpenv stdout list1.txt
+
+# create a new user
+soft user create foo --key "$USER1_AUTHORIZED_KEY"
+soft user list
+cmpenv stdout list2.txt
+
+# get new user info
+soft user info foo
+cmpenv stdout foo_info1.txt
+
+# make user admin
+soft user set-admin foo true
+soft user info foo
+cmpenv stdout foo_info2.txt
+
+# remove admin
+soft user set-admin foo false
+soft user info foo
+cmpenv stdout foo_info3.txt
+
+# remove key from user
+soft user remove-pubkey foo "$USER1_AUTHORIZED_KEY"
+soft user info foo
+cmpenv stdout foo_info4.txt
+
+# rename user
+soft user set-username foo foo2
+soft user info foo2
+cmpenv stdout foo_info5.txt
+
+# remove user
+soft user delete foo2
+! stdout .
+soft user list
+cmpenv stdout list1.txt
+
+
+-- info.txt --
+Username: admin
+Admin: true
+Public keys:
+ $ADMIN1_AUTHORIZED_KEY
+ $ADMIN2_AUTHORIZED_KEY
+-- list1.txt --
+admin
+-- list2.txt --
+admin
+foo
+-- foo_info1.txt --
+Username: foo
+Admin: false
+Public keys:
+ $USER1_AUTHORIZED_KEY
+-- foo_info2.txt --
+Username: foo
+Admin: true
+Public keys:
+ $USER1_AUTHORIZED_KEY
+-- foo_info3.txt --
+Username: foo
+Admin: false
+Public keys:
+ $USER1_AUTHORIZED_KEY
+-- foo_info4.txt --
+Username: foo
+Admin: false
+Public keys:
+-- foo_info5.txt --
+Username: foo2
+Admin: false
+Public keys:
+-- admin_key_list1.txt --
+$ADMIN1_AUTHORIZED_KEY
+$ADMIN2_AUTHORIZED_KEY
+-- admin_key_list2.txt --
+$ADMIN1_AUTHORIZED_KEY