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