1package cliconfig
2
3import (
4	"io/ioutil"
5	"net/http"
6	"os"
7	"path/filepath"
8	"testing"
9
10	"github.com/google/go-cmp/cmp"
11	"github.com/zclconf/go-cty/cty"
12
13	"github.com/hashicorp/terraform-svchost"
14	svcauth "github.com/hashicorp/terraform-svchost/auth"
15)
16
17func TestCredentialsForHost(t *testing.T) {
18	credSrc := &CredentialsSource{
19		configured: map[svchost.Hostname]cty.Value{
20			"configured.example.com": cty.ObjectVal(map[string]cty.Value{
21				"token": cty.StringVal("configured"),
22			}),
23			"unused.example.com": cty.ObjectVal(map[string]cty.Value{
24				"token": cty.StringVal("incorrectly-configured"),
25			}),
26		},
27
28		// We'll use a static source to stand in for what would normally be
29		// a credentials helper program, since we're only testing the logic
30		// for choosing when to delegate to the helper here. The logic for
31		// interacting with a helper program is tested in the svcauth package.
32		helper: svcauth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
33			"from-helper.example.com": {
34				"token": "from-helper",
35			},
36
37			// This should be shadowed by the "configured" entry with the same
38			// hostname above.
39			"configured.example.com": {
40				"token": "incorrectly-from-helper",
41			},
42		}),
43		helperType: "fake",
44	}
45
46	testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
47		t.Helper()
48
49		if creds == nil {
50			return ""
51		}
52
53		req, err := http.NewRequest("GET", "http://example.com/", nil)
54		if err != nil {
55			t.Fatalf("cannot construct HTTP request: %s", err)
56		}
57		creds.PrepareRequest(req)
58		return req.Header.Get("Authorization")
59	}
60
61	t.Run("configured", func(t *testing.T) {
62		creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com"))
63		if err != nil {
64			t.Fatalf("unexpected error: %s", err)
65		}
66		if got, want := testReqAuthHeader(t, creds), "Bearer configured"; got != want {
67			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
68		}
69	})
70	t.Run("from helper", func(t *testing.T) {
71		creds, err := credSrc.ForHost(svchost.Hostname("from-helper.example.com"))
72		if err != nil {
73			t.Fatalf("unexpected error: %s", err)
74		}
75		if got, want := testReqAuthHeader(t, creds), "Bearer from-helper"; got != want {
76			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
77		}
78	})
79	t.Run("not available", func(t *testing.T) {
80		creds, err := credSrc.ForHost(svchost.Hostname("unavailable.example.com"))
81		if err != nil {
82			t.Fatalf("unexpected error: %s", err)
83		}
84		if got, want := testReqAuthHeader(t, creds), ""; got != want {
85			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
86		}
87	})
88}
89
90func TestCredentialsStoreForget(t *testing.T) {
91	d, err := ioutil.TempDir("", "terraform-cliconfig-test")
92	if err != nil {
93		t.Fatal(err)
94	}
95	defer os.RemoveAll(d)
96
97	mockCredsFilename := filepath.Join(d, "credentials.tfrc.json")
98
99	cfg := &Config{
100		// This simulates there being a credentials block manually configured
101		// in some file _other than_ credentials.tfrc.json.
102		Credentials: map[string]map[string]interface{}{
103			"manually-configured.example.com": {
104				"token": "manually-configured",
105			},
106		},
107	}
108
109	// We'll initially use a credentials source with no credentials helper at
110	// all, and thus with credentials stored in the credentials file.
111	credSrc := cfg.credentialsSource(
112		"", nil,
113		mockCredsFilename,
114	)
115
116	testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
117		t.Helper()
118
119		if creds == nil {
120			return ""
121		}
122
123		req, err := http.NewRequest("GET", "http://example.com/", nil)
124		if err != nil {
125			t.Fatalf("cannot construct HTTP request: %s", err)
126		}
127		creds.PrepareRequest(req)
128		return req.Header.Get("Authorization")
129	}
130
131	// Because these store/forget calls have side-effects, we'll bail out with
132	// t.Fatal (or equivalent) as soon as anything unexpected happens.
133	// Otherwise downstream tests might fail in confusing ways.
134	{
135		err := credSrc.StoreForHost(
136			svchost.Hostname("manually-configured.example.com"),
137			svcauth.HostCredentialsToken("not-manually-configured"),
138		)
139		if err == nil {
140			t.Fatalf("successfully stored for manually-configured; want error")
141		}
142		if _, ok := err.(ErrUnwritableHostCredentials); !ok {
143			t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
144		}
145	}
146	{
147		err := credSrc.ForgetForHost(
148			svchost.Hostname("manually-configured.example.com"),
149		)
150		if err == nil {
151			t.Fatalf("successfully forgot for manually-configured; want error")
152		}
153		if _, ok := err.(ErrUnwritableHostCredentials); !ok {
154			t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
155		}
156	}
157	{
158		// We don't have a credentials file at all yet, so this first call
159		// must create it.
160		err := credSrc.StoreForHost(
161			svchost.Hostname("stored-locally.example.com"),
162			svcauth.HostCredentialsToken("stored-locally"),
163		)
164		if err != nil {
165			t.Fatalf("unexpected error storing locally: %s", err)
166		}
167
168		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
169		if err != nil {
170			t.Fatalf("failed to read back stored-locally credentials: %s", err)
171		}
172
173		if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally"; got != want {
174			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
175		}
176
177		got := readHostsInCredentialsFile(mockCredsFilename)
178		want := map[svchost.Hostname]struct{}{
179			svchost.Hostname("stored-locally.example.com"): struct{}{},
180		}
181		if diff := cmp.Diff(want, got); diff != "" {
182			t.Fatalf("wrong credentials file content\n%s", diff)
183		}
184	}
185
186	// Now we'll switch to having a credential helper active.
187	// If we were loading the real CLI config from disk here then this
188	// entry would already be in cfg.Credentials, but we need to fake that
189	// in the test because we're constructing this *Config value directly.
190	cfg.Credentials["stored-locally.example.com"] = map[string]interface{}{
191		"token": "stored-locally",
192	}
193	mockHelper := &mockCredentialsHelper{current: make(map[svchost.Hostname]cty.Value)}
194	credSrc = cfg.credentialsSource(
195		"mock", mockHelper,
196		mockCredsFilename,
197	)
198	{
199		err := credSrc.StoreForHost(
200			svchost.Hostname("manually-configured.example.com"),
201			svcauth.HostCredentialsToken("not-manually-configured"),
202		)
203		if err == nil {
204			t.Fatalf("successfully stored for manually-configured with helper active; want error")
205		}
206	}
207	{
208		err := credSrc.StoreForHost(
209			svchost.Hostname("stored-in-helper.example.com"),
210			svcauth.HostCredentialsToken("stored-in-helper"),
211		)
212		if err != nil {
213			t.Fatalf("unexpected error storing in helper: %s", err)
214		}
215
216		creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
217		if err != nil {
218			t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
219		}
220
221		if got, want := testReqAuthHeader(t, creds), "Bearer stored-in-helper"; got != want {
222			t.Fatalf("wrong header value for stored-in-helper\ngot:  %s\nwant: %s", got, want)
223		}
224
225		// Nothing should have changed in the saved credentials file
226		got := readHostsInCredentialsFile(mockCredsFilename)
227		want := map[svchost.Hostname]struct{}{
228			svchost.Hostname("stored-locally.example.com"): struct{}{},
229		}
230		if diff := cmp.Diff(want, got); diff != "" {
231			t.Fatalf("wrong credentials file content\n%s", diff)
232		}
233	}
234	{
235		// Because stored-locally is already in the credentials file, a new
236		// store should be sent there rather than to the credentials helper.
237		err := credSrc.StoreForHost(
238			svchost.Hostname("stored-locally.example.com"),
239			svcauth.HostCredentialsToken("stored-locally-again"),
240		)
241		if err != nil {
242			t.Fatalf("unexpected error storing locally again: %s", err)
243		}
244
245		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
246		if err != nil {
247			t.Fatalf("failed to read back stored-locally credentials: %s", err)
248		}
249
250		if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally-again"; got != want {
251			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
252		}
253	}
254	{
255		// Forgetting a host already in the credentials file should remove it
256		// from the credentials file, not from the helper.
257		err := credSrc.ForgetForHost(
258			svchost.Hostname("stored-locally.example.com"),
259		)
260		if err != nil {
261			t.Fatalf("unexpected error forgetting locally: %s", err)
262		}
263
264		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
265		if err != nil {
266			t.Fatalf("failed to read back stored-locally credentials: %s", err)
267		}
268
269		if got, want := testReqAuthHeader(t, creds), ""; got != want {
270			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
271		}
272
273		// Should not be present in the credentials file anymore
274		got := readHostsInCredentialsFile(mockCredsFilename)
275		want := map[svchost.Hostname]struct{}{}
276		if diff := cmp.Diff(want, got); diff != "" {
277			t.Fatalf("wrong credentials file content\n%s", diff)
278		}
279	}
280	{
281		err := credSrc.ForgetForHost(
282			svchost.Hostname("stored-in-helper.example.com"),
283		)
284		if err != nil {
285			t.Fatalf("unexpected error forgetting in helper: %s", err)
286		}
287
288		creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
289		if err != nil {
290			t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
291		}
292
293		if got, want := testReqAuthHeader(t, creds), ""; got != want {
294			t.Fatalf("wrong header value for stored-in-helper\ngot:  %s\nwant: %s", got, want)
295		}
296	}
297
298	{
299		// Finally, the log in our mock helper should show that it was only
300		// asked to deal with stored-in-helper, not stored-locally.
301		got := mockHelper.log
302		want := []mockCredentialsHelperChange{
303			{
304				Host:   svchost.Hostname("stored-in-helper.example.com"),
305				Action: "store",
306			},
307			{
308				Host:   svchost.Hostname("stored-in-helper.example.com"),
309				Action: "forget",
310			},
311		}
312		if diff := cmp.Diff(want, got); diff != "" {
313			t.Errorf("unexpected credentials helper operation log\n%s", diff)
314		}
315	}
316}
317
318type mockCredentialsHelperChange struct {
319	Host   svchost.Hostname
320	Action string
321}
322
323type mockCredentialsHelper struct {
324	current map[svchost.Hostname]cty.Value
325	log     []mockCredentialsHelperChange
326}
327
328// Assertion that mockCredentialsHelper implements svcauth.CredentialsSource
329var _ svcauth.CredentialsSource = (*mockCredentialsHelper)(nil)
330
331func (s *mockCredentialsHelper) ForHost(hostname svchost.Hostname) (svcauth.HostCredentials, error) {
332	v, ok := s.current[hostname]
333	if !ok {
334		return nil, nil
335	}
336	return svcauth.HostCredentialsFromObject(v), nil
337}
338
339func (s *mockCredentialsHelper) StoreForHost(hostname svchost.Hostname, new svcauth.HostCredentialsWritable) error {
340	s.log = append(s.log, mockCredentialsHelperChange{
341		Host:   hostname,
342		Action: "store",
343	})
344	s.current[hostname] = new.ToStore()
345	return nil
346}
347
348func (s *mockCredentialsHelper) ForgetForHost(hostname svchost.Hostname) error {
349	s.log = append(s.log, mockCredentialsHelperChange{
350		Host:   hostname,
351		Action: "forget",
352	})
353	delete(s.current, hostname)
354	return nil
355}
356