diff --git a/functions/challenge.go b/functions/challenge.go index 1c4d564..a2c1d5d 100644 --- a/functions/challenge.go +++ b/functions/challenge.go @@ -15,7 +15,7 @@ func ChallengeHandler(w http.ResponseWriter, r *http.Request) { return } - if err := util.ValidateRequestMethod(&ctx, "GET", ""); err != nil { + if err := ctx.ValidateRequestMethod("GET", ""); err != nil { ctx.WriteStatusError(err) return } diff --git a/functions/challenge_test.go b/functions/challenge_test.go new file mode 100644 index 0000000..434957b --- /dev/null +++ b/functions/challenge_test.go @@ -0,0 +1,38 @@ +package functions + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "functions/internal/pow" + "functions/internal/util" +) + +func TestChallenge(t *testing.T) { + firestore, err := util.NewTestFirestore() + assert.Nil(t, err) + + client, err := firestore.FirestoreClientContext() + assert.Nil(t, err) + req, err := http.NewRequestWithContext(client, "GET", "/challenge", nil) + assert.Nil(t, err) + + r := httptest.NewRecorder() + ChallengeHandler(r, req) + + // First, unmarshal using pow.Challenge in order to benefit from its + // validation. + var c0 pow.Challenge + err = json.Unmarshal(r.Body.Bytes(), &c0) + assert.Nil(t, err) + + // Second, unmarshal into a map so that we can inspect its contents. + var c1 map[string]interface{} + err = json.Unmarshal(r.Body.Bytes(), &c1) + assert.Nil(t, err) + assert.Equal(t, c1["work_factor"], float64(pow.DefaultWorkFactor)) +} diff --git a/functions/go.mod b/functions/go.mod index e1c07b4..3f26c62 100644 --- a/functions/go.mod +++ b/functions/go.mod @@ -11,9 +11,10 @@ require ( github.com/stretchr/testify v1.5.1 golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 golang.org/x/net v0.0.0-20200506145744-7e3656a0809f // indirect + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 // indirect golang.org/x/tools v0.0.0-20200507205054-480da3ebd79c // indirect - google.golang.org/api v0.23.0 // indirect + google.golang.org/api v0.23.0 google.golang.org/genproto v0.0.0-20200507105951-43844f6eee31 // indirect google.golang.org/grpc v1.29.1 ) diff --git a/functions/internal/pow/pow.go b/functions/internal/pow/pow.go index be4ba94..eaaebc5 100644 --- a/functions/internal/pow/pow.go +++ b/functions/internal/pow/pow.go @@ -24,8 +24,10 @@ const ( // Allow challenges to remain valid for one minute to allow for slow // connections. We may need to increase this if we find that there's a tail // of clients whose connections are bad enough that this is too short. - expirationPeriod = 60 * time.Second - defaultWorkFactor = 1024 + expirationPeriod = 60 * time.Second + // DefaultWorkFactor is the default work factor used when generating + // challenges. + DefaultWorkFactor = 1024 // The name of the Firestore collection of challenges. challengeCollection = "challenges" @@ -148,7 +150,7 @@ type challengeDoc struct { // GenerateChallenge generates a new challenge and stores it in the database. func GenerateChallenge(ctx util.Context) (*Challenge, error) { - c := generateChallenge(defaultWorkFactor) + c := generateChallenge(DefaultWorkFactor) doc := challengeDoc{Expiration: time.Now().Add(expirationPeriod)} _, err := ctx.FirestoreClient().Collection(challengeCollection).Doc(c.docID()).Create(ctx, doc) diff --git a/functions/internal/pow/pow_test.go b/functions/internal/pow/pow_test.go index 8cc3e5c..0f36168 100644 --- a/functions/internal/pow/pow_test.go +++ b/functions/internal/pow/pow_test.go @@ -29,7 +29,7 @@ func TestValidate(t *testing.T) { // On a 2018 MacBook Pro, this takes ~930us per validation. func BenchmarkValidate(b *testing.B) { - c := generateChallenge(defaultWorkFactor) + c := generateChallenge(DefaultWorkFactor) var s Solution for { _, err := rand.Read(s.inner.Nonce[:]) @@ -48,6 +48,6 @@ func BenchmarkValidate(b *testing.B) { // On a 2018 MacBook Pro, this takes ~1100ns per validation. func BenchmarkGenerate(b *testing.B) { for i := 0; i < b.N; i++ { - generateChallenge(defaultWorkFactor) + generateChallenge(DefaultWorkFactor) } } diff --git a/functions/internal/util/firestore.go b/functions/internal/util/firestore.go new file mode 100644 index 0000000..ade98cb --- /dev/null +++ b/functions/internal/util/firestore.go @@ -0,0 +1,168 @@ +package util + +import ( + "bufio" + "context" + "fmt" + "log" + "net" + "os/exec" + "runtime" + "strings" + "time" + + "cloud.google.com/go/firestore" + "golang.org/x/oauth2" + "google.golang.org/api/option" + "google.golang.org/grpc" +) + +// TestFirestore is a handle to a Firestore emulator running as a subprocess. +// +// When a TestFirestore is garbage collected, the subprocess is killed. Make +// sure to keep a TestFirestore reachable so long as code is attempting to use +// the emulator so the emulator won't be killed. +type TestFirestore struct { + // The emulator subprocess. + emulator *exec.Cmd + host string +} + +func NewTestFirestore() (*TestFirestore, error) { + // For some reason, if the emulator crashes, the stderr does not close, and + // without any other intervention, our stderr scanning code hangs + // indefinitely. This timeout ensures that, if that happens, this timeout + // will eventually trigger, the process will be killed (thus closing + // stderr), and we will return an error. + // + // TODO(joshlf): Implement a more sophisticated solution to this problem. + // The right way to do this is probably to spawn two goroutines - one to + // scan stderr, and one to monitor the process to see if it quits. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // When we don't specify a local address, a random port is chosen + // automatically. + cmd := exec.CommandContext(ctx, "gcloud", "beta", "emulators", "firestore", "start") + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("could not start Firestore emulator: %v", err) + } + defer stderr.Close() + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("could not start Firestore emulator: %v", err) + } + + firestore := &TestFirestore{emulator: cmd} + // Set a finalizer so that the subprocess is killed when we're done with it. + runtime.SetFinalizer(firestore, func(firestore *TestFirestore) { + err := firestore.emulator.Process.Kill() + if err != nil { + log.Print("could not kill emulator process:", err) + } + }) + + // Parse the command's stderr until we find the line indicating what local + // address the emulator is listening on. Example output: + // + // Executing: /Users/dvader/google-cloud-sdk/platform/cloud-firestore-emulator/cloud_firestore_emulator start --host=::1 --port=8007 + // [firestore] API endpoint: http://::1:8007 + // [firestore] If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run: + // [firestore] + // [firestore] export FIRESTORE_EMULATOR_HOST=::1:8007 + // [firestore] + // [firestore] Dev App Server is now running. + // [firestore] + // + // In particular, we look for "export FIRESTORE_EMULATOR_HOST=". + + s := bufio.NewScanner(stderr) + var host string +loop: + for s.Scan() { + parts := strings.Split(s.Text(), "export FIRESTORE_EMULATOR_HOST=") + switch len(parts) { + case 1: + if strings.Contains(s.Text(), "Dev App Server is now running") { + break loop + } + case 2: + host = parts[1] + default: + return nil, fmt.Errorf("got unexpected line from stderr output: \"%v\"", s.Text()) + } + } + + if host == "" { + return nil, fmt.Errorf("emulator started without outputting its listening address") + } + + // Instead of just storing the host as is, we split it into two and then + // recombine it using net.JoinHostPort. The reason is that, when using IPv6, + // the host looks like "::1:8007", which cannot be parsed as an IP + // address/port pair by the Go Firestore client. net.JoinHostPort takes care + // of wrapping IPv6 addresses in square brackets. + colon := strings.LastIndex(host, ":") + if colon == -1 { + return nil, fmt.Errorf("could not parse host: %v", host) + } + + firestore.host = net.JoinHostPort(host[:colon], host[colon+1:]) + return firestore, nil +} + +type dummyTokenSource struct{} + +func (d dummyTokenSource) Token() (*oauth2.Token, error) { + return &oauth2.Token{}, nil +} + +// FirestoreClientContext returns a context which is intended to be set as the +// context for an http.Request which will be passed to NewContext. In +// particular, it sets option.ClientOptions (from the +// "google.golang.org/api/option" package) which will be used when creating the +// Firestore client which instruct the client to use this Firestore emulator as +// its target. +func (t *TestFirestore) FirestoreClientContext() (context.Context, error) { + opt, err := t.clientOption() + if err != nil { + return nil, err + } + return WithClientOptions(context.Background(), opt), nil +} + +func (t *TestFirestore) FirestoreClient(ctx context.Context) (*firestore.Client, error) { + opt, err := t.clientOption() + if err != nil { + return nil, err + } + return firestore.NewClient(ctx, "test", opt) +} + +func (t *TestFirestore) clientOption() (option.ClientOption, error) { + conn, err := grpc.Dial(t.host, grpc.WithInsecure(), grpc.WithPerRPCCredentials(emulatorCreds{})) + if err != nil { + return nil, err + } + return option.WithGRPCConn(conn), nil +} + +// emulatorCreds is taken from cloud.google.com/go/firestore/client.go. +// +// TODO(https://github.com/googleapis/google-cloud-go/issues/1978): Switch to a +// first-class API if one is provided. + +// emulatorCreds is an instance of grpc.PerRPCCredentials that will configure a +// client to act as an admin for the Firestore emulator. It always hardcodes +// the "authorization" metadata field to contain "Bearer owner", which the +// Firestore emulator accepts as valid admin credentials. +type emulatorCreds struct{} + +func (ec emulatorCreds) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer owner"}, nil +} + +func (ec emulatorCreds) RequireTransportSecurity() bool { + return false +} diff --git a/functions/internal/util/firestore_test.go b/functions/internal/util/firestore_test.go new file mode 100644 index 0000000..c4ef739 --- /dev/null +++ b/functions/internal/util/firestore_test.go @@ -0,0 +1,13 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewFirestore(t *testing.T) { + f, err := NewTestFirestore() + assert.Nil(t, err) + assert.NotEqual(t, f.host, "") +} diff --git a/functions/internal/util/util.go b/functions/internal/util/util.go index 9adf0b6..4cee3a5 100644 --- a/functions/internal/util/util.go +++ b/functions/internal/util/util.go @@ -11,10 +11,36 @@ import ( "net/http" "cloud.google.com/go/firestore" + "google.golang.org/api/option" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) +// clientOptionContextKey is a key used to identify a slice of +// option.ClientOption objects. Since it is not exported, there is no way for +// code outside of this package to overwrite or access values stored with this +// key. +type clientOptionContextKey struct{} + +// WithClientOptions returns a copy of parent which stores the given +// clientOptions. If this context is used to initialize an http.Request, and +// that request is passed to NewContext, then these options will be used when +// connecting to the firestore. +func WithClientOptions(parent context.Context, clientOptions ...option.ClientOption) context.Context { + return context.WithValue(parent, clientOptionContextKey{}, clientOptions) +} + +func getClientOptions(ctx context.Context) []option.ClientOption { + switch val := ctx.Value(clientOptionContextKey{}).(type) { + case nil: + return nil + case []option.ClientOption: + return val + default: + panic("unreachable") + } +} + // Context is a context.Context that provides extra utilities for common // operations. type Context struct { @@ -28,16 +54,23 @@ type Context struct { // NewContext constructs a new Context from an http.ResponseWriter and an // *http.Request. If an error occurs, NewContext takes care of writing an // appropriate response to w, and logs the error using log.Printf. +// +// If r.Context() returns a context which was produced by WithClientOptions, +// those client options will used when connecting to the Firestore. func NewContext(w http.ResponseWriter, r *http.Request) (Context, error) { ctx := r.Context() - client, err := firestore.NewClient(ctx, "test") + client, err := firestore.NewClient(ctx, "test", getClientOptions(ctx)...) if err != nil { err := NewInternalServerError(err) writeStatusError(w, r, err) return Context{}, err } - return Context{w, r, client, ctx}, nil + return Context{resp: w, + req: r, + client: client, + Context: ctx, + }, nil } // HTTPRequest returns the *http.Request that was used to construct this @@ -66,8 +99,8 @@ func writeStatusError(w http.ResponseWriter, r *http.Request, err StatusError) { w.WriteHeader(err.HTTPStatusCode()) json.NewEncoder(w).Encode(response{Message: err.Message()}) - log.Printf("[%v %v %v]: responding with error code %v and message \"%v\"", - r.RemoteAddr, r.Method, r.URL, err.HTTPStatusCode(), err.Message()) + log.Printf("[%v %v %v]: responding with error code %v and message \"%v\" (error: %v)", + r.RemoteAddr, r.Method, r.URL, err.HTTPStatusCode(), err.Message(), err) } // WriteStatusError writes err to c.HTTPResponseWriter(), and logs it using @@ -76,10 +109,10 @@ func (c *Context) WriteStatusError(err StatusError) { writeStatusError(c.resp, c.req, err) } -// ValidateRequestMethod validates that ctx.HTTPRequest().Method == method, and -// if not, returns an appropriate StatusError. -func ValidateRequestMethod(ctx *Context, method, err string) StatusError { - m := ctx.HTTPRequest().Method +// ValidateRequestMethod validates that c.HTTPRequest().Method == method, and if +// not, returns an appropriate StatusError. +func (c *Context) ValidateRequestMethod(method, err string) StatusError { + m := c.HTTPRequest().Method if m != method { return NewMethodNotAllowedError(m) } @@ -151,14 +184,18 @@ func NewMethodNotAllowedError(method string) StatusError { } var ( - notFoundError = NewBadRequestError(errors.New("not found")) + // NotFoundError is an error returned when a resource is not found. + NotFoundError = NewBadRequestError(errors.New("not found")) ) // FirestoreToStatusError converts an error returned from the // "cloud.google.com/go/firestore" package to a StatusError. func FirestoreToStatusError(err error) StatusError { + if err, ok := err.(StatusError); ok { + return err + } if status.Code(err) == codes.NotFound { - return notFoundError + return NotFoundError } return NewInternalServerError(err) @@ -170,6 +207,8 @@ func FirestoreToStatusError(err error) StatusError { // internal server errors. func JSONToStatusError(err error) StatusError { switch err := err.(type) { + case StatusError: + return err case *json.MarshalerError, *json.SyntaxError, *json.UnmarshalFieldError, *json.UnmarshalTypeError, *json.UnsupportedTypeError, *json.UnsupportedValueError: return NewBadRequestError(err)