Line data Source code
1 : /**
2 : * @file test_compose_pty.c
3 : * @brief PTY tests for email-tui compose/reply/send commands.
4 : *
5 : * Usage: test-pty-compose <email-tui-bin> <mock-smtp-server-bin> <email-cli-bin>
6 : */
7 :
8 : #include "ptytest.h"
9 : #include "pty_assert.h"
10 : #include <stdio.h>
11 : #include <stdlib.h>
12 : #include <string.h>
13 : #include <unistd.h>
14 : #include <signal.h>
15 : #include <fcntl.h>
16 : #include <sys/stat.h>
17 : #include <sys/wait.h>
18 : #include <sys/socket.h>
19 : #include <arpa/inet.h>
20 : #include <errno.h>
21 :
22 : /* ── Test infrastructure ─────────────────────────────────────────────── */
23 :
24 : #define COLS 120
25 : #define ROWS 50
26 : #define WAIT_MS 4000
27 : #define SETTLE_MS 300
28 :
29 : static int g_tests_run = 0;
30 : static int g_tests_failed = 0;
31 :
32 : static char g_tui_bin[512];
33 : static char g_cli_bin[512];
34 : static char g_smtp_mock_bin[512];
35 : static char g_test_home[512];
36 : static pid_t g_smtp_pid = -1;
37 :
38 : #define RUN_TEST(fn) \
39 : do { \
40 : fprintf(stdout, " %s...\n", #fn); \
41 : fflush(stdout); \
42 : fn(); \
43 : } while(0)
44 :
45 : /* ── Config helpers ──────────────────────────────────────────────────── */
46 :
47 90 : static void write_config(void) {
48 : char d1[600], d2[620], d3[640], d4[660], path[700];
49 90 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
50 90 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
51 90 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
52 90 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
53 90 : mkdir(g_test_home, 0700);
54 90 : mkdir(d1, 0700);
55 90 : mkdir(d2, 0700);
56 90 : mkdir(d3, 0700);
57 90 : mkdir(d4, 0700);
58 90 : snprintf(path, sizeof(path), "%s/config.ini", d4);
59 90 : FILE *fp = fopen(path, "w");
60 90 : if (!fp) return;
61 90 : fprintf(fp,
62 : "EMAIL_HOST=imaps://localhost:9993\n"
63 : "EMAIL_USER=testuser\n"
64 : "EMAIL_PASS=testpass\n"
65 : "EMAIL_FOLDER=INBOX\n"
66 : "SMTP_HOST=smtps://localhost:9025\n"
67 : "SMTP_PORT=9025\n"
68 : "SMTP_USER=testuser\n"
69 : "SMTP_PASS=testpass\n"
70 : "SSL_NO_VERIFY=1\n"); /* permit self-signed cert from mock server */
71 90 : fclose(fp);
72 90 : chmod(path, 0600);
73 : }
74 :
75 : /* ── Mock SMTP server management ──────────────────────────────────────── */
76 :
77 57 : static int probe_smtp(void) {
78 57 : int fd = socket(AF_INET, SOCK_STREAM, 0);
79 57 : if (fd < 0) return -1;
80 57 : struct sockaddr_in addr = {
81 : .sin_family = AF_INET,
82 57 : .sin_port = htons(9025),
83 57 : .sin_addr.s_addr = htonl(INADDR_LOOPBACK)
84 : };
85 57 : int ret = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
86 57 : close(fd);
87 57 : return ret;
88 : }
89 :
90 57 : static void start_smtp_server(void) {
91 57 : g_smtp_pid = fork();
92 63 : if (g_smtp_pid < 0) return;
93 63 : if (g_smtp_pid == 0) {
94 6 : int devnull = open("/dev/null", O_WRONLY);
95 6 : if (devnull >= 0) { dup2(devnull, 2); close(devnull); }
96 6 : execl(g_smtp_mock_bin, "mock_smtp_server", (char *)NULL);
97 6 : _exit(127);
98 : }
99 57 : usleep(600000);
100 : }
101 :
102 41 : static void restart_smtp(void) {
103 41 : if (g_smtp_pid > 0) {
104 41 : kill(g_smtp_pid, SIGKILL);
105 41 : waitpid(g_smtp_pid, NULL, 0);
106 41 : g_smtp_pid = -1;
107 : }
108 41 : usleep(200000);
109 41 : start_smtp_server();
110 41 : for (int i = 0; i < 30 && probe_smtp() != 0; i++)
111 0 : usleep(100000);
112 41 : }
113 :
114 1 : static void stop_smtp_server(void) {
115 1 : if (g_smtp_pid > 0) {
116 1 : kill(g_smtp_pid, SIGKILL);
117 1 : waitpid(g_smtp_pid, NULL, 0);
118 1 : g_smtp_pid = -1;
119 : }
120 1 : }
121 :
122 : /* ── Runner helper ───────────────────────────────────────────────────── */
123 :
124 126 : static PtySession *tui_open_size(int cols, int rows, const char **extra_args) {
125 : const char *args[32];
126 126 : int n = 0;
127 126 : args[n++] = g_tui_bin;
128 126 : if (extra_args)
129 534 : for (int i = 0; extra_args[i] && n < 31; i++)
130 408 : args[n++] = extra_args[i];
131 126 : args[n] = NULL;
132 :
133 126 : PtySession *s = pty_open(cols, rows);
134 126 : if (!s) return NULL;
135 126 : if (pty_run(s, args) != 0) { pty_close(s); return NULL; }
136 114 : return s;
137 : }
138 :
139 58 : static PtySession *tui_run(const char **extra_args) {
140 58 : return tui_open_size(COLS, ROWS, extra_args);
141 : }
142 :
143 9 : static PtySession *cli_open_size(int cols, int rows, const char **extra_args) {
144 : const char *args[32];
145 9 : int n = 0;
146 9 : args[n++] = g_cli_bin;
147 9 : if (extra_args)
148 29 : for (int i = 0; extra_args[i] && n < 31; i++)
149 20 : args[n++] = extra_args[i];
150 9 : args[n] = NULL;
151 :
152 9 : PtySession *s = pty_open(cols, rows);
153 9 : if (!s) return NULL;
154 9 : if (pty_run(s, args) != 0) { pty_close(s); return NULL; }
155 6 : return s;
156 : }
157 :
158 4 : static PtySession *cli_run(const char **extra_args) {
159 4 : return cli_open_size(COLS, ROWS, extra_args);
160 : }
161 :
162 : /* ── Help page tests ─────────────────────────────────────────────────── */
163 :
164 16 : static void test_help_compose(void) {
165 16 : const char *a[] = {"compose", "--help", NULL};
166 16 : PtySession *s = tui_open_size(120, 50, a);
167 15 : ASSERT(s != NULL, "help compose: opens");
168 15 : ASSERT_WAIT_FOR(s, "Usage: email-tui", WAIT_MS);
169 15 : pty_settle(s, SETTLE_MS);
170 15 : ASSERT_SCREEN_CONTAINS(s, "To");
171 15 : ASSERT_SCREEN_CONTAINS(s, "Subject");
172 15 : pty_close(s);
173 : }
174 :
175 15 : static void test_help_send(void) {
176 15 : const char *a[] = {"send", "--help", NULL};
177 15 : PtySession *s = tui_open_size(120, 50, a);
178 14 : ASSERT(s != NULL, "help send: opens");
179 14 : ASSERT_WAIT_FOR(s, "Usage: email-tui", WAIT_MS);
180 14 : pty_settle(s, SETTLE_MS);
181 14 : ASSERT_SCREEN_CONTAINS(s, "--to");
182 14 : ASSERT_SCREEN_CONTAINS(s, "--subject");
183 14 : ASSERT_SCREEN_CONTAINS(s, "--body");
184 14 : pty_close(s);
185 : }
186 :
187 14 : static void test_help_reply(void) {
188 14 : const char *a[] = {"reply", "--help", NULL};
189 14 : PtySession *s = tui_open_size(120, 50, a);
190 13 : ASSERT(s != NULL, "help reply: opens");
191 13 : ASSERT_WAIT_FOR(s, "Usage: email-tui", WAIT_MS);
192 13 : pty_settle(s, SETTLE_MS);
193 13 : ASSERT_SCREEN_CONTAINS(s, "<uid>");
194 13 : pty_close(s);
195 : }
196 :
197 : /* ── Batch send tests ──────────────────────────────────────────────────── */
198 :
199 13 : static void test_send_missing_to(void) {
200 13 : write_config();
201 13 : const char *a[] = {"--batch", "send", "--subject", "Hi", "--body", "Hello", NULL};
202 13 : PtySession *s = tui_open_size(120, 50, a);
203 12 : ASSERT(s != NULL, "send missing to: opens");
204 12 : ASSERT_WAIT_FOR(s, "required", WAIT_MS);
205 12 : pty_close(s);
206 : }
207 :
208 12 : static void test_send_batch_ok(void) {
209 12 : write_config();
210 12 : restart_smtp();
211 12 : const char *a[] = {"--batch", "send",
212 : "--to", "recipient@example.com",
213 : "--subject", "Test Subject",
214 : "--body", "Hello from PTY test",
215 : NULL};
216 12 : PtySession *s = tui_run(a);
217 11 : ASSERT(s != NULL, "batch send: opens");
218 11 : ASSERT_WAIT_FOR(s, "Message sent", WAIT_MS);
219 11 : pty_close(s);
220 : }
221 :
222 11 : static void test_send_no_smtp_config(void) {
223 : /* Write config without SMTP settings */
224 : char d1[600], d2[620], d3[640], d4[660], path[700];
225 11 : snprintf(d1, sizeof(d1), "%s/.config", g_test_home);
226 11 : snprintf(d2, sizeof(d2), "%s/.config/email-cli", g_test_home);
227 11 : snprintf(d3, sizeof(d3), "%s/.config/email-cli/accounts", g_test_home);
228 11 : snprintf(d4, sizeof(d4), "%s/.config/email-cli/accounts/testuser", g_test_home);
229 11 : mkdir(g_test_home, 0700); mkdir(d1, 0700); mkdir(d2, 0700);
230 11 : mkdir(d3, 0700); mkdir(d4, 0700);
231 11 : snprintf(path, sizeof(path), "%s/config.ini", d4);
232 11 : FILE *fp = fopen(path, "w");
233 11 : if (fp) {
234 11 : fprintf(fp,
235 : "EMAIL_HOST=imaps://localhost:9993\n"
236 : "EMAIL_USER=testuser\n"
237 : "EMAIL_PASS=testpass\n"
238 : "EMAIL_FOLDER=INBOX\n"
239 : "SSL_NO_VERIFY=1\n"); /* permit non-TLS mock server in tests */
240 11 : fclose(fp);
241 11 : chmod(path, 0600);
242 : }
243 11 : const char *a[] = {"--batch", "send",
244 : "--to", "x@x.com", "--subject", "X", "--body", "X", NULL};
245 11 : PtySession *s = tui_run(a);
246 10 : ASSERT(s != NULL, "no smtp cfg: opens");
247 : /* Should still attempt; derives SMTP from IMAP host */
248 : /* We just check it exits (doesn't hang) */
249 10 : pty_settle(s, SETTLE_MS * 5);
250 10 : pty_close(s);
251 : }
252 :
253 : /* ── Compose TUI tests (editor-based) ───────────────────────────────── */
254 :
255 : /**
256 : * Helper: write a shell script that replaces the draft file ($1) with a
257 : * complete message and returns. Sets $EDITOR to that script path.
258 : * Returns the script path (caller must unlink when done).
259 : */
260 : static char g_editor_script[256];
261 :
262 24 : static void setup_editor_mock(const char *from, const char *to,
263 : const char *subject, const char *body) {
264 24 : snprintf(g_editor_script, sizeof(g_editor_script),
265 24 : "/tmp/test_editor_%d.sh", (int)getpid());
266 24 : FILE *ef = fopen(g_editor_script, "w");
267 24 : if (!ef) return;
268 24 : fprintf(ef, "#!/bin/sh\n");
269 24 : fprintf(ef,
270 : "printf 'From: %s\\nTo: %s\\nSubject: %s\\n\\n%s\\n' > \"$1\"\n",
271 : from, to, subject, body);
272 24 : fclose(ef);
273 24 : chmod(g_editor_script, 0755);
274 24 : setenv("EDITOR", g_editor_script, 1);
275 : }
276 :
277 21 : static void cleanup_editor_mock(void) {
278 21 : unlink(g_editor_script);
279 21 : g_editor_script[0] = '\0';
280 21 : }
281 :
282 : /**
283 : * compose abort: EDITOR=true → editor exits without changing To: → "Aborted"
284 : */
285 10 : static void test_compose_abort_no_to(void) {
286 10 : write_config();
287 10 : setenv("EDITOR", "true", 1); /* no-op editor: exits without saving */
288 10 : const char *a[] = {"compose", NULL};
289 10 : PtySession *s = tui_open_size(120, 50, a);
290 9 : ASSERT(s != NULL, "compose abort no-to: opens");
291 9 : ASSERT_WAIT_FOR(s, "Aborted", WAIT_MS);
292 9 : pty_close(s);
293 : }
294 :
295 : /**
296 : * compose success: mock editor fills in all headers + body → confirm 'y' → "Message sent"
297 : */
298 9 : static void test_compose_editor_send(void) {
299 9 : write_config();
300 9 : restart_smtp();
301 9 : setup_editor_mock("testuser@example.com", "recipient@example.com",
302 : "PTY test subject", "Hello from editor mock test");
303 9 : const char *a[] = {"compose", NULL};
304 9 : PtySession *s = tui_run(a);
305 8 : ASSERT(s != NULL, "compose editor send: opens");
306 8 : ASSERT_WAIT_FOR(s, "Send?", WAIT_MS * 2);
307 8 : pty_send_str(s, "y");
308 8 : ASSERT_WAIT_FOR(s, "Message sent", WAIT_MS);
309 8 : pty_close(s);
310 8 : cleanup_editor_mock();
311 : }
312 :
313 : /**
314 : * compose cancel: mock editor fills in all headers + body → confirm 'n' → "Cancelled"
315 : */
316 8 : static void test_compose_confirm_cancel(void) {
317 8 : write_config();
318 8 : restart_smtp();
319 8 : setup_editor_mock("testuser@example.com", "recipient@example.com",
320 : "Cancelled test subject", "Body of cancelled message");
321 8 : const char *a[] = {"compose", NULL};
322 8 : PtySession *s = tui_run(a);
323 7 : ASSERT(s != NULL, "compose confirm cancel: opens");
324 7 : ASSERT_WAIT_FOR(s, "Send?", WAIT_MS * 2);
325 7 : pty_send_str(s, "n");
326 7 : ASSERT_WAIT_FOR(s, "Cancelled", WAIT_MS);
327 7 : pty_close(s);
328 7 : cleanup_editor_mock();
329 : }
330 :
331 : /**
332 : * \r stripping (US-20): reply quote must not contain carriage-return bytes.
333 : *
334 : * We set up an editor that captures the draft to a temp file for inspection.
335 : * The mock IMAP server delivers messages with CRLF line endings (RFC 2822);
336 : * after quote extraction those CRs must be stripped before the draft is
337 : * written. The editor mock simply outputs a fixed reply so the send
338 : * completes; we inspect the captured draft file instead of the sent message.
339 : */
340 5 : static void test_reply_no_cr_in_quote(void) {
341 5 : write_config();
342 5 : restart_smtp();
343 :
344 : /* Editor mock: capture draft to a known path, then fill in a valid reply */
345 : char capture_path[256];
346 5 : snprintf(capture_path, sizeof(capture_path),
347 5 : "/tmp/draft_capture_%d.txt", (int)getpid());
348 :
349 : char editor_script[300];
350 5 : snprintf(editor_script, sizeof(editor_script),
351 5 : "/tmp/test_editor_cr_%d.sh", (int)getpid());
352 5 : FILE *ef = fopen(editor_script, "w");
353 5 : if (ef) {
354 5 : fprintf(ef, "#!/bin/sh\n");
355 : /* Copy draft to capture path for inspection */
356 5 : fprintf(ef, "cp \"$1\" '%s'\n", capture_path);
357 : /* Fill valid To: so compose sends */
358 5 : fprintf(ef,
359 : "printf 'From: testuser@example.com\\n"
360 : "To: recipient@example.com\\n"
361 : "Subject: Re: Test\\n\\nReply body\\n' > \"$1\"\n");
362 5 : fclose(ef);
363 5 : chmod(editor_script, 0755);
364 : }
365 5 : setenv("EDITOR", editor_script, 1);
366 :
367 : /* Launch as batch reply UID 1 (mock server has message UID 1) */
368 5 : const char *a[] = {"reply", "1", NULL};
369 5 : PtySession *s = tui_run(a);
370 4 : ASSERT(s != NULL, "no CR in quote: opens");
371 : /* Wait until the editor has run and the draft was captured */
372 4 : pty_settle(s, WAIT_MS);
373 4 : pty_close(s);
374 :
375 : /* Inspect captured draft: must not contain \r */
376 4 : FILE *fp = fopen(capture_path, "r");
377 4 : if (fp) {
378 0 : int found_cr = 0;
379 : int c;
380 0 : while ((c = fgetc(fp)) != EOF)
381 0 : if (c == '\r') { found_cr = 1; break; }
382 0 : fclose(fp);
383 0 : ASSERT(!found_cr, "no CR in quoted body: draft must not contain \\r");
384 0 : unlink(capture_path);
385 : }
386 : /* cleanup */
387 4 : unlink(editor_script);
388 : }
389 :
390 : /**
391 : * reply abort: batch reply with EDITOR=true → empty To: → "Aborted"
392 : * (cmd_reply is invoked; the raw message may not exist → error before editor)
393 : */
394 6 : static void test_reply_missing_uid(void) {
395 6 : write_config();
396 6 : const char *a[] = {"--batch", "reply", NULL};
397 6 : PtySession *s = tui_run(a);
398 5 : ASSERT(s != NULL, "reply missing uid: opens");
399 5 : ASSERT_WAIT_FOR(s, "requires", WAIT_MS);
400 5 : pty_close(s);
401 : }
402 :
403 : /**
404 : * compose sent folder (US-20): after a successful send, "Saving to Sent folder"
405 : * appears in output confirming the sent copy is stored locally/remotely.
406 : */
407 7 : static void test_compose_sent_folder(void) {
408 7 : write_config();
409 7 : restart_smtp();
410 7 : setup_editor_mock("testuser@example.com", "recipient@example.com",
411 : "Sent folder test", "Body for sent folder test");
412 7 : const char *a[] = {"compose", NULL};
413 7 : PtySession *s = tui_run(a);
414 6 : ASSERT(s != NULL, "compose sent folder: opens");
415 6 : ASSERT_WAIT_FOR(s, "Send?", WAIT_MS * 2);
416 6 : pty_send_str(s, "y");
417 6 : ASSERT_WAIT_FOR(s, "Saved locally", WAIT_MS);
418 6 : pty_close(s);
419 6 : cleanup_editor_mock();
420 : }
421 :
422 : /* ── Config command tests (US-24) ────────────────────────────────────── */
423 :
424 : /**
425 : * config show: batch mode prints IMAP settings including user and folder.
426 : * Passwords are masked; the account user and folder must be visible.
427 : */
428 4 : static void test_config_show(void) {
429 4 : write_config();
430 4 : const char *a[] = {"config", "show", NULL};
431 4 : PtySession *s = cli_run(a);
432 3 : ASSERT(s != NULL, "config show: opens");
433 3 : ASSERT_WAIT_FOR(s, "IMAP", WAIT_MS);
434 3 : pty_settle(s, SETTLE_MS);
435 3 : ASSERT_SCREEN_CONTAINS(s, "testuser");
436 3 : ASSERT_SCREEN_CONTAINS(s, "INBOX");
437 3 : pty_close(s);
438 : }
439 :
440 : /**
441 : * config --help: must mention imap, smtp, and --account subcommands.
442 : */
443 3 : static void test_config_help(void) {
444 3 : const char *a[] = {"config", "--help", NULL};
445 3 : PtySession *s = cli_open_size(120, 50, a);
446 2 : ASSERT(s != NULL, "config help: opens");
447 2 : ASSERT_WAIT_FOR(s, "Usage: email-cli", WAIT_MS);
448 2 : pty_settle(s, SETTLE_MS);
449 2 : ASSERT_SCREEN_CONTAINS(s, "imap");
450 2 : ASSERT_SCREEN_CONTAINS(s, "smtp");
451 2 : ASSERT_SCREEN_CONTAINS(s, "account");
452 2 : pty_close(s);
453 : }
454 :
455 : /**
456 : * config imap --help: must print usage information and exit.
457 : */
458 2 : static void test_config_imap_help(void) {
459 2 : const char *a[] = {"config", "imap", "--help", NULL};
460 2 : PtySession *s = cli_open_size(120, 50, a);
461 1 : ASSERT(s != NULL, "config imap help: opens");
462 1 : ASSERT_WAIT_FOR(s, "Usage: email-cli", WAIT_MS);
463 1 : pty_settle(s, SETTLE_MS);
464 1 : ASSERT_SCREEN_CONTAINS(s, "imap");
465 1 : pty_close(s);
466 : }
467 :
468 : /* ── Main ────────────────────────────────────────────────────────────── */
469 :
470 16 : int main(int argc, char *argv[]) {
471 16 : if (argc < 4) {
472 0 : fprintf(stderr, "Usage: %s <email-tui> <mock-smtp-server> <email-cli>\n", argv[0]);
473 0 : return 1;
474 : }
475 :
476 16 : snprintf(g_tui_bin, sizeof(g_tui_bin), "%s", argv[1]);
477 16 : snprintf(g_smtp_mock_bin, sizeof(g_smtp_mock_bin), "%s", argv[2]);
478 16 : snprintf(g_cli_bin, sizeof(g_cli_bin), "%s", argv[3]);
479 :
480 : /* Isolated HOME for tests */
481 16 : snprintf(g_test_home, sizeof(g_test_home), "/tmp/email_cli_compose_test_%d", getpid());
482 16 : mkdir(g_test_home, 0700);
483 16 : setenv("HOME", g_test_home, 1);
484 16 : unsetenv("XDG_CONFIG_HOME");
485 16 : unsetenv("XDG_CACHE_HOME");
486 16 : unsetenv("XDG_DATA_HOME");
487 :
488 16 : write_config();
489 16 : start_smtp_server();
490 16 : for (int i = 0; i < 30 && probe_smtp() != 0; i++) usleep(100000);
491 :
492 16 : printf("\n--- Compose/send: help pages ---\n");
493 16 : RUN_TEST(test_help_compose);
494 15 : RUN_TEST(test_help_send);
495 14 : RUN_TEST(test_help_reply);
496 :
497 13 : printf("\n--- Compose/send: batch send ---\n");
498 13 : RUN_TEST(test_send_missing_to);
499 12 : RUN_TEST(test_send_batch_ok);
500 11 : RUN_TEST(test_send_no_smtp_config);
501 :
502 10 : printf("\n--- Compose/send: interactive compose ---\n");
503 10 : RUN_TEST(test_compose_abort_no_to);
504 9 : RUN_TEST(test_compose_editor_send);
505 8 : RUN_TEST(test_compose_confirm_cancel);
506 7 : RUN_TEST(test_compose_sent_folder);
507 6 : RUN_TEST(test_reply_missing_uid);
508 5 : RUN_TEST(test_reply_no_cr_in_quote);
509 :
510 4 : printf("\n--- Config command (US-24) ---\n");
511 4 : RUN_TEST(test_config_show);
512 3 : RUN_TEST(test_config_help);
513 2 : RUN_TEST(test_config_imap_help);
514 :
515 1 : stop_smtp_server();
516 :
517 1 : printf("\n--- PTY Compose Test Results ---\n");
518 1 : printf("Tests Run: %d\n", g_tests_run);
519 1 : printf("Tests Passed: %d\n", g_tests_run - g_tests_failed);
520 1 : printf("Tests Failed: %d\n", g_tests_failed);
521 :
522 1 : return g_tests_failed > 0 ? 1 : 0;
523 : }
|