aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-20 15:57:08 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-20 15:57:08 +0200
commit90068419d51b0289c65d4db9e14849fbecd34a95 (patch)
tree8d9dbf65ee6d869a6d3cac94ec0dc7639f9d6f8a /tests
parent9c3892b09a439e5bafa80b7c9ef910b5af0d8598 (diff)
downloadbokf-90068419d51b0289c65d4db9e14849fbecd34a95.tar.gz
bokf-90068419d51b0289c65d4db9e14849fbecd34a95.zip
invoice: send the issued PDF over SMTP
Diffstat (limited to 'tests')
-rw-r--r--tests/test_core.c356
1 files changed, 356 insertions, 0 deletions
diff --git a/tests/test_core.c b/tests/test_core.c
index 47b6acf..29985e2 100644
--- a/tests/test_core.c
+++ b/tests/test_core.c
@@ -343,6 +343,134 @@ static void test_transport(void)
}
}
+/* Minimal SMTP server for invoice.send: 220, EHLO (multiline), MAIL/RCPT,
+ 354, DATA capture until ".", 250 and 221. The transcript goes to logpath
+ as "C:" (client), "S:" (server) and "D:" (data) lines. */
+static int smtp_child_line(int fd, char *buf, size_t n)
+{
+ size_t o = 0;
+ for (;;) {
+ char ch;
+ ssize_t r = read(fd, &ch, 1);
+ if (r <= 0)
+ return -1;
+ if (ch == '\n') {
+ while (o > 0 && buf[o - 1] == '\r')
+ o--;
+ buf[o] = '\0';
+ return (int)o;
+ }
+ if (o + 1 < n)
+ buf[o++] = ch;
+ }
+}
+
+static void smtp_child_say(int fd, FILE *log, const char *line)
+{
+ fprintf(log, "S: %s\n", line);
+ fflush(log);
+ char out[512];
+ int n = snprintf(out, sizeof out, "%s\r\n", line);
+ if (n > 0) {
+ ssize_t wr = write(fd, out, (size_t)n);
+ (void)wr;
+ }
+}
+
+static void smtp_child(int wfd, const char *logpath, int reject_rcpt)
+{
+ signal(SIGPIPE, SIG_IGN);
+ alarm(20);
+ int sfd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
+ if (sfd < 0)
+ _exit(1);
+ struct sockaddr_in sa;
+ memset(&sa, 0, sizeof sa);
+ sa.sin_family = AF_INET;
+ sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ sa.sin_port = 0;
+ if (bind(sfd, (struct sockaddr *)&sa, sizeof sa) != 0 ||
+ listen(sfd, 1) != 0)
+ _exit(1);
+ socklen_t slen = sizeof sa;
+ if (getsockname(sfd, (struct sockaddr *)&sa, &slen) != 0)
+ _exit(1);
+ int port = ntohs(sa.sin_port);
+ if (write(wfd, &port, sizeof port) != (ssize_t)sizeof port)
+ _exit(1);
+ close(wfd);
+ int cfd = accept(sfd, NULL, NULL);
+ if (cfd < 0)
+ _exit(1);
+ FILE *log = fopen(logpath, "w");
+ if (!log)
+ _exit(1);
+ smtp_child_say(cfd, log, "220 test.invalid ESMTP");
+ char line[2048];
+ while (smtp_child_line(cfd, line, sizeof line) >= 0) {
+ fprintf(log, "C: %s\n", line);
+ fflush(log);
+ if (strncmp(line, "EHLO", 4) == 0 ||
+ strncmp(line, "HELO", 4) == 0) {
+ smtp_child_say(cfd, log, "250-test.invalid");
+ smtp_child_say(cfd, log, "250 AUTH PLAIN LOGIN");
+ } else if (strncmp(line, "AUTH", 4) == 0) {
+ smtp_child_say(cfd, log, "235 2.7.0 authenticated");
+ } else if (strncmp(line, "MAIL FROM:", 10) == 0) {
+ smtp_child_say(cfd, log, "250 2.1.0 ok");
+ } else if (strncmp(line, "RCPT TO:", 8) == 0) {
+ if (reject_rcpt) {
+ smtp_child_say(cfd, log, "550 5.1.1 no such user");
+ break;
+ }
+ smtp_child_say(cfd, log, "250 2.1.5 ok");
+ } else if (strcmp(line, "DATA") == 0) {
+ smtp_child_say(cfd, log, "354 end with .");
+ for (;;) {
+ if (smtp_child_line(cfd, line, sizeof line) < 0)
+ break;
+ fprintf(log, "D: %s\n", line);
+ fflush(log);
+ if (strcmp(line, ".") == 0)
+ break;
+ }
+ smtp_child_say(cfd, log, "250 2.0.0 queued");
+ } else if (strcmp(line, "QUIT") == 0) {
+ smtp_child_say(cfd, log, "221 2.0.0 bye");
+ break;
+ } else {
+ smtp_child_say(cfd, log, "250 2.0.0 ok");
+ }
+ }
+ fclose(log);
+ close(cfd);
+ close(sfd);
+ _exit(0);
+}
+
+static pid_t fake_smtp_start(int *out_port, const char *logpath, int reject_rcpt)
+{
+ int pfd[2];
+ if (pipe(pfd) != 0)
+ return -1;
+ pid_t pid = fork();
+ if (pid == 0) {
+ close(pfd[0]);
+ smtp_child(pfd[1], logpath, reject_rcpt);
+ }
+ close(pfd[1]);
+ int port = 0;
+ ssize_t got = read(pfd[0], &port, sizeof port);
+ close(pfd[0]);
+ if (got != (ssize_t)sizeof port || port <= 0) {
+ if (pid > 0)
+ waitpid(pid, NULL, 0);
+ return -1;
+ }
+ *out_port = port;
+ return pid;
+}
+
static int count_snapshots(const char *dir)
{
DIR *d = opendir(dir);
@@ -3023,6 +3151,234 @@ int main(void)
free(preview_b64);
+ /* ---------------- invoice.send over a fake SMTP server ----------- */
+
+ static const char smtp_key[] =
+ "000102030405060708090a0b0c0d0e0f"
+ "101112131415161718191a1b1c1d1e1f";
+ char smtp_log[600];
+ snprintf(smtp_log, sizeof smtp_log, "%s/smtp.log", tmpdir);
+ unlink(smtp_log);
+ setenv("BOKFD_SECRET_KEY", smtp_key, 1);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp1\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_host\",\"value\":\"127.0.0.1\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp2\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_from\",\"value\":\"fakturor@example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ /* user set: the password must decrypt and AUTH PLAIN must run */
+ d = call(reqf("{\"v\":1,\"id\":\"smtp2b\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_user\",\"value\":\"faktura@example.se\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp3\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_security\",\"value\":\"plain\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp4\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_password\",\"value\":\"hemlig\"}}",
+ g_session, (int)org_id));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.value", "[redacted]");
+ yyjson_doc_free(d);
+
+ /* dry_run before any server exists: valid config, no send, no update */
+ d = call(reqf("{\"v\":1,\"id\":\"smtp5\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"dry_run\":true,\"args\":"
+ "{\"id\":%lld,\"to\":\"test@example.se\"}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK(jbool(d, "result.dry_run"));
+ CHECK(jint(d, "result.id") == inv1);
+ CHECK_STR(d, "result.to", "test@example.se");
+ CHECK_STR(d, "result.subject", "Faktura 17761");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp6\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK(yyjson_is_null(jget(d, "result.last_sent_at")));
+ yyjson_doc_free(d);
+
+ int smtp_port = 0;
+ pid_t smtp_pid = fake_smtp_start(&smtp_port, smtp_log, 0);
+ CHECK(smtp_pid > 0);
+ CHECK(smtp_port > 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp7\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"%d\"}}",
+ g_session, (int)org_id, smtp_port));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ char sent_at[64] = "";
+ d = call(reqf("{\"v\":1,\"id\":\"smtp8\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK(jint(d, "result.id") == inv1);
+ CHECK_STR(d, "result.sent_to", "faktura@andra.se");
+ if (jstr(d, "result.at"))
+ snprintf(sent_at, sizeof sent_at, "%s", jstr(d, "result.at"));
+ CHECK(sent_at[0] != '\0');
+ yyjson_doc_free(d);
+ if (smtp_pid > 0)
+ waitpid(smtp_pid, NULL, 0);
+
+ {
+ FILE *lf = fopen(smtp_log, "rb");
+ CHECK(lf != NULL);
+ if (lf) {
+ char lbuf[65536];
+ size_t ln = fread(lbuf, 1, sizeof lbuf - 1, lf);
+ lbuf[ln] = '\0';
+ fclose(lf);
+ CHECK(strstr(lbuf, "Faktura 17761") != NULL);
+ CHECK(strstr(lbuf, "application/pdf") != NULL);
+ CHECK(strstr(lbuf, "To: <faktura@andra.se>") != NULL);
+ CHECK(strstr(lbuf, "hemlig") == NULL);
+ }
+ }
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp9\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.last_sent_at", sent_at);
+ CHECK_STR(d, "result.last_sent_to", "faktura@andra.se");
+ yyjson_doc_free(d);
+
+ char *send_audit = db_text(
+ g_db, "SELECT request_json FROM audit_log"
+ " WHERE action='invoice.send' ORDER BY seq DESC LIMIT 1");
+ CHECK(send_audit != NULL);
+ CHECK(send_audit && strstr(send_audit, "\"subject\":\"Faktura 17761\""));
+ CHECK(send_audit && strstr(send_audit, "hemlig") == NULL);
+ free(send_audit);
+
+ /* with a user set the stored password must decrypt */
+ unsetenv("BOKFD_SECRET_KEY");
+ d = call(reqf("{\"v\":1,\"id\":\"smtp9b\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_STR(d, "error.code", "SMTP_NOT_CONFIGURED");
+ yyjson_doc_free(d);
+ setenv("BOKFD_SECRET_KEY", smtp_key, 1);
+
+ /* a refused recipient maps to SMTP_FAILED and leaves the stamp alone */
+ unlink(smtp_log);
+ smtp_pid = fake_smtp_start(&smtp_port, smtp_log, 1);
+ CHECK(smtp_pid > 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp10\",\"cmd\":\"settings.set\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":"
+ "{\"key\":\"smtp_port\",\"value\":\"%d\"}}",
+ g_session, (int)org_id, smtp_port));
+ CHECK_OK(d);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp11\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_STR(d, "error.code", "SMTP_FAILED");
+ CHECK(jstr(d, "error.message") && strstr(jstr(d, "error.message"), "550"));
+ yyjson_doc_free(d);
+ if (smtp_pid > 0)
+ waitpid(smtp_pid, NULL, 0);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp12\",\"cmd\":\"invoice.get\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)org_id, (long long)inv1));
+ CHECK_OK(d);
+ CHECK_STR(d, "result.last_sent_at", sent_at);
+ CHECK_STR(d, "result.last_sent_to", "faktura@andra.se");
+ yyjson_doc_free(d);
+
+ /* missing SMTP settings on a fresh org; recipient is checked first */
+ d = call(reqf("{\"v\":1,\"id\":\"smtp13\",\"cmd\":\"org.create\","
+ "\"session\":\"%s\",\"args\":{\"name\":\"SMTP Saknas AB\"}}",
+ g_session));
+ CHECK_OK(d);
+ int64_t smtp_org = jint(d, "result.id");
+ CHECK(smtp_org > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp14\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Med Epost AB\",\"email\":\"mottagare@example.se\"}}",
+ g_session, (int)smtp_org));
+ CHECK_OK(d);
+ int64_t smtp_cust = jint(d, "result.id");
+ CHECK(smtp_cust > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp15\",\"cmd\":\"customer.create\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"name\":"
+ "\"Utan Epost AB\"}}",
+ g_session, (int)smtp_org));
+ CHECK_OK(d);
+ int64_t no_mail_cust = jint(d, "result.id");
+ CHECK(no_mail_cust > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp16\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"Tjänst\","
+ "\"quantity\":\"1\",\"unit_price_ore\":10000,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)smtp_org, (long long)smtp_cust));
+ CHECK_OK(d);
+ int64_t smtp_inv = jint(d, "result.id");
+ CHECK(smtp_inv > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp17\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)smtp_org, (long long)smtp_inv));
+ CHECK_STR(d, "error.code", "SMTP_NOT_CONFIGURED");
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp18\",\"cmd\":\"invoice.issue\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"customer_id\":"
+ "%lld,\"invoice_date\":\"2026-09-20\",\"due_date\":"
+ "\"2026-10-20\",\"rows\":[{\"description\":\"Tjänst\","
+ "\"quantity\":\"1\",\"unit_price_ore\":10000,"
+ "\"vat_code\":\"25\"}]}}",
+ g_session, (int)smtp_org, (long long)no_mail_cust));
+ CHECK_OK(d);
+ int64_t no_mail_inv = jint(d, "result.id");
+ CHECK(no_mail_inv > 0);
+ yyjson_doc_free(d);
+
+ d = call(reqf("{\"v\":1,\"id\":\"smtp19\",\"cmd\":\"invoice.send\","
+ "\"session\":\"%s\",\"org\":%d,\"args\":{\"id\":%lld}}",
+ g_session, (int)smtp_org, (long long)no_mail_inv));
+ CHECK_STR(d, "error.code", "INVALID_ARGS");
+ CHECK(jstr(d, "error.message") &&
+ strstr(jstr(d, "error.message"), "no e-mail") != NULL);
+ yyjson_doc_free(d);
+
+ unsetenv("BOKFD_SECRET_KEY");
+
/* ---------------- moms rules: ranges merge per box ------------- */
d = call(reqf("{\"v\":1,\"id\":\"110\",\"cmd\":\"org.create\","
"\"session\":\"%s\",\"args\":{\"name\":\"Moms AB\"}}",