diff --git a/cmd/up.go b/cmd/up.go index 108aa95fd..b30f248fc 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -397,7 +397,10 @@ func (cmd *UpCmd) configureWorkspace( // Run after dotfiles so the signing config isn't overwritten by a // dotfiles installer that replaces .gitconfig. - if cmd.GitSSHSigningKey != "" { + gitSSHSignatureEnabled := devPodConfig.ContextOption( + config.ContextOptionGitSSHSignatureForwarding, + ) == "true" + if cmd.GitSSHSigningKey != "" && gitSSHSignatureEnabled { if err := setupGitSSHSignature(cmd.GitSSHSigningKey, client); err != nil { return err } diff --git a/e2e/tests/ssh/ssh.go b/e2e/tests/ssh/ssh.go index 11cbee40b..6fd728cf7 100644 --- a/e2e/tests/ssh/ssh.go +++ b/e2e/tests/ssh/ssh.go @@ -17,6 +17,8 @@ import ( "github.com/skevetter/devpod/e2e/framework" ) +const osWindows = "windows" + var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ordered, func() { var initialDir string @@ -59,7 +61,7 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord // ginkgo.It("should start a new workspace with a docker provider (default) and forward gpg agent into it", func() { // // skip windows for now - // if runtime.GOOS == "windows" { + // if runtime.GOOS == osWindows { // return // } // @@ -109,7 +111,7 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord "should set up git SSH signature helper and sign a commit", ginkgo.SpecTimeout(7*time.Minute), func(ctx ginkgo.SpecContext) { - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { ginkgo.Skip("skipping on windows") } @@ -253,10 +255,132 @@ var _ = ginkgo.Describe("devpod ssh test suite", ginkgo.Label("ssh"), ginkgo.Ord }, ) + ginkgo.It( + "should not install git SSH signature helper when signing key is not provided", + ginkgo.SpecTimeout(5*time.Minute), + func(ctx ginkgo.SpecContext) { + if runtime.GOOS == osWindows { + ginkgo.Skip("skipping on windows") + } + + tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ssh-signing") + framework.ExpectNoError(err) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _ = f.DevPodProviderAdd(ctx, "docker") + err = f.DevPodProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevPodWorkspaceDelete(cleanupCtx, tempDir) + framework.CleanupTempDir(initialDir, tempDir) + }) + + // Start workspace WITHOUT --git-ssh-signing-key + err = f.DevPodUp(ctx, tempDir) + framework.ExpectNoError(err) + + // Verify the helper script was NOT installed + out, err := f.DevPodSSH(ctx, tempDir, + "test -x /usr/local/bin/devpod-ssh-signature && echo EXISTS || echo MISSING", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To( + gomega.Equal("MISSING"), + "devpod-ssh-signature helper should not be installed without --git-ssh-signing-key", + ) + + // Verify git config was NOT set for SSH signing + out, err = f.DevPodSSH(ctx, tempDir, + "git config --global gpg.ssh.program || echo UNSET", + ) + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To( + gomega.Equal("UNSET"), + "gpg.ssh.program should not be configured without --git-ssh-signing-key", + ) + }, + ) + + ginkgo.It( + "should surface clear error when SSH signing fails", + ginkgo.SpecTimeout(7*time.Minute), + func(ctx ginkgo.SpecContext) { + if runtime.GOOS == osWindows { + ginkgo.Skip("skipping on windows") + } + + tempDir, err := framework.CopyToTempDir("tests/ssh/testdata/ssh-signing") + framework.ExpectNoError(err) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _ = f.DevPodProviderAdd(ctx, "docker") + err = f.DevPodProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevPodWorkspaceDelete(cleanupCtx, tempDir) + framework.CleanupTempDir(initialDir, tempDir) + }) + + // Generate a key but do NOT add it to the ssh-agent so signing will fail + sshKeyDir, err := os.MkdirTemp("", "devpod-ssh-signing-err-test") + framework.ExpectNoError(err) + defer func() { _ = os.RemoveAll(sshKeyDir) }() + + keyPath := filepath.Join(sshKeyDir, "id_ed25519") + // #nosec G204 -- test command with controlled arguments + err = exec.Command( + "ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q", + ).Run() + framework.ExpectNoError(err) + + // Start workspace with signing key + err = f.DevPodUp(ctx, tempDir, "--git-ssh-signing-key", keyPath+".pub") + framework.ExpectNoError(err) + + // Attempt a signed commit — this should fail because the key + // is not in the agent, but the error must be human-readable. + commitCmd := strings.Join([]string{ + "cd /tmp", + "git init test-sign-err-repo", + "cd test-sign-err-repo", + "git config user.name 'Test User'", + "git config user.email 'test@example.com'", + "git config commit.gpgsign true", + "echo test > testfile", + "git add testfile", + "git commit -m 'signed test commit' 2>&1", + }, " && ") + + stdout, stderr, err := f.ExecCommandCapture(ctx, []string{ + "ssh", + "--agent-forwarding", + "--start-services", + tempDir, + "--command", commitCmd, + }) + ginkgo.GinkgoWriter.Printf("error commit stdout: %s\n", stdout) + ginkgo.GinkgoWriter.Printf("error commit stderr: %s\n", stderr) + + // The commit should fail + combined := stdout + stderr + if err != nil { + combined += err.Error() + } + + // The error must NOT contain JSON decode artifacts + gomega.Expect(combined).NotTo( + gomega.ContainSubstring("invalid character"), + "error should not contain JSON parse errors — error messages must be human-readable", + ) + }, + ) + ginkgo.It( "should start a new workspace with a docker provider (default) and forward a port into it", func(ctx context.Context) { - if runtime.GOOS == "windows" { + if runtime.GOOS == osWindows { ginkgo.Skip("skipping on windows") } diff --git a/pkg/credentials/integration_test.go b/pkg/credentials/integration_test.go new file mode 100644 index 000000000..0c37a05cf --- /dev/null +++ b/pkg/credentials/integration_test.go @@ -0,0 +1,94 @@ +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/skevetter/devpod/pkg/agent/tunnel" + "github.com/skevetter/devpod/pkg/gitsshsigning" + "github.com/skevetter/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIntegration_SigningFailure_SurfacesServerError(t *testing.T) { + mock := &mockTunnelClient{ + gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { + return nil, fmt.Errorf( + "failed to sign commit: exit status 1, stderr: Permission denied (publickey)", + ) + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := handleGitSSHSignatureRequest(context.Background(), w, r, mock, log.Discard) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + defer server.Close() + + gitsshsigning.SetSignatureServerURL(server.URL + "/git-ssh-signature") + t.Cleanup(func() { gitsshsigning.SetSignatureServerURL("") }) + + tmpDir := t.TempDir() + bufferFile := filepath.Join(tmpDir, "buffer") + require.NoError(t, os.WriteFile(bufferFile, []byte("commit content"), 0o600)) + + err := gitsshsigning.HandleGitSSHProgramCall("/tmp/key.pub", "git", bufferFile, log.Discard) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "Permission denied") + assert.NotContains(t, err.Error(), "invalid character") + + _, statErr := os.Stat(bufferFile + ".sig") + assert.True(t, os.IsNotExist(statErr), "expected no .sig file to be created") +} + +func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) { + expectedSig := []byte( + "-----BEGIN SSH SIGNATURE-----\ntest-signature\n-----END SSH SIGNATURE-----\n", + ) + + mock := &mockTunnelClient{ + gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { + response := gitsshsigning.GitSSHSignatureResponse{Signature: expectedSig} + jsonBytes, err := json.Marshal(response) + if err != nil { + return nil, err + } + return &tunnel.Message{Message: string(jsonBytes)}, nil + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := handleGitSSHSignatureRequest(context.Background(), w, r, mock, log.Discard) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) + defer server.Close() + + gitsshsigning.SetSignatureServerURL(server.URL + "/git-ssh-signature") + t.Cleanup(func() { gitsshsigning.SetSignatureServerURL("") }) + + tmpDir := t.TempDir() + bufferFile := filepath.Join(tmpDir, "buffer") + require.NoError(t, os.WriteFile(bufferFile, []byte("commit content"), 0o600)) + + err := gitsshsigning.HandleGitSSHProgramCall("/tmp/key.pub", "git", bufferFile, log.Discard) + + require.NoError(t, err) + + sigContent, readErr := os.ReadFile( + bufferFile + ".sig", + ) // #nosec G304 -- test file path from t.TempDir + require.NoError(t, readErr) + assert.Equal(t, expectedSig, sigContent) +} diff --git a/pkg/credentials/mock_tunnel_client_test.go b/pkg/credentials/mock_tunnel_client_test.go new file mode 100644 index 000000000..652fb632e --- /dev/null +++ b/pkg/credentials/mock_tunnel_client_test.go @@ -0,0 +1,133 @@ +package credentials + +import ( + "context" + "fmt" + + "github.com/skevetter/devpod/pkg/agent/tunnel" + "google.golang.org/grpc" +) + +type mockTunnelClient struct { + gitSSHSignatureFunc func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) +} + +func (m *mockTunnelClient) Ping( + ctx context.Context, + in *tunnel.Empty, + opts ...grpc.CallOption, +) (*tunnel.Empty, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) Log( + ctx context.Context, + in *tunnel.LogMessage, + opts ...grpc.CallOption, +) (*tunnel.Empty, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) SendResult( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Empty, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) DockerCredentials( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) GitCredentials( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) GitSSHSignature( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return m.gitSSHSignatureFunc(ctx, in) +} + +func (m *mockTunnelClient) GitUser( + ctx context.Context, + in *tunnel.Empty, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) LoftConfig( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) GPGPublicKeys( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) KubeConfig( + ctx context.Context, + in *tunnel.Message, + opts ...grpc.CallOption, +) (*tunnel.Message, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) ForwardPort( + ctx context.Context, + in *tunnel.ForwardPortRequest, + opts ...grpc.CallOption, +) (*tunnel.ForwardPortResponse, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) StopForwardPort( + ctx context.Context, + in *tunnel.StopForwardPortRequest, + opts ...grpc.CallOption, +) (*tunnel.StopForwardPortResponse, error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) StreamGitClone( + ctx context.Context, + in *tunnel.Empty, + opts ...grpc.CallOption, +) (grpc.ServerStreamingClient[tunnel.Chunk], error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) StreamWorkspace( + ctx context.Context, + in *tunnel.Empty, + opts ...grpc.CallOption, +) (grpc.ServerStreamingClient[tunnel.Chunk], error) { + return nil, fmt.Errorf("not implemented") +} + +func (m *mockTunnelClient) StreamMount( + ctx context.Context, + in *tunnel.StreamMountRequest, + opts ...grpc.CallOption, +) (grpc.ServerStreamingClient[tunnel.Chunk], error) { + return nil, fmt.Errorf("not implemented") +} diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index 607eb50f3..2dfb17d8b 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -3,6 +3,7 @@ package credentials import ( "cmp" "context" + "encoding/json" "fmt" "io" "net" @@ -162,7 +163,11 @@ func handleGitSSHSignatureRequest( response, err := client.GitSSHSignature(ctx, &tunnel.Message{Message: string(out)}) if err != nil { log.WithFields(logrus.Fields{"error": err}).Error("error receiving git SSH signature") - return fmt.Errorf("get git ssh signature: %w", err) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + errJSON, _ := json.Marshal(map[string]string{"error": err.Error()}) + _, _ = writer.Write(errJSON) + return nil // error already written to response } writer.Header().Set("Content-Type", "application/json") diff --git a/pkg/credentials/server_test.go b/pkg/credentials/server_test.go new file mode 100644 index 000000000..70beb8cdf --- /dev/null +++ b/pkg/credentials/server_test.go @@ -0,0 +1,71 @@ +package credentials + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/skevetter/devpod/pkg/agent/tunnel" + "github.com/skevetter/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandleGitSSHSignature_GRPCError_ReturnsJSON500(t *testing.T) { + mock := &mockTunnelClient{ + gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { + return nil, fmt.Errorf("Permission denied") + }, + } + + req := httptest.NewRequest( + http.MethodPost, + "/git-ssh-signature", + strings.NewReader("test payload"), + ) + w := httptest.NewRecorder() + + err := handleGitSSHSignatureRequest(context.Background(), w, req, mock, log.Discard) + require.NoError(t, err) + + resp := w.Result() + assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + var body map[string]string + err = json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Contains(t, body["error"], "Permission denied") +} + +func TestHandleGitSSHSignature_GRPCSuccess_ReturnsJSON200(t *testing.T) { + expectedMessage := `{"signature":"abc123"}` + mock := &mockTunnelClient{ + gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { + return &tunnel.Message{Message: expectedMessage}, nil + }, + } + + req := httptest.NewRequest( + http.MethodPost, + "/git-ssh-signature", + strings.NewReader("test payload"), + ) + w := httptest.NewRecorder() + + err := handleGitSSHSignatureRequest(context.Background(), w, req, mock, log.Discard) + require.NoError(t, err) + + resp := w.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + var body map[string]string + err = json.NewDecoder(resp.Body).Decode(&body) + require.NoError(t, err) + assert.Equal(t, "abc123", body["signature"]) +} diff --git a/pkg/gitsshsigning/client.go b/pkg/gitsshsigning/client.go index 487ab79bd..98b1e73a6 100644 --- a/pkg/gitsshsigning/client.go +++ b/pkg/gitsshsigning/client.go @@ -3,15 +3,32 @@ package gitsshsigning import ( "bytes" "encoding/json" + "fmt" "io" + "net/http" "os" "strconv" + "strings" - "github.com/skevetter/devpod/pkg/credentials" + "github.com/skevetter/devpod/pkg/config" devpodhttp "github.com/skevetter/devpod/pkg/http" "github.com/skevetter/log" ) +const defaultCredentialsServerPort = "12049" + +func getCredentialsPort() (int, error) { + strPort := os.Getenv(config.EnvCredentialsServerPort) + if strPort == "" { + strPort = defaultCredentialsServerPort + } + port, err := strconv.Atoi(strPort) + if err != nil { + return 0, fmt.Errorf("convert port %s: %w", strPort, err) + } + return port, nil +} + // HandleGitSSHProgramCall implements logic handling call from git when signing a commit. func HandleGitSSHProgramCall(certPath, namespace, bufferFile string, log log.Logger) error { content, err := extractContentFromGitBuffer(bufferFile) @@ -56,7 +73,7 @@ func writeSignatureToFile(signature []byte, bufferFile string, log log.Logger) e sigFile := bufferFile + ".sig" // #nosec G306 -- TODO Consider using a more secure permission setting and ownership if needed. if err := os.WriteFile(sigFile, signature, 0o644); err != nil { - log.Errorf("Failed to write signature to file: %w", err) + log.Errorf("Failed to write signature to file: %v", err) return err } return nil @@ -70,32 +87,67 @@ func createSignatureRequestBody(content []byte, certPath string) ([]byte, error) return json.Marshal(request) } +// signatureServerURL overrides the server URL for testing. Empty means use credentials.GetPort(). +var signatureServerURL string + +// SetSignatureServerURL sets the server URL override for testing. +func SetSignatureServerURL(url string) { + signatureServerURL = url +} + +func getSignatureURL() (string, error) { + if signatureServerURL != "" { + return signatureServerURL, nil + } + port, err := getCredentialsPort() + if err != nil { + return "", err + } + return "http://localhost:" + strconv.Itoa(port) + "/git-ssh-signature", nil +} + func sendSignatureRequest(requestBody []byte, log log.Logger) ([]byte, error) { - port, err := credentials.GetPort() + url, err := getSignatureURL() if err != nil { return nil, err } response, err := devpodhttp.GetHTTPClient().Post( - "http://localhost:"+strconv.Itoa(port)+ - "/git-ssh-signature", // TODO: build the url, don't hardcode localhost + url, "application/json", bytes.NewReader(requestBody), ) if err != nil { - log.Errorf("Error retrieving git ssh signature: %w", err) + log.Errorf("Error retrieving git ssh signature: %v", err) return nil, err } defer func() { _ = response.Body.Close() }() - return io.ReadAll(response.Body) + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("reading signature response: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "signature server returned %d: %s", + response.StatusCode, + strings.TrimSpace(string(body)), + ) + } + + return body, nil } func parseSignatureResponse(responseBody []byte, log log.Logger) ([]byte, error) { signatureResponse := &GitSSHSignatureResponse{} if err := json.Unmarshal(responseBody, signatureResponse); err != nil { - log.Errorf("Error decoding git ssh signature: %w", err) - return nil, err + log.Errorf("Error decoding git ssh signature: %v", err) + return nil, fmt.Errorf( + "error decoding signature response (body: %s): %w", + string(responseBody), + err, + ) } return signatureResponse.Signature, nil diff --git a/pkg/gitsshsigning/client_test.go b/pkg/gitsshsigning/client_test.go new file mode 100644 index 000000000..c12404338 --- /dev/null +++ b/pkg/gitsshsigning/client_test.go @@ -0,0 +1,98 @@ +package gitsshsigning + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/skevetter/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSignatureResponse_NonJSONBody(t *testing.T) { + body := []byte("get git ssh signature: permission denied") + _, err := parseSignatureResponse(body, log.Discard) + require.Error(t, err) + assert.Contains(t, err.Error(), "get git ssh signature: permission denied") +} + +func TestParseSignatureResponse_ValidJSON(t *testing.T) { + sig := []byte("ssh-sig-data") + response := &GitSSHSignatureResponse{Signature: sig} + body, err := json.Marshal(response) + require.NoError(t, err) + + result, err := parseSignatureResponse(body, log.Discard) + require.NoError(t, err) + assert.Equal(t, sig, result) +} + +func TestRequestContentSignature_ServerError_PlainText(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error( + w, + "get git ssh signature: failed to sign commit: exit status 1", + http.StatusInternalServerError, + ) + })) + defer server.Close() + + signatureServerURL = server.URL + "/git-ssh-signature" + t.Cleanup(func() { signatureServerURL = "" }) + + _, err := requestContentSignature([]byte("commit content"), "/tmp/key.pub", log.Discard) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to sign commit") + assert.NotContains(t, err.Error(), "invalid character") +} + +func TestRequestContentSignature_ServerError_JSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "signing failed"}) + })) + defer server.Close() + + signatureServerURL = server.URL + "/git-ssh-signature" + t.Cleanup(func() { signatureServerURL = "" }) + + _, err := requestContentSignature([]byte("commit content"), "/tmp/key.pub", log.Discard) + require.Error(t, err) + assert.Contains(t, err.Error(), "signing failed") +} + +func TestRequestContentSignature_Success(t *testing.T) { + sig := []byte("-----BEGIN SSH SIGNATURE-----\ntest\n-----END SSH SIGNATURE-----") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(&GitSSHSignatureResponse{Signature: sig}) + })) + defer server.Close() + + signatureServerURL = server.URL + "/git-ssh-signature" + t.Cleanup(func() { signatureServerURL = "" }) + + result, err := requestContentSignature([]byte("commit content"), "/tmp/key.pub", log.Discard) + require.NoError(t, err) + assert.Equal(t, sig, result) + assert.Contains(t, string(result), "BEGIN SSH SIGNATURE") +} + +func TestRequestContentSignature_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not json at all")) + })) + defer server.Close() + + signatureServerURL = server.URL + "/git-ssh-signature" + t.Cleanup(func() { signatureServerURL = "" }) + + _, err := requestContentSignature([]byte("commit content"), "/tmp/key.pub", log.Discard) + require.Error(t, err) + assert.Contains(t, err.Error(), "not json at all") +}