Commit Diff


commit - d40addaae71726e78912c56b7b2b14a4795fe7e4
commit + 97858f5c321756c6495a682f6913b59211b51a96
blob - fc2b64876b2b20537494f7fa72e0f2a19756ca54
blob + 4638c10ed322c7d77ce9a38b39a234f80a172047
--- gotd/gotd.conf.5
+++ gotd/gotd.conf.5
@@ -333,7 +333,7 @@ The
 and
 .Ic port
 directives can be used to specify a different SMTP server address and port.
-.It Ic url Ar URL Oo Ic user Ar user Ic password Ar password Oo Ic insecure Oc Oc
+.It Ic url Ar URL Oo Ic user Ar user Ic password Ar password Oo Ic insecure Oc Oc Oo Ic hmac Ar secret Oc 
 Send notifications via HTTP.
 This directive may be specified multiple times to build a list of
 HTTP servers to send notifications to.
@@ -368,6 +368,19 @@ must be a
 .Dq https://
 URL to avoid leaking of authentication credentials.
 .Pp
+If a
+.Ic hmac
+.Ar secret
+is provided, the request body will be signed using HMAC, allowing the
+receiver to verify the notification message's authenticity and integrity.
+The signature uses HMAC-SHA256 and will be sent in the HTTP header
+.Dq HTTP_X_GOTD_SIGNATURE_256 .
+Suitable secrets can be generated with
+.Xr openssl 1
+as follows:
+.Pp
+.Dl $ openssl rand -base64 32
+.Pp
 The request body contains a JSON object with a
 .Dq notifications
 property containing an array of notification objects.
blob - 127286a48554f9687ba552171a77f0e02cb100f7
blob + 9f241a410e3a686fa14a79fae05f48c7bc955556
--- gotd/gotd.h
+++ gotd/gotd.h
@@ -115,6 +115,7 @@ struct gotd_notification_target {
 			char *path;
 			char *user;
 			char *password;
+			char *hmac_secret;
 		} http;
 	} conf;
 };
blob - 66d39b63e3d3fd27e90584c6189567206564c049
blob + 81a563d1b9541e72594736ccf73c37af464ef9a7
--- gotd/libexec/got-notify-http/got-notify-http.c
+++ gotd/libexec/got-notify-http/got-notify-http.c
@@ -19,6 +19,7 @@
 #include <sys/time.h>
 #include <sys/types.h>
 #include <sys/socket.h>
+#include <sys/queue.h>
 
 #include <err.h>
 #include <errno.h>
@@ -26,6 +27,8 @@
 #include <limits.h>
 #include <netdb.h>
 #include <poll.h>
+#include <sha1.h>
+#include <sha2.h>
 #include <stdarg.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -34,9 +37,15 @@
 #include <time.h>
 #include <unistd.h>
 
+#include <openssl/evp.h>
+#include <openssl/hmac.h>
+
 #include "got_opentemp.h"
 #include "got_version.h"
+#include "got_object.h"
 
+#include "got_lib_hash.h"
+
 #include "bufio.h"
 #include "log.h"
 #include "utf8d.h"
@@ -838,7 +847,62 @@ bufio2poll(struct bufio *bio)
 		ret |= POLLOUT;
 	return ret;
 }
+
+static unsigned char *
+compute_hmac_sha256(FILE *payload, off_t paylen, const char *hmac_secret,
+    size_t secret_len, unsigned char *hmac_sig_buf, unsigned int *hmac_siglen)
+{
+	HMAC_CTX *ctx;
+	char buf[4096];
+	off_t n;
+	ssize_t r;
 
+	*hmac_siglen = 0;
+
+	ctx = HMAC_CTX_new();
+	if (ctx == NULL) {
+		log_warn("HMAC_CTX_new");
+		return NULL;
+	}
+
+	if (!HMAC_Init_ex(ctx, hmac_secret, secret_len, EVP_sha256(), NULL)) {
+		log_warn("HMAC_Init_ex");
+		goto fail;
+	}
+
+	n = paylen;
+	while (n > 0) {
+		r = fread(buf, 1, n > sizeof(buf) ? sizeof(buf) : n, payload);
+		if (r == 0) {
+			if (feof(payload)) {
+				log_warnx("HMAC payload truncated");
+				goto fail;
+			}
+			log_warnx("reading HMAC payload: %s",
+			    strerror(ferror(payload)));
+			goto fail;
+		}
+		if (!HMAC_Update(ctx, buf, r)) {
+			log_warn("HMAC_Update");
+			goto fail;
+		}
+		n -= r;
+	}
+
+	if (!HMAC_Final(ctx, hmac_sig_buf, hmac_siglen)) {
+		log_warn("HMAC_Final");
+		goto fail;
+	}
+
+	*hmac_siglen = HMAC_size(ctx);
+
+	HMAC_CTX_free(ctx);
+	return hmac_sig_buf;
+fail:
+	HMAC_CTX_free(ctx);
+	return NULL;
+}
+
 int
 main(int argc, char **argv)
 {
@@ -849,11 +913,16 @@ main(int argc, char **argv)
 	const char	*username;
 	const char	*password;
 	const char	*timeoutstr;
+	const char	*hmac_secret;
 	const char	*errstr;
 	const char	*repo = NULL;
 	const char	*host = NULL, *port = NULL, *path = NULL;
 	const char	*gotd_auth_user = NULL;
 	char		*auth, *line, *spc;
+	unsigned char	*hmac_sig = NULL;
+	unsigned char	 hmac_sig_buf[EVP_MAX_MD_SIZE];
+	unsigned int	 hmac_siglen;
+	char		 hex[SHA256_DIGEST_STRING_LENGTH];
 	size_t		 len;
 	ssize_t		 r;
 	off_t		 paylen;
@@ -935,7 +1004,20 @@ main(int argc, char **argv)
 	if (pledge("stdio rpath dns inet", NULL) == -1)
 		err(1, "pledge");
 #endif
+	hmac_secret = getenv("GOT_NOTIFY_HTTP_HMAC_SECRET");
+	if (hmac_secret) {
+		hmac_sig = compute_hmac_sha256(tmpfp, paylen, hmac_secret,
+		    strlen(hmac_secret), hmac_sig_buf, &hmac_siglen);
+		if (hmac_sig == NULL || hmac_siglen != SHA256_DIGEST_LENGTH)
+			fatalx("HMAC computation failed");
+		if (got_sha256_digest_to_str(hmac_sig, hex, sizeof(hex))
+		    == NULL)
+			fatalx("HMAC conversion to hex string failed");
 
+		if (fseeko(tmpfp, 0, SEEK_SET) == -1)
+			fatal("fseeko");
+	}
+
 	memset(&pfd, 0, sizeof(pfd));
 	pfd.fd = dial(host, port);
 
@@ -972,10 +1054,15 @@ main(int argc, char **argv)
 	    "Content-Type: application/json\r\n"
 	    "Content-Length: %lld\r\n"
 	    "User-Agent: %s\r\n"
-	    "Connection: close\r\n",
+	    "Connection: close\r\n"
+	    "%s%s%s%s",
 	    path, host,
 	    nonstd ? ":" : "", nonstd ? port : "",
-	    (long long)paylen, USERAGENT);
+	    (long long)paylen, USERAGENT,
+	    hmac_sig ? "HTTP_X_GOTD_SIGNATURE_256: " : "",
+	    hmac_sig ? "sha256=" : "",
+	    hmac_sig ? hex : "",
+	    hmac_sig ? "\r\n" : "");
 	if (ret == -1)
 		fatal("bufio_compose_fmt");
 
blob - e57194c1b439e147659de38acc77b1ddfab8c286
blob + b70d407163a937ee506422c8fdcdfefe7155a6ba
--- gotd/notify.c
+++ gotd/notify.c
@@ -162,7 +162,7 @@ gotd_notify_sighdlr(int sig, short event, void *arg)
 
 static void
 run_notification_helper(const char *prog, const char **argv, int fd,
-    const char *user, const char *pass)
+    const char *user, const char *pass, const char *hmac_secret)
 {
 	const struct got_error *err = NULL;
 	pid_t pid;
@@ -190,6 +190,11 @@ run_notification_helper(const char *prog, const char *
 			setenv("GOT_NOTIFY_HTTP_USER", user, 1);
 			setenv("GOT_NOTIFY_HTTP_PASS", pass, 1);
 		}
+
+		if (hmac_secret)
+			setenv("GOT_NOTIFY_HTTP_HMAC_SECRET", hmac_secret, 1);
+		else
+			unsetenv("GOT_NOTIFY_HTTP_HMAC_SECRET");
 
 		if (execv(prog, (char *const *)argv) == -1) {
 			fprintf(stderr, "%s: exec %s: %s\n", getprogname(),
@@ -256,7 +261,7 @@ notify_email(struct gotd_notification_target *target, 
 	argv[i] = NULL;
 
 	run_notification_helper(GOTD_PATH_PROG_NOTIFY_EMAIL, argv, fd,
-	    NULL, NULL);
+	    NULL, NULL, NULL);
 }
 
 static void
@@ -284,7 +289,8 @@ notify_http(struct gotd_notification_target *target, c
 	argv[argc] = NULL;
 
 	run_notification_helper(GOTD_PATH_PROG_NOTIFY_HTTP, argv, fd,
-	    target->conf.http.user, target->conf.http.password);
+	    target->conf.http.user, target->conf.http.password,
+	    target->conf.http.hmac_secret);
 }
 
 static const struct got_error *
blob - d8c3ed4f6c061359778be4ea784b59494d658587
blob + 22e5ac55f023330a537ca5055c6edba55f97b782
--- gotd/parse.y
+++ gotd/parse.y
@@ -111,7 +111,7 @@ static int			 conf_notify_ref_namespace(struct gotd_re
 static int			 conf_notify_email(struct gotd_repo *,
 				    char *, char *, char *, char *, char *);
 static int			 conf_notify_http(struct gotd_repo *,
-				    char *, char *, char *, int);
+				    char *, char *, char *, int, char *);
 static enum gotd_procid		 gotd_proc_id;
 
 typedef struct {
@@ -128,7 +128,7 @@ typedef struct {
 %token	PATH ERROR LISTEN ON USER REPOSITORY PERMIT DENY
 %token	RO RW CONNECTION LIMIT REQUEST TIMEOUT
 %token	PROTECT NAMESPACE BRANCH TAG REFERENCE RELAY PORT
-%token	NOTIFY EMAIL FROM REPLY TO URL PASSWORD INSECURE
+%token	NOTIFY EMAIL FROM REPLY TO URL PASSWORD INSECURE HMAC
 
 %token	<v.string>	STRING
 %token	<v.number>	NUMBER
@@ -611,7 +611,7 @@ notifyflags	: BRANCH STRING {
 			    gotd_proc_id == PROC_SESSION_WRITE ||
 			    gotd_proc_id == PROC_NOTIFY) {
 				if (conf_notify_http(new_repo, $2, NULL,
-				    NULL, 0)) {
+				    NULL, 0, NULL)) {
 					free($2);
 					YYERROR;
 				}
@@ -622,7 +622,8 @@ notifyflags	: BRANCH STRING {
 			if (gotd_proc_id == PROC_GOTD ||
 			    gotd_proc_id == PROC_SESSION_WRITE ||
 			    gotd_proc_id == PROC_NOTIFY) {
-				if (conf_notify_http(new_repo, $2, $4, $6, 0)) {
+				if (conf_notify_http(new_repo, $2, $4, $6, 0,
+				    NULL)) {
 					free($2);
 					free($4);
 					free($6);
@@ -637,16 +638,67 @@ notifyflags	: BRANCH STRING {
 			if (gotd_proc_id == PROC_GOTD ||
 			    gotd_proc_id == PROC_SESSION_WRITE ||
 			    gotd_proc_id == PROC_NOTIFY) {
-				if (conf_notify_http(new_repo, $2, $4, $6, 1)) {
+				if (conf_notify_http(new_repo, $2, $4, $6, 1,
+				    NULL)) {
+					free($2);
+					free($4);
+					free($6);
+					YYERROR;
+				}
+			}
+			free($2);
+			free($4);
+			free($6);
+		}
+		| URL STRING HMAC STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_http(new_repo, $2, NULL,
+				    NULL, 0, $4)) {
+					free($2);
+					free($4);
+					YYERROR;
+				}
+			}
+			free($2);
+			free($4);
+		}
+		| URL STRING USER STRING PASSWORD STRING HMAC STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_http(new_repo, $2, $4, $6, 0,
+				    $8)) {
+					free($2);
+					free($4);
+					free($6);
+					free($8);
+					YYERROR;
+				}
+			}
+			free($2);
+			free($4);
+			free($6);
+			free($8);
+		}
+		| URL STRING USER STRING PASSWORD STRING INSECURE HMAC STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_http(new_repo, $2, $4, $6, 1,
+				    $9)) {
 					free($2);
 					free($4);
 					free($6);
+					free($9);
 					YYERROR;
 				}
 			}
 			free($2);
 			free($4);
 			free($6);
+			free($9);
 		}
 		;
 	
@@ -792,6 +844,7 @@ lookup(char *s)
 		{ "deny",			DENY },
 		{ "email",			EMAIL },
 		{ "from",			FROM },
+		{ "hmac",			HMAC },
 		{ "insecure",			INSECURE },
 		{ "limit",			LIMIT },
 		{ "listen",			LISTEN },
@@ -1562,7 +1615,7 @@ conf_notify_email(struct gotd_repo *repo, char *sender
 
 static int
 conf_notify_http(struct gotd_repo *repo, char *url, char *user, char *password,
-    int insecure)
+    int insecure, char *hmac_secret)
 {
 	const struct got_error *error;
 	struct gotd_notification_target *target;
@@ -1645,6 +1698,11 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch
 		if (target->conf.http.password == NULL)
 			fatal("strdup");
 	}	
+	if (hmac_secret) {
+		target->conf.http.hmac_secret = strdup(hmac_secret);
+		if (target->conf.http.hmac_secret == NULL)
+			fatal("strdup");
+	}
 
 	STAILQ_INSERT_TAIL(&repo->notification_targets, target, entry);
 done:
blob - 4897b27e2bbd6fa672d2a449a5b25ad65e303850
blob + 184054f12c04e1cd6db0a1f82dff27b5bcbb2de3
--- regress/gotd/Makefile
+++ regress/gotd/Makefile
@@ -6,7 +6,8 @@ REGRESS_TARGETS=test_repo_read test_repo_read_group \
 	test_repo_write test_repo_write_empty test_request_bad \
 	test_repo_write_protected test_repo_write_readonly \
 	test_email_notification test_http_notification \
-	test_git_interop test_email_and_http_notification
+	test_git_interop test_email_and_http_notification \
+	test_http_notification_hmac
 NOOBJ=Yes
 CLEANFILES=gotd.conf
 
@@ -20,6 +21,7 @@ GOTD_TEST_REPO_NAME=test-repo
 GOTD_TEST_REPO_URL=ssh://${GOTD_DEVUSER}@127.0.0.1/$(GOTD_TEST_REPO_NAME)
 GOTD_TEST_SMTP_PORT=2525
 GOTD_TEST_HTTP_PORT=8000
+GOTD_TEST_HMAC_SECRET=test1234
 
 GOTD_TEST_USER?=${DOAS_USER}
 .if empty(GOTD_TEST_USER)
@@ -58,6 +60,7 @@ GOTD_TEST_ENV=GOTD_TEST_ROOT=$(GOTD_TEST_ROOT) \
 	GOTD_CONF=$(PWD)/gotd.conf \
 	GOTD_TEST_SMTP_PORT=$(GOTD_TEST_SMTP_PORT) \
 	GOTD_TEST_HTTP_PORT=$(GOTD_TEST_HTTP_PORT) \
+	GOTD_TEST_HMAC_SECRET=$(GOTD_TEST_HMAC_SECRET) \
 	HOME=$(GOTD_TEST_USER_HOME) \
 	PATH=$(GOTD_TEST_USER_HOME)/bin:$(PATH)
 
@@ -211,6 +214,18 @@ start_gotd_email_and_http_notification: ensure_root
 	@$(GOTD_TRAP); $(GOTD_START_CMD)
 	@$(GOTD_TRAP); sleep .5
 
+start_gotd_http_notification_hmac: ensure_root
+	@echo 'listen on "$(GOTD_SOCK)"' > $(PWD)/gotd.conf
+	@echo "user $(GOTD_USER)" >> $(PWD)/gotd.conf
+	@echo 'repository "test-repo" {' >> $(PWD)/gotd.conf
+	@echo '    path "$(GOTD_TEST_REPO)"' >> $(PWD)/gotd.conf
+	@echo '    permit rw $(GOTD_DEVUSER)' >> $(PWD)/gotd.conf
+	@echo '    notify {' >> $(PWD)/gotd.conf
+	@echo '         url "http://localhost:${GOTD_TEST_HTTP_PORT}/" user flan password "password" insecure hmac "${GOTD_TEST_HMAC_SECRET}"' >> $(PWD)/gotd.conf
+	@echo "    }" >> $(PWD)/gotd.conf
+	@echo "}" >> $(PWD)/gotd.conf
+	@$(GOTD_TRAP); $(GOTD_START_CMD)
+	@$(GOTD_TRAP); sleep .5
 prepare_test_repo: ensure_root
 	@chown ${GOTD_USER} "${GOTD_TEST_REPO}"
 	@su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./prepare_test_repo.sh'
@@ -294,6 +309,11 @@ test_http_notification: prepare_test_repo start_gotd_h
 		'env $(GOTD_TEST_ENV) sh ./http_notification.sh'
 	@$(GOTD_STOP_CMD) 2>/dev/null
 
+test_http_notification_hmac: prepare_test_repo start_gotd_http_notification_hmac
+	@-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \
+		'env $(GOTD_TEST_ENV) sh ./http_notification_hmac.sh'
+	@$(GOTD_STOP_CMD) 2>/dev/null
+
 test_email_and_http_notification: prepare_test_repo start_gotd_email_and_http_notification
 	@-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \
 		'env $(GOTD_TEST_ENV) sh ./http_notification.sh test_file_changed'
blob - 4e5c767b884376a69d6df25e19f47a8c53829fba
blob + a2518bf858a18dbc66fcb6ad278ee0cfc54f9a8c
--- regress/gotd/README
+++ regress/gotd/README
@@ -51,7 +51,7 @@ sshd must be restarted for configuration changes to ta
 
 The server test suite can now be run from the top-level directory:
 
- $ doas pkg_add git p5-http-daemon
+ $ doas pkg_add git p5-http-daemon p5-digest-hmac
  $ doas make server-regress
 
 The suite must be started as root in order to be able to start and stop gotd.
blob - a9297fb4c098dec8fe8f0e0da89a4516a4ddaecc
blob + 800393052afde0178d8c080fbbf7ab7dffd883e8
--- regress/gotd/http-server
+++ regress/gotd/http-server
@@ -17,12 +17,17 @@
 use v5.36;
 use IPC::Open2;
 use Getopt::Long qw(:config bundling);
+use Digest;
+use Digest::HMAC;
 
 my $auth;
 my $port = 8000;
+my $hmac_secret;
+my $hmac_signature;
+my $hmac;
 
-GetOptions("a:s" => \$auth, "p:i" => \$port)
-    or die("usage: $0 [-a auth] [-p port]\n");
+GetOptions("a:s" => \$auth, "p:i" => \$port, "s:s" => \$hmac_secret)
+    or die("usage: $0 [-a auth] [-p port] [-s hmac_secret]\n");
 
 my $pid = open2(my $out, my $in, 'nc', '-l', 'localhost', $port);
 
@@ -71,10 +76,23 @@ while (<$out>) {
 		    if not defined($auth) or $auth ne $t;
 		next;
 	}
+
+	if (m/HTTP_X_GOTD_SIGNATURE_256/) {
+		die "bad hmac signature header"
+		    unless m/HTTP_X_GOTD_SIGNATURE_256: sha256=(.*)$/;
+		$hmac_signature = $1;
+		next;
+	}
 }
 
 die "no Content-Length header" unless defined $clen;
 
+if (defined $hmac_signature) {
+	die "no HMAC secret provided" if not (defined $hmac_secret);
+	my $sha256 = Digest->new("SHA-256");
+	$hmac = Digest::HMAC->new($hmac_secret, $sha256);
+}
+
 while ($clen != 0) {
 	my $len = $clen;
 	$len = 512 if $clen > 512;
@@ -82,10 +100,21 @@ while ($clen != 0) {
 	my $r = read($out, my $buf, $len);
 	$clen -= $r;
 
+	if (defined $hmac) {
+		$hmac->add($buf);
+	}
+
 	print $buf;
 }
 say "";
 
+if (defined $hmac) {
+	my $digest = $hmac->hexdigest;
+	if ($digest ne $hmac_signature) {
+		die "bad hmac signature: expected: $hmac_signature, actual: $digest";
+	}
+}
+
 print $in "HTTP/1.1 200 OK\r\n";
 print $in "Content-Length: 0\r\n";
 print $in "Connection: close\r\n";
blob - /dev/null
blob + 486096684215bcc49c73b7826b11ec392222db4f (mode 644)
--- /dev/null
+++ regress/gotd/http_notification_hmac.sh
@@ -0,0 +1,114 @@
+#!/bin/sh
+#
+# Copyright (c) 2024 Omar Polo <op@openbsd.org>
+# Copyright (c) 2024 Stefan Sperling <stsp@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+. ./common.sh
+
+# flan:password encoded in base64
+AUTH="ZmxhbjpwYXNzd29yZA=="
+
+test_file_changed() {
+	local testroot=`test_init file_changed 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo "change alpha" > $testroot/wt/alpha
+	(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+	local commit_id=`git_show_head $testroot/repo-clone`
+	local author_time=`git_show_author_time $testroot/repo-clone`
+
+	timeout 5 ./http-server -a $AUTH -p $GOTD_TEST_HTTP_PORT \
+		-s $GOTD_TEST_HMAC_SECRET > $testroot/stdout &
+
+	sleep 1 # server starts up
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for the http "server"
+
+	touch "$testroot/stdout.expected"
+	ed -s "$testroot/stdout.expected" <<-EOF
+	a
+	{"notifications":[{
+		"type":"commit",
+		"short":false,
+		"repo":"test-repo",
+		"authenticated_user":"${GOTD_DEVUSER}",
+		"id":"$commit_id",
+		"author":{
+			"full":"$GOT_AUTHOR",
+			"name":"$GIT_AUTHOR_NAME",
+			"mail":"$GIT_AUTHOR_EMAIL",
+			"user":"$GOT_AUTHOR_11"
+		},
+		"committer":{
+			"full":"$GOT_AUTHOR",
+			"name":"$GIT_AUTHOR_NAME",
+			"mail":"$GIT_AUTHOR_EMAIL",
+			"user":"$GOT_AUTHOR_11"
+		},
+		"date":$author_time,
+		"short_message":"make changes",
+		"message":"make changes\n",
+		"diffstat":{
+			"files":[{
+				"action":"modified",
+				"file":"alpha",
+				"added":1,
+				"removed":1
+			}],
+			"total":{
+				"added":1,
+				"removed":1
+			}
+		}
+	}]}
+	.
+	,j
+	w
+	EOF
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+	fi
+	test_done "$testroot" "$ret"
+}
+
+test_parseargs "$@"
+run_test test_file_changed