Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ make download-php

Tests use github.com/stretchr/testify for assertions. Table-driven tests are preferred with a "cases" slice containing simple test case structs.

Integration tests (`integration-tests/`) call `t.Parallel()`, so they must not share state: no `t.Setenv` or unsynchronized package variables, and use `cmdFactory.useConfig` to change the CLI config file. Write output with `t.Log` (e.g. `mockapi.RequestLogger`), not to stdout or stderr: it is then attributed to its test, and shown only on failure or with `-v`. Tests of environment operations use `setUpProject`, and tests of Git commands (push, clone, fetch) use `setUpGitProject`, which also serves a bare repository over the mock SSH server. Tests of other SSH commands use `serveSSH` with an app from `sshEnv`, and fake remote executables from `remoteEnv`.
Integration tests (`integration-tests/`) call `t.Parallel()`, so they must not share state: no `t.Setenv` or unsynchronized package variables, and use `cmdFactory.useConfig` to change the CLI config file. Write output with `t.Log` (e.g. `mockapi.RequestLogger`), not to stdout or stderr: it is then attributed to its test, and shown only on failure or with `-v`. Tests of environment operations use `setUpProject`, and tests of Git commands (push, clone, fetch) use `setUpGitProject`, which also serves a bare repository over the mock SSH server. Tests of other SSH commands use `serveSSH` with an app from `sshEnv`, and fake remote executables from `remoteEnv`. The mock SSH server can also serve SFTP (`SFTPDir`) and forward connections (`DialTCP`, `ListenUnix`); tests of commands that start background processes (tunnels) must make sure they exit.

### Configuration

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/godbus/dbus/v5 v5.2.3
github.com/gofrs/flock v0.13.1
github.com/oklog/ulid/v2 v2.1.2
github.com/pkg/sftp v1.13.11
github.com/platformsh/platformify v0.5.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down Expand Up @@ -91,6 +92,7 @@ require (
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
Expand Down Expand Up @@ -226,6 +228,8 @@ github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA=
github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/platformsh/platformify v0.5.0 h1:i9cRbM0K1f14Yr0dJ6dhYq9BcmZb8fsx/mpYSnw8ssA=
github.com/platformsh/platformify v0.5.0/go.mod h1:fgmCcfQfHbhe1oXsIdIhpnniyZu8IdIMOlcBAa/ygic=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down
53 changes: 53 additions & 0 deletions integration-tests/environment_scp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package tests

import (
"os/exec"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/upsun/cli/pkg/mockapi"
"github.com/upsun/cli/pkg/mockssh"
)

func TestEnvironmentScp(t *testing.T) {
t.Parallel()
if _, err := exec.LookPath("scp"); err != nil {
t.Skip("scp is not installed")
}
remoteDir := t.TempDir()
writeFiles(t, remoteDir, map[string]string{"remote.txt": "remote", "logs/a.log": "a", "logs/b.log": "b"})

p := setUpProject(t, sshEnv(&mockapi.Deployment{
WebApps: map[string]mockapi.App{
"app": {Name: "app", Type: "golang:1.23", Size: "M", Mounts: map[string]mockapi.Mount{}},
},
}))
// Older scp clients run "scp -t" or "scp -f" instead of using SFTP.
sshServer := p.serveSSH(t, mockssh.ExecHandler(remoteDir, nil))
sshServer.SFTPDir = remoteDir
f := p.f
f.dir = t.TempDir()
writeFiles(t, f.dir, map[string]string{"local.txt": "local"})
args := []string{"scp", "-p", layoutProjectID, "-e", "main"}

f.Run(append(args, "--", "local.txt", "remote:uploaded.txt")...)
assert.Equal(t, "local", readFile(t, filepath.Join(remoteDir, "uploaded.txt")))

f.Run(append(args, "--", "remote:remote.txt", "downloaded.txt")...)
assert.Equal(t, "remote", readFile(t, filepath.Join(f.dir, "downloaded.txt")))

f.Run(append(args, "-r", "--", "remote:logs", "logs")...)
assert.Equal(t, "a", readFile(t, filepath.Join(f.dir, "logs", "a.log")))
assert.Equal(t, "b", readFile(t, filepath.Join(f.dir, "logs", "b.log")))

_, stdErr, err := f.RunCombinedOutput(append(args, "--", "remote:missing.txt", ".")...)
require.Error(t, err)
assert.Contains(t, stdErr, "missing.txt")

_, stdErr, err = f.RunCombinedOutput(append(args, "--", "local.txt", "other.txt")...)
require.Error(t, err)
assert.Contains(t, stdErr, `At least one argument needs to contain the "remote:" prefix`)
}
95 changes: 95 additions & 0 deletions integration-tests/environment_xdebug_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package tests

import (
"io"
"net"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -121,3 +128,91 @@ func TestEnvironmentXdebug(t *testing.T) {
mu.Unlock()
}
}

func TestEnvironmentXdebugTunnel(t *testing.T) {
t.Parallel()
p := setUpProject(t, sshEnv(&mockapi.Deployment{
WebApps: map[string]mockapi.App{
"app": {Name: "app", Type: "php:8.4", Size: "M", Mounts: map[string]mockapi.Mount{},
Runtime: map[string]any{"xdebug": map[string]any{"idekey": "secret_key"}}},
},
}))
sshServer := p.serveSSH(t, func(_ ssh.ConnMetadata, _ string, _ mockssh.CommandIO) int { return 0 })

// The server listens at a local socket instead of /run/xdebug-tunnel.sock. It is
// in /tmp, as socket paths are limited to about 100 characters.
socketDir, err := os.MkdirTemp("/tmp", "xdebug")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(socketDir) })
socket := filepath.Join(socketDir, "xdebug.sock")
var requestedPath atomic.Value
sshServer.ListenUnix = func(path string) (net.Listener, error) {
requestedPath.Store(path)
return net.Listen("unix", socket)
}

// The IDE listens locally.
ide, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ide.Close() })
port := tcpPort(t, ide)
received := make(chan string, 1)
go func() {
c, err := ide.Accept()
if err != nil {
return
}
defer c.Close()
b, _ := io.ReadAll(c)
received <- string(b)
}()

cmd := p.f.buildCommand("xdebug", "-p", layoutProjectID, "-e", "main", "--port", strconv.Itoa(port))
var stdErr syncBuffer
cmd.Stderr = &stdErr
require.NoError(t, cmd.Start())
var waitErr error
exited := make(chan struct{})
go func() {
waitErr = cmd.Wait()
close(exited)
}()
t.Cleanup(func() {
select {
case <-exited:
case <-time.After(10 * time.Second):
_ = cmd.Process.Kill()
t.Error("xdebug did not exit")
}
p.f.logStderr(stdErr.String())
})

// A connection from the app (Xdebug) to the socket reaches the IDE.
var conn net.Conn
require.Eventually(t, func() bool {
conn, err = net.Dial("unix", socket)
return err == nil
}, 10*time.Second, 50*time.Millisecond)
_, err = io.WriteString(conn, "<init idekey=\"secret_key\"/>")
require.NoError(t, err)
require.NoError(t, conn.Close())
select {
case got := <-received:
assert.Equal(t, "<init idekey=\"secret_key\"/>", got)
case <-time.After(10 * time.Second):
t.Fatal("the IDE did not receive a connection")
}
assert.Equal(t, "/run/xdebug-tunnel.sock", requestedPath.Load())
require.Eventually(t, func() bool {
return strings.Contains(stdErr.String(), "Xdebug tunnel opened at: 127.0.0.1:"+strconv.Itoa(port))
}, 10*time.Second, 50*time.Millisecond)

// The tunnel closes with the SSH connection.
require.NoError(t, sshServer.Stop())
select {
case <-exited:
case <-time.After(10 * time.Second):
t.Fatal("xdebug did not exit after the SSH connection closed")
}
assertExitCode(t, 255, waitErr)
}
18 changes: 18 additions & 0 deletions integration-tests/tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,21 @@ func waitForServer(t *testing.T, client *http.Client, url string) *http.Response
}, 10*time.Second, 50*time.Millisecond, "server did not respond: %s", url)
return resp
}

// syncBuffer is a buffer that is safe for concurrent use, e.g. to read a running command's output.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}

func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}

func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
Loading
Loading