| // Copyright 2017 syzkaller project authors. All rights reserved. |
| // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. |
| |
| // The test uses aetest package that starts local dev_appserver and handles all requests locally: |
| // https://cloud.google.com/appengine/docs/standard/go/tools/localunittesting/reference |
| // The test requires installed appengine SDK (dev_appserver), so we guard it by aetest tag. |
| // Run the test with: goapp test -tags=aetest |
| |
| // +build aetest |
| |
| package dash |
| |
| import ( |
| "bytes" |
| "fmt" |
| "io/ioutil" |
| "net/http" |
| "net/http/httptest" |
| "path/filepath" |
| "reflect" |
| "runtime" |
| "strings" |
| "sync" |
| "testing" |
| "time" |
| |
| "github.com/google/syzkaller/dashboard/dashapi" |
| "golang.org/x/net/context" |
| "google.golang.org/appengine" |
| "google.golang.org/appengine/aetest" |
| "google.golang.org/appengine/datastore" |
| aemail "google.golang.org/appengine/mail" |
| "google.golang.org/appengine/user" |
| ) |
| |
| type Ctx struct { |
| t *testing.T |
| inst aetest.Instance |
| ctx context.Context |
| mockedTime time.Time |
| emailSink chan *aemail.Message |
| } |
| |
| func NewCtx(t *testing.T) *Ctx { |
| t.Parallel() |
| inst, err := aetest.NewInstance(&aetest.Options{ |
| // Without this option datastore queries return data with slight delay, |
| // which fails reporting tests. |
| StronglyConsistentDatastore: true, |
| }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| r, err := inst.NewRequest("GET", "", nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| c := &Ctx{ |
| t: t, |
| inst: inst, |
| ctx: appengine.NewContext(r), |
| mockedTime: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), |
| emailSink: make(chan *aemail.Message, 100), |
| } |
| registerContext(r, c) |
| return c |
| } |
| |
| func (c *Ctx) expectOK(err error) { |
| if err != nil { |
| c.t.Fatalf("\n%v: %v", caller(0), err) |
| } |
| } |
| |
| func (c *Ctx) expectFail(msg string, err error) { |
| if err == nil { |
| c.t.Fatalf("\n%v: expected to fail, but it does not", caller(0)) |
| } |
| if !strings.Contains(err.Error(), msg) { |
| c.t.Fatalf("\n%v: expected to fail with %q, but failed with %q", caller(0), msg, err) |
| } |
| } |
| |
| func (c *Ctx) expectForbidden(err error) { |
| if err == nil { |
| c.t.Fatalf("\n%v: expected to fail as 403, but it does not", caller(0)) |
| } |
| httpErr, ok := err.(HttpError) |
| if !ok || httpErr.Code != http.StatusForbidden { |
| c.t.Fatalf("\n%v: expected to fail as 403, but it failed as %v", caller(0), err) |
| } |
| } |
| |
| func (c *Ctx) expectEQ(got, want interface{}) { |
| if !reflect.DeepEqual(got, want) { |
| c.t.Fatalf("\n%v: got %#v, want %#v", caller(0), got, want) |
| } |
| } |
| |
| func (c *Ctx) expectTrue(v bool) { |
| if !v { |
| c.t.Fatalf("\n%v: failed", caller(0)) |
| } |
| } |
| |
| func caller(skip int) string { |
| _, file, line, _ := runtime.Caller(skip + 2) |
| return fmt.Sprintf("%v:%v", filepath.Base(file), line) |
| } |
| |
| func (c *Ctx) Close() { |
| if !c.t.Failed() { |
| // Ensure that we can render main page and all bugs in the final test state. |
| c.expectOK(c.GET("/")) |
| var bugs []*Bug |
| keys, err := datastore.NewQuery("Bug").GetAll(c.ctx, &bugs) |
| if err != nil { |
| c.t.Errorf("ERROR: failed to query bugs: %v", err) |
| } |
| for _, key := range keys { |
| c.expectOK(c.GET(fmt.Sprintf("/bug?id=%v", key.StringID()))) |
| } |
| c.expectOK(c.GET("/email_poll")) |
| for len(c.emailSink) != 0 { |
| c.t.Errorf("ERROR: leftover email: %v", (<-c.emailSink).Body) |
| } |
| } |
| unregisterContext(c) |
| c.inst.Close() |
| } |
| |
| func (c *Ctx) advanceTime(d time.Duration) { |
| c.mockedTime = c.mockedTime.Add(d) |
| } |
| |
| // API makes an api request to the app from the specified client. |
| func (c *Ctx) API(client, key, method string, req, reply interface{}) error { |
| doer := func(r *http.Request) (*http.Response, error) { |
| registerContext(r, c) |
| w := httptest.NewRecorder() |
| http.DefaultServeMux.ServeHTTP(w, r) |
| // Later versions of Go have a nice w.Result method, |
| // but we stuck on 1.6 on appengine. |
| if w.Body == nil { |
| w.Body = new(bytes.Buffer) |
| } |
| res := &http.Response{ |
| StatusCode: w.Code, |
| Status: http.StatusText(w.Code), |
| Body: ioutil.NopCloser(bytes.NewReader(w.Body.Bytes())), |
| } |
| return res, nil |
| } |
| |
| c.t.Logf("API(%v): %#v", method, req) |
| err := dashapi.Query(client, "", key, method, c.inst.NewRequest, doer, req, reply) |
| if err != nil { |
| c.t.Logf("ERROR: %v", err) |
| return err |
| } |
| c.t.Logf("REPLY: %#v", reply) |
| return nil |
| } |
| |
| // GET sends admin-authorized HTTP GET request to the app. |
| func (c *Ctx) GET(url string) error { |
| _, err := c.httpRequest("GET", url, "", AccessAdmin) |
| return err |
| } |
| |
| // AuthGET sends HTTP GET request to the app with the specified authorization. |
| func (c *Ctx) AuthGET(access AccessLevel, url string) ([]byte, error) { |
| return c.httpRequest("GET", url, "", access) |
| } |
| |
| // POST sends admin-authorized HTTP POST request to the app. |
| func (c *Ctx) POST(url, body string) error { |
| _, err := c.httpRequest("POST", url, body, AccessAdmin) |
| return err |
| } |
| |
| func (c *Ctx) httpRequest(method, url, body string, access AccessLevel) ([]byte, error) { |
| c.t.Logf("%v: %v", method, url) |
| r, err := c.inst.NewRequest(method, url, strings.NewReader(body)) |
| if err != nil { |
| c.t.Fatal(err) |
| } |
| registerContext(r, c) |
| if access == AccessAdmin || access == AccessUser { |
| user := &user.User{ |
| Email: "user@syzkaller.com", |
| AuthDomain: "gmail.com", |
| } |
| if access == AccessAdmin { |
| user.Admin = true |
| } |
| aetest.Login(user, r) |
| } |
| w := httptest.NewRecorder() |
| http.DefaultServeMux.ServeHTTP(w, r) |
| c.t.Logf("REPLY: %v", w.Code) |
| if w.Code != http.StatusOK { |
| return nil, HttpError{w.Code, w.Body.String()} |
| } |
| return w.Body.Bytes(), nil |
| } |
| |
| type HttpError struct { |
| Code int |
| Body string |
| } |
| |
| func (err HttpError) Error() string { |
| return fmt.Sprintf("%v: %v", err.Code, err.Body) |
| } |
| |
| func (c *Ctx) loadBug(extID string) (*Bug, *Crash, *Build) { |
| bug, _, err := findBugByReportingID(c.ctx, extID) |
| if err != nil { |
| c.t.Fatalf("failed to load bug: %v", err) |
| } |
| crash, _, err := findCrashForBug(c.ctx, bug) |
| if err != nil { |
| c.t.Fatalf("failed to load crash: %v", err) |
| } |
| build, err := loadBuild(c.ctx, bug.Namespace, crash.BuildID) |
| if err != nil { |
| c.t.Fatalf("failed to load build: %v", err) |
| } |
| return bug, crash, build |
| } |
| |
| func (c *Ctx) loadJob(extID string) (*Job, *Build) { |
| jobKey, err := jobID2Key(c.ctx, extID) |
| if err != nil { |
| c.t.Fatalf("failed to create job key: %v", err) |
| } |
| job := new(Job) |
| if err := datastore.Get(c.ctx, jobKey, job); err != nil { |
| c.t.Fatalf("failed to get job %v: %v", extID, err) |
| } |
| build, err := loadBuild(c.ctx, job.Namespace, job.BuildID) |
| if err != nil { |
| c.t.Fatalf("failed to load build: %v", err) |
| } |
| return job, build |
| } |
| |
| func (c *Ctx) checkURLContents(url string, want []byte) { |
| got, err := c.AuthGET(AccessAdmin, url) |
| if err != nil { |
| c.t.Fatalf("\n%v: %v request failed: %v", caller(0), url, err) |
| } |
| if !bytes.Equal(got, want) { |
| c.t.Fatalf("\n%v: url %v: got:\n%s\nwant:\n%s\n", caller(0), url, got, want) |
| } |
| } |
| |
| type apiClient struct { |
| *Ctx |
| client string |
| key string |
| } |
| |
| func (c *Ctx) makeClient(client, key string) *apiClient { |
| return &apiClient{ |
| Ctx: c, |
| client: client, |
| key: key, |
| } |
| } |
| |
| func (client *apiClient) API(method string, req, reply interface{}) error { |
| return client.Ctx.API(client.client, client.key, method, req, reply) |
| } |
| |
| func (client *apiClient) uploadBuild(build *dashapi.Build) { |
| client.expectOK(client.API("upload_build", build, nil)) |
| } |
| |
| func (client *apiClient) reportCrash(crash *dashapi.Crash) { |
| client.expectOK(client.API("report_crash", crash, nil)) |
| } |
| |
| // TODO(dvyukov): make this apiClient method. |
| func reportAllBugs(c *Ctx, expect int) []*dashapi.BugReport { |
| pr := &dashapi.PollBugsRequest{ |
| Type: "test", |
| } |
| resp := new(dashapi.PollBugsResponse) |
| c.expectOK(c.API(client1, key1, "reporting_poll_bugs", pr, resp)) |
| if len(resp.Reports) != expect { |
| c.t.Fatalf("\n%v: want %v reports, got %v", caller(0), expect, len(resp.Reports)) |
| } |
| for _, rep := range resp.Reports { |
| reproLevel := dashapi.ReproLevelNone |
| if len(rep.ReproC) != 0 { |
| reproLevel = dashapi.ReproLevelC |
| } else if len(rep.ReproSyz) != 0 { |
| reproLevel = dashapi.ReproLevelSyz |
| } |
| cmd := &dashapi.BugUpdate{ |
| ID: rep.ID, |
| Status: dashapi.BugStatusOpen, |
| ReproLevel: reproLevel, |
| } |
| reply := new(dashapi.BugUpdateReply) |
| c.expectOK(c.API(client1, key1, "reporting_update", cmd, reply)) |
| c.expectEQ(reply.Error, false) |
| c.expectEQ(reply.OK, true) |
| } |
| return resp.Reports |
| } |
| |
| func (client *apiClient) updateBug(extID string, status dashapi.BugStatus, dup string) *dashapi.BugUpdateReply { |
| cmd := &dashapi.BugUpdate{ |
| ID: extID, |
| Status: status, |
| DupOf: dup, |
| } |
| reply := new(dashapi.BugUpdateReply) |
| client.expectOK(client.API("reporting_update", cmd, reply)) |
| return reply |
| } |
| |
| type ( |
| EmailOptMessageID int |
| EmailOptFrom string |
| EmailOptCC []string |
| ) |
| |
| func (c *Ctx) incomingEmail(to, body string, opts ...interface{}) { |
| id := 0 |
| from := "default@sender.com" |
| cc := []string{"test@syzkaller.com", "bugs@syzkaller.com"} |
| for _, o := range opts { |
| switch opt := o.(type) { |
| case EmailOptMessageID: |
| id = int(opt) |
| case EmailOptFrom: |
| from = string(opt) |
| case EmailOptCC: |
| cc = []string(opt) |
| } |
| } |
| email := fmt.Sprintf(`Sender: %v |
| Date: Tue, 15 Aug 2017 14:59:00 -0700 |
| Message-ID: <%v> |
| Subject: crash1 |
| From: %v |
| Cc: %v |
| To: %v |
| Content-Type: text/plain |
| |
| %v |
| `, from, id, from, strings.Join(cc, ","), to, body) |
| c.expectOK(c.POST("/_ah/mail/", email)) |
| } |
| |
| func init() { |
| // Mock time as some functionality relies on real time. |
| timeNow = func(c context.Context) time.Time { |
| return getRequestContext(c).mockedTime |
| } |
| sendEmail = func(c context.Context, msg *aemail.Message) error { |
| getRequestContext(c).emailSink <- msg |
| return nil |
| } |
| } |
| |
| // Machinery to associate mocked time with requests. |
| type RequestMapping struct { |
| c context.Context |
| ctx *Ctx |
| } |
| |
| var ( |
| requestMu sync.Mutex |
| requestContexts []RequestMapping |
| ) |
| |
| func registerContext(r *http.Request, c *Ctx) { |
| requestMu.Lock() |
| defer requestMu.Unlock() |
| requestContexts = append(requestContexts, RequestMapping{appengine.NewContext(r), c}) |
| } |
| |
| func getRequestContext(c context.Context) *Ctx { |
| requestMu.Lock() |
| defer requestMu.Unlock() |
| for _, m := range requestContexts { |
| if reflect.DeepEqual(c, m.c) { |
| return m.ctx |
| } |
| } |
| panic(fmt.Sprintf("no context for: %#v", c)) |
| } |
| |
| func unregisterContext(c *Ctx) { |
| requestMu.Lock() |
| defer requestMu.Unlock() |
| n := 0 |
| for _, m := range requestContexts { |
| if m.ctx == c { |
| continue |
| } |
| requestContexts[n] = m |
| n++ |
| } |
| requestContexts = requestContexts[:n] |
| } |