terraform/command/webbrowser/mock.go

156 lines
5.8 KiB
Go

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()
}