github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/login_test.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package command
     5  
     6  import (
     7  	"context"
     8  	"net/http/httptest"
     9  	"path/filepath"
    10  	"strings"
    11  	"testing"
    12  
    13  	"github.com/mitchellh/cli"
    14  
    15  	svchost "github.com/hashicorp/terraform-svchost"
    16  	"github.com/hashicorp/terraform-svchost/disco"
    17  	"github.com/terramate-io/tf/command/cliconfig"
    18  	oauthserver "github.com/terramate-io/tf/command/testdata/login-oauth-server"
    19  	tfeserver "github.com/terramate-io/tf/command/testdata/login-tfe-server"
    20  	"github.com/terramate-io/tf/command/webbrowser"
    21  	"github.com/terramate-io/tf/httpclient"
    22  	"github.com/terramate-io/tf/version"
    23  )
    24  
    25  func TestLogin(t *testing.T) {
    26  	// oauthserver.Handler is a stub OAuth server implementation that will,
    27  	// on success, always issue a bearer token named "good-token".
    28  	s := httptest.NewServer(oauthserver.Handler)
    29  	defer s.Close()
    30  
    31  	// tfeserver.Handler is a stub TFE API implementation which will respond
    32  	// to ping and current account requests, when requests are authenticated
    33  	// with token "good-token"
    34  	ts := httptest.NewServer(tfeserver.Handler)
    35  	defer ts.Close()
    36  
    37  	loginTestCase := func(test func(t *testing.T, c *LoginCommand, ui *cli.MockUi)) func(t *testing.T) {
    38  		return func(t *testing.T) {
    39  			t.Helper()
    40  			workDir := t.TempDir()
    41  
    42  			// We'll use this context to avoid asynchronous tasks outliving
    43  			// a single test run.
    44  			ctx, cancel := context.WithCancel(context.Background())
    45  			defer cancel()
    46  
    47  			// Do not use the NewMockUi initializer here, as we want to delay
    48  			// the call to init until after setting up the input mocks
    49  			ui := new(cli.MockUi)
    50  
    51  			browserLauncher := webbrowser.NewMockLauncher(ctx)
    52  			creds := cliconfig.EmptyCredentialsSourceForTests(filepath.Join(workDir, "credentials.tfrc.json"))
    53  			svcs := disco.NewWithCredentialsSource(creds)
    54  			svcs.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
    55  
    56  			svcs.ForceHostServices(svchost.Hostname("example.com"), map[string]interface{}{
    57  				"login.v1": map[string]interface{}{
    58  					// For this fake hostname we'll use a conventional OAuth flow,
    59  					// with browser-based consent that we'll mock away using a
    60  					// mock browser launcher below.
    61  					"client": "anything-goes",
    62  					"authz":  s.URL + "/authz",
    63  					"token":  s.URL + "/token",
    64  				},
    65  			})
    66  			svcs.ForceHostServices(svchost.Hostname("with-scopes.example.com"), map[string]interface{}{
    67  				"login.v1": map[string]interface{}{
    68  					// with scopes
    69  					// mock browser launcher below.
    70  					"client": "scopes_test",
    71  					"authz":  s.URL + "/authz",
    72  					"token":  s.URL + "/token",
    73  					"scopes": []interface{}{"app1.full_access", "app2.read_only"},
    74  				},
    75  			})
    76  			svcs.ForceHostServices(svchost.Hostname("app.terraform.io"), map[string]interface{}{
    77  				// This represents Terraform Cloud, which does not yet support the
    78  				// login API, but does support its own bespoke tokens API.
    79  				"tfe.v2":   ts.URL + "/api/v2",
    80  				"tfe.v2.1": ts.URL + "/api/v2",
    81  				"tfe.v2.2": ts.URL + "/api/v2",
    82  				"motd.v1":  ts.URL + "/api/terraform/motd",
    83  			})
    84  			svcs.ForceHostServices(svchost.Hostname("tfe.acme.com"), map[string]interface{}{
    85  				// This represents a Terraform Enterprise instance which does not
    86  				// yet support the login API, but does support its own bespoke tokens API.
    87  				"tfe.v2":   ts.URL + "/api/v2",
    88  				"tfe.v2.1": ts.URL + "/api/v2",
    89  				"tfe.v2.2": ts.URL + "/api/v2",
    90  			})
    91  			svcs.ForceHostServices(svchost.Hostname("unsupported.example.net"), map[string]interface{}{
    92  				// This host intentionally left blank.
    93  			})
    94  
    95  			c := &LoginCommand{
    96  				Meta: Meta{
    97  					Ui:              ui,
    98  					BrowserLauncher: browserLauncher,
    99  					Services:        svcs,
   100  				},
   101  			}
   102  
   103  			test(t, c, ui)
   104  		}
   105  	}
   106  
   107  	t.Run("app.terraform.io (no login support)", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   108  		// Enter "yes" at the consent prompt, then paste a token with some
   109  		// accidental whitespace.
   110  		defer testInputMap(t, map[string]string{
   111  			"approve": "yes",
   112  			"token":   "  good-token ",
   113  		})()
   114  		status := c.Run([]string{"app.terraform.io"})
   115  		if status != 0 {
   116  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   117  		}
   118  
   119  		credsSrc := c.Services.CredentialsSource()
   120  		creds, err := credsSrc.ForHost(svchost.Hostname("app.terraform.io"))
   121  		if err != nil {
   122  			t.Errorf("failed to retrieve credentials: %s", err)
   123  		}
   124  		if got, want := creds.Token(), "good-token"; got != want {
   125  			t.Errorf("wrong token %q; want %q", got, want)
   126  		}
   127  		if got, want := ui.OutputWriter.String(), "Welcome to Terraform Cloud!"; !strings.Contains(got, want) {
   128  			t.Errorf("expected output to contain %q, but was:\n%s", want, got)
   129  		}
   130  	}))
   131  
   132  	t.Run("example.com with authorization code flow", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   133  		// Enter "yes" at the consent prompt.
   134  		defer testInputMap(t, map[string]string{
   135  			"approve": "yes",
   136  		})()
   137  		status := c.Run([]string{"example.com"})
   138  		if status != 0 {
   139  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   140  		}
   141  
   142  		credsSrc := c.Services.CredentialsSource()
   143  		creds, err := credsSrc.ForHost(svchost.Hostname("example.com"))
   144  		if err != nil {
   145  			t.Errorf("failed to retrieve credentials: %s", err)
   146  		}
   147  		if got, want := creds.Token(), "good-token"; got != want {
   148  			t.Errorf("wrong token %q; want %q", got, want)
   149  		}
   150  
   151  		if got, want := ui.OutputWriter.String(), "Terraform has obtained and saved an API token."; !strings.Contains(got, want) {
   152  			t.Errorf("expected output to contain %q, but was:\n%s", want, got)
   153  		}
   154  	}))
   155  
   156  	t.Run("example.com results in no scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   157  
   158  		host, _ := c.Services.Discover("example.com")
   159  		client, _ := host.ServiceOAuthClient("login.v1")
   160  		if len(client.Scopes) != 0 {
   161  			t.Errorf("unexpected scopes %q; expected none", client.Scopes)
   162  		}
   163  	}))
   164  
   165  	t.Run("with-scopes.example.com with authorization code flow and scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   166  		// Enter "yes" at the consent prompt.
   167  		defer testInputMap(t, map[string]string{
   168  			"approve": "yes",
   169  		})()
   170  		status := c.Run([]string{"with-scopes.example.com"})
   171  		if status != 0 {
   172  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   173  		}
   174  
   175  		credsSrc := c.Services.CredentialsSource()
   176  		creds, err := credsSrc.ForHost(svchost.Hostname("with-scopes.example.com"))
   177  
   178  		if err != nil {
   179  			t.Errorf("failed to retrieve credentials: %s", err)
   180  		}
   181  
   182  		if got, want := creds.Token(), "good-token"; got != want {
   183  			t.Errorf("wrong token %q; want %q", got, want)
   184  		}
   185  
   186  		if got, want := ui.OutputWriter.String(), "Terraform has obtained and saved an API token."; !strings.Contains(got, want) {
   187  			t.Errorf("expected output to contain %q, but was:\n%s", want, got)
   188  		}
   189  	}))
   190  
   191  	t.Run("with-scopes.example.com results in expected scopes", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   192  
   193  		host, _ := c.Services.Discover("with-scopes.example.com")
   194  		client, _ := host.ServiceOAuthClient("login.v1")
   195  
   196  		expectedScopes := [2]string{"app1.full_access", "app2.read_only"}
   197  
   198  		var foundScopes [2]string
   199  		copy(foundScopes[:], client.Scopes)
   200  
   201  		if foundScopes != expectedScopes || len(client.Scopes) != len(expectedScopes) {
   202  			t.Errorf("unexpected scopes %q; want %q", client.Scopes, expectedScopes)
   203  		}
   204  	}))
   205  
   206  	t.Run("TFE host without login support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   207  		// Enter "yes" at the consent prompt, then paste a token with some
   208  		// accidental whitespace.
   209  		defer testInputMap(t, map[string]string{
   210  			"approve": "yes",
   211  			"token":   "  good-token ",
   212  		})()
   213  		status := c.Run([]string{"tfe.acme.com"})
   214  		if status != 0 {
   215  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   216  		}
   217  
   218  		credsSrc := c.Services.CredentialsSource()
   219  		creds, err := credsSrc.ForHost(svchost.Hostname("tfe.acme.com"))
   220  		if err != nil {
   221  			t.Errorf("failed to retrieve credentials: %s", err)
   222  		}
   223  		if got, want := creds.Token(), "good-token"; got != want {
   224  			t.Errorf("wrong token %q; want %q", got, want)
   225  		}
   226  
   227  		if got, want := ui.OutputWriter.String(), "Logged in to Terraform Enterprise"; !strings.Contains(got, want) {
   228  			t.Errorf("expected output to contain %q, but was:\n%s", want, got)
   229  		}
   230  	}))
   231  
   232  	t.Run("TFE host without login support, incorrectly pasted token", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   233  		// Enter "yes" at the consent prompt, then paste an invalid token.
   234  		defer testInputMap(t, map[string]string{
   235  			"approve": "yes",
   236  			"token":   "good-tok",
   237  		})()
   238  		status := c.Run([]string{"tfe.acme.com"})
   239  		if status != 1 {
   240  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   241  		}
   242  
   243  		credsSrc := c.Services.CredentialsSource()
   244  		creds, err := credsSrc.ForHost(svchost.Hostname("tfe.acme.com"))
   245  		if err != nil {
   246  			t.Errorf("failed to retrieve credentials: %s", err)
   247  		}
   248  		if creds != nil {
   249  			t.Errorf("wrong token %q; should have no token", creds.Token())
   250  		}
   251  	}))
   252  
   253  	t.Run("host without login or TFE API support", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   254  		status := c.Run([]string{"unsupported.example.net"})
   255  		if status == 0 {
   256  			t.Fatalf("successful exit; want error")
   257  		}
   258  
   259  		if got, want := ui.ErrorWriter.String(), "Error: Host does not support Terraform tokens API"; !strings.Contains(got, want) {
   260  			t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
   261  		}
   262  	}))
   263  
   264  	t.Run("answering no cancels", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   265  		// Enter "no" at the consent prompt
   266  		defer testInputMap(t, map[string]string{
   267  			"approve": "no",
   268  		})()
   269  		status := c.Run(nil)
   270  		if status != 1 {
   271  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   272  		}
   273  
   274  		if got, want := ui.ErrorWriter.String(), "Login cancelled"; !strings.Contains(got, want) {
   275  			t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
   276  		}
   277  	}))
   278  
   279  	t.Run("answering y cancels", loginTestCase(func(t *testing.T, c *LoginCommand, ui *cli.MockUi) {
   280  		// Enter "y" at the consent prompt
   281  		defer testInputMap(t, map[string]string{
   282  			"approve": "y",
   283  		})()
   284  		status := c.Run(nil)
   285  		if status != 1 {
   286  			t.Fatalf("unexpected error code %d\nstderr:\n%s", status, ui.ErrorWriter.String())
   287  		}
   288  
   289  		if got, want := ui.ErrorWriter.String(), "Login cancelled"; !strings.Contains(got, want) {
   290  			t.Fatalf("missing expected error message\nwant: %s\nfull output:\n%s", want, got)
   291  		}
   292  	}))
   293  }