-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[test] Add framework for running Firestore emulator for unit tests
- Loading branch information
Showing
8 changed files
with
278 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, "") | ||
} |
Oops, something went wrong.