integration.test.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. /**
  2. * Integration tests for apple-mail-mcp
  3. *
  4. * These tests run against REAL Apple Mail data — no mocks.
  5. * They exercise the full stack: Zod schemas → AppleMailManager → AppleScript → Mail.app.
  6. *
  7. * Prerequisites:
  8. * - macOS with Mail.app configured and at least one account
  9. * - Automation permission granted to the terminal running the tests
  10. * - At least one message in INBOX
  11. *
  12. * Run via: npm run test:integration
  13. */
  14. import { describe, it, expect, beforeAll } from "vitest";
  15. import { z } from "zod";
  16. import { resolve } from "path";
  17. import { homedir } from "os";
  18. import { AppleMailManager } from "../src/services/appleMailManager.js";
  19. // ---------------------------------------------------------------------------
  20. // Shared schemas (mirrored from index.ts so we can test them directly)
  21. // ---------------------------------------------------------------------------
  22. const MESSAGE_ID_SCHEMA = z.string().regex(/^\d+$/, "Message ID must be numeric");
  23. const BATCH_IDS_SCHEMA = z
  24. .array(MESSAGE_ID_SCHEMA)
  25. .min(1, "At least one message ID is required")
  26. .max(100, "Cannot process more than 100 messages in a single batch");
  27. const DATE_FILTER_SCHEMA = z
  28. .string()
  29. .regex(
  30. /^[a-zA-Z0-9 ,/\-:]+$/,
  31. "Date must contain only alphanumeric characters, spaces, commas, slashes, hyphens, and colons"
  32. )
  33. .optional();
  34. // ---------------------------------------------------------------------------
  35. // Test state shared across describe blocks
  36. // ---------------------------------------------------------------------------
  37. let mgr: AppleMailManager;
  38. let realMessageId: string | null = null;
  39. let realAccount: string | null = null;
  40. beforeAll(() => {
  41. mgr = new AppleMailManager();
  42. });
  43. // ===========================================================================
  44. // Schema validation (no Mail.app interaction)
  45. // ===========================================================================
  46. describe("schema validation", () => {
  47. describe("MESSAGE_ID_SCHEMA", () => {
  48. it.each(["12345", "0", "999999999"])("accepts valid numeric ID: %s", (id) => {
  49. expect(MESSAGE_ID_SCHEMA.parse(id)).toBe(id);
  50. });
  51. it.each([
  52. ["AppleScript injection", '1" & do shell script "rm -rf /'],
  53. ["non-numeric", "abc"],
  54. ["mixed", "123abc"],
  55. ["negative", "-1"],
  56. ["spaces", "12 34"],
  57. ["empty", ""],
  58. ["template ID", "tmpl_1"],
  59. ])("rejects %s: %s", (_label, id) => {
  60. expect(() => MESSAGE_ID_SCHEMA.parse(id)).toThrow();
  61. });
  62. });
  63. describe("BATCH_IDS_SCHEMA", () => {
  64. it("accepts 1-100 valid numeric IDs", () => {
  65. expect(BATCH_IDS_SCHEMA.parse(["1", "2", "3"])).toEqual(["1", "2", "3"]);
  66. });
  67. it("accepts exactly 100 IDs", () => {
  68. const ids = Array.from({ length: 100 }, (_, i) => String(i));
  69. expect(BATCH_IDS_SCHEMA.parse(ids)).toHaveLength(100);
  70. });
  71. it("rejects empty array", () => {
  72. expect(() => BATCH_IDS_SCHEMA.parse([])).toThrow();
  73. });
  74. it("rejects more than 100 IDs", () => {
  75. const ids = Array.from({ length: 101 }, (_, i) => String(i));
  76. expect(() => BATCH_IDS_SCHEMA.parse(ids)).toThrow();
  77. });
  78. it("rejects batch containing non-numeric IDs", () => {
  79. expect(() => BATCH_IDS_SCHEMA.parse(["123", "abc", "456"])).toThrow();
  80. });
  81. });
  82. describe("DATE_FILTER_SCHEMA", () => {
  83. it.each(["January 1, 2026", "March 15, 2026", "2026-03-15", "3/15/2026", "12:30:00"])(
  84. "accepts valid date string: %s",
  85. (d) => {
  86. expect(DATE_FILTER_SCHEMA.parse(d)).toBe(d);
  87. }
  88. );
  89. it("accepts undefined (optional)", () => {
  90. expect(DATE_FILTER_SCHEMA.parse(undefined)).toBeUndefined();
  91. });
  92. it.each([
  93. ["quotes (injection)", '"January 1" & do shell script "evil"'],
  94. ["backslash", "January\\1"],
  95. ["parentheses", "date(2026)"],
  96. ["semicolon", "Jan 1; evil"],
  97. ["ampersand", "Jan & evil"],
  98. ])("rejects %s: %s", (_label, d) => {
  99. expect(() => DATE_FILTER_SCHEMA.parse(d)).toThrow();
  100. });
  101. });
  102. });
  103. // ===========================================================================
  104. // saveAttachment input validation (no Mail.app interaction for bad inputs)
  105. // ===========================================================================
  106. describe("saveAttachment path safety", () => {
  107. // These all use a bogus message ID so even if validation passes,
  108. // the AppleScript would just return "message not found".
  109. const BOGUS_ID = "99999999";
  110. it("blocks path traversal in savePath", () => {
  111. const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", "/tmp/../../etc");
  112. expect(result).toBe(false);
  113. });
  114. it("blocks directory traversal in attachment name", () => {
  115. const result = mgr.saveAttachment(BOGUS_ID, "../../etc/passwd", "/tmp");
  116. expect(result).toBe(false);
  117. });
  118. it("blocks backslash in attachment name", () => {
  119. const result = mgr.saveAttachment(BOGUS_ID, "file\\name.txt", "/tmp");
  120. expect(result).toBe(false);
  121. });
  122. it("blocks null byte in attachment name", () => {
  123. const result = mgr.saveAttachment(BOGUS_ID, "file\0name.txt", "/tmp");
  124. expect(result).toBe(false);
  125. });
  126. it("blocks forward slash in attachment name", () => {
  127. const result = mgr.saveAttachment(BOGUS_ID, "path/to/file.txt", "/tmp");
  128. expect(result).toBe(false);
  129. });
  130. it("blocks save path outside allowed directories", () => {
  131. const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", "/etc");
  132. expect(result).toBe(false);
  133. });
  134. it("blocks save path to /usr", () => {
  135. const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", "/usr/local");
  136. expect(result).toBe(false);
  137. });
  138. it("allows save path under home directory", () => {
  139. // Will fail at "message not found" stage, but should pass path validation
  140. // (returns false because the message doesn't exist, not because of path)
  141. const result = mgr.saveAttachment(BOGUS_ID, "test.pdf", `${homedir()}/Downloads`);
  142. // We can't distinguish path-fail from message-not-found via boolean alone,
  143. // but this at least confirms it doesn't throw.
  144. expect(typeof result).toBe("boolean");
  145. });
  146. });
  147. // ===========================================================================
  148. // Live Mail.app operations (read-only)
  149. // ===========================================================================
  150. describe("live Mail.app operations", { timeout: 120_000 }, () => {
  151. it("lists at least one account and finds one with INBOX", () => {
  152. const accounts = mgr.listAccounts();
  153. expect(accounts.length).toBeGreaterThan(0);
  154. // Some accounts (e.g. iCloud) may not have a standard INBOX.
  155. // Find the first account that has messages in INBOX.
  156. for (const acct of accounts) {
  157. const messages = mgr.listMessages("INBOX", acct.name, 1);
  158. if (messages.length > 0) {
  159. realAccount = acct.name;
  160. break;
  161. }
  162. }
  163. expect(realAccount).not.toBeNull();
  164. });
  165. it("lists messages from INBOX", () => {
  166. expect(realAccount).not.toBeNull();
  167. const messages = mgr.listMessages("INBOX", realAccount!, 5);
  168. expect(messages.length).toBeGreaterThan(0);
  169. // Capture a real message ID for subsequent tests
  170. realMessageId = messages[0].id;
  171. // Verify the real ID is numeric (critical for Number(id) defense)
  172. expect(realMessageId).toMatch(/^\d+$/);
  173. });
  174. it("retrieves a message by numeric ID", () => {
  175. expect(realMessageId).not.toBeNull();
  176. const msg = mgr.getMessageById(realMessageId!);
  177. expect(msg).not.toBeNull();
  178. expect(msg!.id).toBe(realMessageId);
  179. });
  180. it("gets message content by ID", () => {
  181. expect(realMessageId).not.toBeNull();
  182. const content = mgr.getMessageContent(realMessageId!);
  183. expect(content).not.toBeNull();
  184. expect(content!.subject).toBeDefined();
  185. });
  186. it("lists attachments for a message (may be empty)", () => {
  187. expect(realMessageId).not.toBeNull();
  188. const attachments = mgr.listAttachments(realMessageId!);
  189. expect(Array.isArray(attachments)).toBe(true);
  190. });
  191. it("searches messages with date range filter", () => {
  192. // This exercises the DATE_FILTER_SCHEMA → AppleScript date literal path
  193. const messages = mgr.searchMessages(
  194. undefined,
  195. "INBOX",
  196. realAccount ?? undefined,
  197. 5,
  198. "January 1, 2025",
  199. "December 31, 2026"
  200. );
  201. // May find 0 messages but should not error
  202. expect(Array.isArray(messages)).toBe(true);
  203. });
  204. it("lists mailboxes for an account", () => {
  205. expect(realAccount).not.toBeNull();
  206. const mailboxes = mgr.listMailboxes(realAccount!);
  207. expect(mailboxes.length).toBeGreaterThan(0);
  208. });
  209. it("gets unread count without error", () => {
  210. const count = mgr.getUnreadCount(undefined, realAccount ?? undefined);
  211. expect(typeof count).toBe("number");
  212. expect(count).toBeGreaterThanOrEqual(0);
  213. });
  214. it("runs health check", () => {
  215. const result = mgr.healthCheck();
  216. expect(result).toBeDefined();
  217. expect(typeof result.healthy).toBe("boolean");
  218. expect(Array.isArray(result.checks)).toBe(true);
  219. });
  220. it("confirms real message IDs are safe for Number() cast", () => {
  221. expect(realMessageId).not.toBeNull();
  222. const num = Number(realMessageId);
  223. expect(Number.isNaN(num)).toBe(false);
  224. expect(Number.isFinite(num)).toBe(true);
  225. expect(num).toBeGreaterThan(0);
  226. });
  227. });