"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Stefan Sperling <stsp@stsp.name>
Subject:
HMAC digest for gotd HTTP notifications
To:
gameoftrees@openbsd.org
Date:
Mon, 19 Aug 2024 12:08:58 +0200

Download raw body.

Thread
Add support for HMAC digests to allow authenticating and
verifying gotd HTTP notifications.

HMAC is used by several Git forges to authenticate their webhooks,
rather than using basic auth. It makes sense for gotd to provide
a similar scheme in addition to basic authentication. Even though
at present none of the forges support gotd notifications, having
this feature available might help with having such support in
some forges in the future.

The way it works is based on forgejo's docs:
https://forgejo.org/docs/latest/user/webhooks/
We provide a custom HTTP header which contains a sha256 HMAC digest,
which can be verified by the recipient if they know the shared secret.
A new regression test verifies the digest in Perl, which can also
serve as a general example of how to implement verification.

Our manual page suggests using openssl rand to generate secrets.
I hope this is enough to ensure that good secrets will be used,
though it's entirely up to the admin of gotd to set this up securely.

Feature addition suggested by dch@freebsd.org in private mail.

ok?

M  gotd/gotd.conf.5                                |   14+  1-
M  gotd/gotd.h                                     |    1+  0-
M  gotd/libexec/got-notify-http/Makefile           |    2+  2-
M  gotd/libexec/got-notify-http/got-notify-http.c  |   89+  2-
M  gotd/notify.c                                   |    9+  3-
M  gotd/parse.y                                    |   64+  6-
M  regress/gotd/Makefile                           |   21+  1-
M  regress/gotd/README                             |    1+  1-
M  regress/gotd/http-server                        |   31+  2-
A  regress/gotd/http_notification_hmac.sh          |  114+  0-

10 files changed, 346 insertions(+), 18 deletions(-)

commit - bc7dcd6603946972172d2e08412488029d34759a
commit + 5dba089cd36617a108a1b7b28b178db09084af06
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 - c51b69d2d80fd63e6ed9b528b0fb749a764c65d7
blob + 4c8e79a9beb60c52b4a922d424c3d7db0000dfb5
--- gotd/gotd.h
+++ gotd/gotd.h
@@ -112,6 +112,7 @@ struct gotd_notification_target {
 			char *path;
 			char *user;
 			char *password;
+			char *hmac_secret;
 		} http;
 	} conf;
 };
blob - 1b2e7a4c9e059ff71925c7d37574891474f4b03a
blob + 0088ca5969d4d02be4a1843978465c7ceddec283
--- gotd/libexec/got-notify-http/Makefile
+++ gotd/libexec/got-notify-http/Makefile
@@ -8,7 +8,7 @@ SRCS=	got-notify-http.c bufio.c opentemp.c pollfd.c er
 
 CPPFLAGS= -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib -I${.CURDIR}/../..
 
-DPADD=	${LIBTLS}
-LDADD=	-ltls
+DPADD=	${LIBTLS} ${LIBCRYPTO}
+LDADD=	-ltls -lcrypto
 
 .include <bsd.prog.mk>
blob - 1869c8bbbc78dc4836ee3e49a118ae6bd8caf3e3
blob + a181d871b13eedee0dc5684d90fb2bdab9e16c42
--- gotd/libexec/got-notify-http/got-notify-http.c
+++ gotd/libexec/got-notify-http/got-notify-http.c
@@ -17,6 +17,7 @@
 #include <sys/time.h>
 #include <sys/types.h>
 #include <sys/socket.h>
+#include <sys/queue.h>
 
 #include <err.h>
 #include <errno.h>
@@ -24,6 +25,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>
@@ -32,9 +35,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"
@@ -837,6 +846,61 @@ bufio2poll(struct bufio *bio)
 	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)
 {
@@ -847,11 +911,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;
@@ -933,7 +1002,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);
 
@@ -964,10 +1046,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 - 20958912de97f4002cb2fa805ef1624b48a47647
blob + 804d5eca66fbe09fb4b30ded4f78f34438caf7d9
--- gotd/notify.c
+++ gotd/notify.c
@@ -164,7 +164,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;
@@ -193,6 +193,11 @@ run_notification_helper(const char *prog, const char *
 			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(),
 			    prog, strerror(errno));
@@ -258,7 +263,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
@@ -286,7 +291,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 - 8788918701c2f2df8e6513ba34076a4f7d493f04
blob + b2d3f58b80dbf587ac68fbec9d354f48f9ef3e86
--- 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,7 +638,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, 1)) {
+				if (conf_notify_http(new_repo, $2, $4, $6, 1,
+				    NULL)) {
 					free($2);
 					free($4);
 					free($6);
@@ -648,6 +650,56 @@ notifyflags	: BRANCH STRING {
 			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);
+		}
 		;
 	
 repository	: REPOSITORY STRING {
@@ -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