From 802f3f572c5851a59a32652f9430cbc8e752d53e Mon Sep 17 00:00:00 2001 From: Cameron Moore Date: Fri, 30 Oct 2015 21:12:13 -0500 Subject: [PATCH] Add testing framework for main webhook app This commit adds a testing framework modeled after the godoc tests. It builds webhook in a temporary directory, runs it with the supplied `hooks_test.json` configuration, and then tests different payloads. I use `/bin/echo` for the test executable, so I've added build tags to exclude Windows. Three minor (I hope) changes in functionality: - I ended up moving everything from `init()` to `main()` because `init()` was firing while trying to build the tests, and it was dying since `hooks.json` didn't exist. I'm still not 100% sure `init()` was firing, but I didn't see any real need for anything to be in `init()` in the first place. - make sure logger is using `os.Stderr` - don't send `http.StatusBadRequest` when the Hook rules don't match. "Bad Request" is used to identify malformed requests. The request was properly formed and processed, so I think we should send back `http.StatusOK`. For example, if I setup a webhook rule to only execute when commits are made to the `master` branch, we shouldn't send back `http.StatusBadRequest` when we ingest a payload for the `development` branch. The test payloads are pretty verbose and could probably be shortened, but I kind of like having an example payload for each service. We can pare them down if we want to do more focused, minimalist testing. --- hooks_test.json | 135 ++++++++++++++++++ webhook.go | 7 +- webhook_test.go | 368 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 hooks_test.json create mode 100644 webhook_test.go diff --git a/hooks_test.json b/hooks_test.json new file mode 100644 index 0000000..49b9f4d --- /dev/null +++ b/hooks_test.json @@ -0,0 +1,135 @@ +[ + { + "id": "github", + "execute-command": "/bin/echo", + "command-working-directory": "/", + "include-command-output-in-response": true, + "pass-arguments-to-command": + [ + { + "source": "payload", + "name": "head_commit.id" + }, + { + "source": "payload", + "name": "pusher.name" + }, + { + "source": "payload", + "name": "pusher.email" + } + ], + "trigger-rule": + { + "and": + [ + { + "match": + { + "type": "payload-hash-sha1", + "secret": "mysecret", + "parameter": + { + "source": "header", + "name": "X-Hub-Signature" + } + } + }, + { + "match": + { + "type": "value", + "value": "refs/heads/master", + "parameter": + { + "source": "payload", + "name": "ref" + } + } + } + ] + } + }, + { + "id": "bitbucket", + "execute-command": "/bin/echo", + "command-working-directory": "/", + "include-command-output-in-response": true, + "response-message": "success", + "parse-parameters-as-json": [ + { + "source": "payload", + "name": "payload" + } + ], + "trigger-rule": { + "and": [ + { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "payload.canon_url" + }, + "value": "https://bitbucket.org" + } + }, + { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "payload.repository.absolute_url" + }, + "value": "/webhook/testing/" + } + }, + { + "match": { + "type": "value", + "parameter": { + "source": "payload", + "name": "payload.commits.0.branch" + }, + "value": "master" + } + } + ] + } + }, + { + "id": "gitlab", + "execute-command": "/bin/echo", + "command-working-directory": "/", + "response-message": "success", + "include-command-output-in-response": true, + "pass-arguments-to-command": + [ + { + "source": "payload", + "name": "commits.0.id" + }, + { + "source": "payload", + "name": "user_name" + }, + { + "source": "payload", + "name": "user_email" + } + ], + "trigger-rule": + { + "match": + { + "type": "value", + "value": "refs/heads/master", + "parameter": + { + "source": "payload", + "name": "ref" + } + } + } + } +] diff --git a/webhook.go b/webhook.go index 5d18de0..2c8c541 100644 --- a/webhook.go +++ b/webhook.go @@ -46,7 +46,7 @@ var ( hooks hook.Hooks ) -func init() { +func main() { hooks = hook.Hooks{} flag.Parse() @@ -87,9 +87,7 @@ func init() { log.Printf("\t> %s\n", hook.ID) } } -} -func main() { if *hotReload { // set up file watcher log.Printf("setting up file watcher for %s\n", *hooksFilePath) @@ -112,7 +110,7 @@ func main() { } l := negroni.NewLogger() - l.Logger = log.New(os.Stdout, "[webhook] ", log.Ldate|log.Ltime) + l.Logger = log.New(os.Stderr, "[webhook] ", log.Ldate|log.Ltime) negroniRecovery := &negroni.Recovery{ Logger: l.Logger, @@ -233,7 +231,6 @@ func hookHandler(w http.ResponseWriter, r *http.Request) { // if none of the hooks got triggered log.Printf("%s got matched (%d time(s)), but didn't get triggered because the trigger rules were not satisfied\n", matchedHooks[0].ID, len(matchedHooks)) - w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, "Hook rules were not satisfied.") } else { w.WriteHeader(http.StatusNotFound) diff --git a/webhook_test.go b/webhook_test.go new file mode 100644 index 0000000..2b71b53 --- /dev/null +++ b/webhook_test.go @@ -0,0 +1,368 @@ +// +build !windows + +package main + +import ( + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestWebhook(t *testing.T) { + bin, cleanup := buildWebhook(t) + defer cleanup() + + ip, port := serverAddress(t) + args := []string{"-hooks=hooks_test.json", fmt.Sprintf("-ip=%s", ip), fmt.Sprintf("-port=%s", port), "-verbose"} + + cmd := exec.Command(bin, args...) + //cmd.Stderr = os.Stderr // uncomment to see verbose output + cmd.Args[0] = "webhook" + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start webhook: %s", err) + } + defer killAndWait(cmd) + + waitForServerReady(t, ip, port) + + for _, tt := range hookHandlerTests { + url := fmt.Sprintf("http://%s:%s/hooks/%s", ip, port, tt.id) + + req, err := http.NewRequest("POST", url, ioutil.NopCloser(strings.NewReader(tt.body))) + if err != nil { + t.Errorf("New request failed: %s", err) + } + + if tt.headers != nil { + for k, v := range tt.headers { + req.Header.Add(k, v) + } + } + + var res *http.Response + + if tt.urlencoded == true { + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + } else { + req.Header.Add("Content-Type", "application/json") + } + + client := &http.Client{} + res, err = client.Do(req) + if err != nil { + t.Errorf("client.Do failed: %s", err) + } + + body, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Errorf("POST %q: failed to ready body: %s", tt.desc, err) + } + + if res.StatusCode != tt.respStatus || string(body) != tt.respBody { + t.Errorf("failed %q (id: %s):\nexpected status: %#v, response: %s\ngot status: %#v, response: %s", tt.desc, tt.id, tt.respStatus, tt.respBody, res.StatusCode, body) + } + } +} + +func buildWebhook(t *testing.T) (bin string, cleanup func()) { + tmp, err := ioutil.TempDir("", "webhook-test-") + if err != nil { + t.Fatal(err) + } + defer func() { + if cleanup == nil { + os.RemoveAll(tmp) + } + }() + + bin = filepath.Join(tmp, "webhook") + if runtime.GOOS == "windows" { + bin += ".exe" + } + + cmd := exec.Command("go", "build", "-o", bin) + if err := cmd.Run(); err != nil { + t.Fatalf("Building webhook: %v", err) + } + + return bin, func() { os.RemoveAll(tmp) } +} + +func serverAddress(t *testing.T) (string, string) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + ln, err = net.Listen("tcp6", "[::1]:0") + } + if err != nil { + t.Fatal(err) + } + defer ln.Close() + host, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatalf("Failed to split network address: %v", err) + } + return host, port +} + +func waitForServerReady(t *testing.T, ip, port string) { + waitForServer(t, + fmt.Sprintf("http://%v:%v/", ip, port), + http.StatusNotFound, + 5*time.Second) +} + +const pollInterval = 200 * time.Millisecond + +func waitForServer(t *testing.T, url string, status int, timeout time.Duration) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + res, err := http.Get(url) + if err != nil { + continue + } + if res.StatusCode == status { + return + } + } + t.Fatalf("Server failed to respond in %v", timeout) +} + +func killAndWait(cmd *exec.Cmd) { + cmd.Process.Kill() + cmd.Wait() +} + +var hookHandlerTests = []struct { + desc string + id string + headers map[string]string + body string + urlencoded bool + + respStatus int + respBody string +}{ + { + "github", + "github", + map[string]string{"X-Hub-Signature": "f68df0375d7b03e3eb29b4cf9f9ec12e08f42ff8"}, + `{ + "after":"1481a2de7b2a7d02428ad93446ab166be7793fbb", + "before":"17c497ccc7cca9c2f735aa07e9e3813060ce9a6a", + "commits":[ + { + "added":[ + + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"c441029cf673f84c8b7db52d0a5944ee5c52ff89", + "message":"Test", + "modified":[ + "README.md" + ], + "removed":[ + + ], + "timestamp":"2013-02-22T13:50:07-08:00", + "url":"https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89" + }, + { + "added":[ + + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"36c5f2243ed24de58284a96f2a643bed8c028658", + "message":"This is me testing the windows client.", + "modified":[ + "README.md" + ], + "removed":[ + + ], + "timestamp":"2013-02-22T14:07:13-08:00", + "url":"https://github.com/octokitty/testing/commit/36c5f2243ed24de58284a96f2a643bed8c028658" + }, + { + "added":[ + "words/madame-bovary.txt" + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"1481a2de7b2a7d02428ad93446ab166be7793fbb", + "message":"Rename madame-bovary.txt to words/madame-bovary.txt", + "modified":[ + + ], + "removed":[ + "madame-bovary.txt" + ], + "timestamp":"2013-03-12T08:14:29-07:00", + "url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb" + } + ], + "compare":"https://github.com/octokitty/testing/compare/17c497ccc7cc...1481a2de7b2a", + "created":false, + "deleted":false, + "forced":false, + "head_commit":{ + "added":[ + "words/madame-bovary.txt" + ], + "author":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "committer":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian", + "username":"octokitty" + }, + "distinct":true, + "id":"1481a2de7b2a7d02428ad93446ab166be7793fbb", + "message":"Rename madame-bovary.txt to words/madame-bovary.txt", + "modified":[ + + ], + "removed":[ + "madame-bovary.txt" + ], + "timestamp":"2013-03-12T08:14:29-07:00", + "url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb" + }, + "pusher":{ + "email":"lolwut@noway.biz", + "name":"Garen Torikian" + }, + "ref":"refs/heads/master", + "repository":{ + "created_at":1332977768, + "description":"", + "fork":false, + "forks":0, + "has_downloads":true, + "has_issues":true, + "has_wiki":true, + "homepage":"", + "id":3860742, + "language":"Ruby", + "master_branch":"master", + "name":"testing", + "open_issues":2, + "owner":{ + "email":"lolwut@noway.biz", + "name":"octokitty" + }, + "private":false, + "pushed_at":1363295520, + "size":2156, + "stargazers":1, + "url":"https://github.com/octokitty/testing", + "watchers":1 + } + }`, + false, + http.StatusOK, + `{"message":"","output":"1481a2de7b2a7d02428ad93446ab166be7793fbb Garen Torikian lolwut@noway.biz\n","error":""}`, + }, + { + "bitbucket", // bitbucket sends their payload using uriencoded params. + "bitbucket", + nil, + `payload={"canon_url": "https://bitbucket.org","commits": [{"author": "marcus","branch": "master","files": [{"file": "somefile.py","type": "modified"}],"message": "Added some more things to somefile.py\n","node": "620ade18607a","parents": ["702c70160afc"],"raw_author": "Marcus Bertrand ","raw_node": "620ade18607ac42d872b568bb92acaa9a28620e9","revision": null,"size": -1,"timestamp": "2012-05-30 05:58:56","utctimestamp": "2014-11-07 15:19:02+00:00"}],"repository": {"absolute_url": "/webhook/testing/","fork": false,"is_private": true,"name": "Project X","owner": "marcus","scm": "git","slug": "project-x","website": "https://atlassian.com/"},"user": "marcus"}`, + true, + http.StatusOK, + `{"message":"success","output":"\n","error":""}`, + }, + { + "gitlab", + "gitlab", + map[string]string{"X-Gitlab-Event": "Push Hook"}, + `{ + "object_kind": "push", + "before": "95790bf891e76fee5e1747ab589903a6a1f80f22", + "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "ref": "refs/heads/master", + "user_id": 4, + "user_name": "John Smith", + "user_email": "john@example.com", + "project_id": 15, + "repository": { + "name": "Diaspora", + "url": "git@example.com:mike/diasporadiaspora.git", + "description": "", + "homepage": "http://example.com/mike/diaspora", + "git_http_url":"http://example.com/mike/diaspora.git", + "git_ssh_url":"git@example.com:mike/diaspora.git", + "visibility_level":0 + }, + "commits": [ + { + "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "message": "Update Catalan translation to e38cb41.", + "timestamp": "2011-12-12T14:27:31+02:00", + "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", + "author": { + "name": "Jordi Mallach", + "email": "jordi@softcatala.org" + } + }, + { + "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "message": "fixed readme", + "timestamp": "2012-01-03T23:36:29+02:00", + "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", + "author": { + "name": "GitLab dev user", + "email": "gitlabdev@dv6700.(none)" + } + } + ], + "total_commits_count": 4 + }`, + false, + http.StatusOK, + `{"message":"success","output":"b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327 John Smith john@example.com\n","error":""}`, + }, + + {"empty payload", "github", nil, `{}`, false, http.StatusOK, `Hook rules were not satisfied.`}, +}