package webbrowser import ( "context" "fmt" "log" "net/http" "net/url" "sync" "github.com/hashicorp/terraform/internal/httpclient" ) // NewMockLauncher creates and returns a mock implementation of Launcher, // with some special behavior designed for use in unit tests. // // See the documentation of MockLauncher itself for more information. func NewMockLauncher(ctx context.Context) *MockLauncher { client := httpclient.New() return &MockLauncher{ Client: client, Context: ctx, } } // MockLauncher is a mock implementation of Launcher that has some special // behavior designed for use in unit tests. // // When OpenURL is called, MockLauncher will make an HTTP request to the given // URL rather than interacting with a "real" browser. // // In normal situations it will then return with no further action, but if // the response to the given URL is either a standard HTTP redirect response // or includes the custom HTTP header X-Redirect-To then MockLauncher will // send a follow-up request to that target URL, and continue in this manner // until it reaches a URL that is not a redirect. (The X-Redirect-To header // is there so that a server can potentially offer a normal HTML page to // an actual browser while also giving a next-hop hint for MockLauncher.) // // Since MockLauncher is not a full programmable user-agent implementation // it can't be used for testing of real-world web applications, but it can // be used for testing against specialized test servers that are written // with MockLauncher in mind and know how to drive the request flow through // whatever steps are required to complete the desired test. // // All of the actions taken by MockLauncher happen asynchronously in the // background, to simulate the concurrency of a separate web browser. // Test code using MockLauncher should provide a context which is cancelled // when the test completes, to help avoid leaking MockLaunchers. type MockLauncher struct { // Client is the HTTP client that MockLauncher will use to make requests. // By default (if you use NewMockLauncher) this is a new client created // via httpclient.New, but callers may override it if they need customized // behavior for a particular test. // // Do not use a client that is shared with any other subsystem, because // MockLauncher will customize the settings of the given client. Client *http.Client // Context can be cancelled in order to abort an OpenURL call before it // would naturally complete. Context context.Context // Responses is a log of all of the responses recieved from the launcher's // requests, in the order requested. Responses []*http.Response // done is a waitgroup used internally to signal when the async work is // complete, in order to make this mock more convenient to use in tests. done sync.WaitGroup } var _ Launcher = (*MockLauncher)(nil) // OpenURL is the mock implementation of Launcher, which has the special // behavior described for type MockLauncher. func (l *MockLauncher) OpenURL(u string) error { // We run our operation in the background because it's supposed to be // behaving like a web browser running in a separate process. log.Printf("[TRACE] webbrowser.MockLauncher: OpenURL(%q) starting in the background", u) l.done.Add(1) go func() { err := l.openURL(u) if err != nil { // Can't really do anything with this asynchronously, so we'll // just log it so that someone debugging will be able to see it. log.Printf("[ERROR] webbrowser.MockLauncher: OpenURL(%q): %s", u, err) } else { log.Printf("[TRACE] webbrowser.MockLauncher: OpenURL(%q) has concluded", u) } l.done.Done() }() return nil } func (l *MockLauncher) openURL(u string) error { // We need to disable automatic redirect following so that we can implement // it ourselves below, and thus be able to see the redirects in our // responses log. l.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } // We'll keep looping as long as the server keeps giving us new URLs to // request. for u != "" { log.Printf("[DEBUG] webbrowser.MockLauncher: requesting %s", u) req, err := http.NewRequest("GET", u, nil) if err != nil { return fmt.Errorf("failed to construct HTTP request for %s: %s", u, err) } resp, err := l.Client.Do(req) if err != nil { log.Printf("[DEBUG] webbrowser.MockLauncher: request failed: %s", err) return fmt.Errorf("error requesting %s: %s", u, err) } l.Responses = append(l.Responses, resp) if resp.StatusCode >= 400 { log.Printf("[DEBUG] webbrowser.MockLauncher: request failed: %s", resp.Status) return fmt.Errorf("error requesting %s: %s", u, resp.Status) } log.Printf("[DEBUG] webbrowser.MockLauncher: request succeeded: %s", resp.Status) u = "" // unless it's a redirect, we'll stop after this if location := resp.Header.Get("Location"); location != "" { u = location } else if redirectTo := resp.Header.Get("X-Redirect-To"); redirectTo != "" { u = redirectTo } if u != "" { // HTTP technically doesn't permit relative URLs in Location, but // browsers tolerate it and so real-world servers do it, and thus // we'll allow it here too. oldURL := resp.Request.URL givenURL, err := url.Parse(u) if err != nil { return fmt.Errorf("invalid redirect URL %s: %s", u, err) } u = oldURL.ResolveReference(givenURL).String() log.Printf("[DEBUG] webbrowser.MockLauncher: redirected to %s", u) } } log.Printf("[DEBUG] webbrowser.MockLauncher: all done") return nil } // Wait blocks until the MockLauncher has finished its asynchronous work of // making HTTP requests and following redirects, at which point it will have // reached a request that didn't redirect anywhere and stopped iterating. func (l *MockLauncher) Wait() { log.Printf("[TRACE] webbrowser.MockLauncher: Wait() for current work to complete") l.done.Wait() }