aboutsummaryrefslogtreecommitdiff
path: root/tests/test-ai-mcp-helpers.el
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test-ai-mcp-helpers.el')
-rw-r--r--tests/test-ai-mcp-helpers.el419
1 files changed, 0 insertions, 419 deletions
diff --git a/tests/test-ai-mcp-helpers.el b/tests/test-ai-mcp-helpers.el
deleted file mode 100644
index 5a995ff2..00000000
--- a/tests/test-ai-mcp-helpers.el
+++ /dev/null
@@ -1,419 +0,0 @@
-;;; test-ai-mcp-helpers.el --- Tests for pure helpers in ai-mcp.el -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Normal / Boundary / Error tests for the side-effect-free helpers in
-;; ai-mcp.el: secrets redaction, confirm-policy classifier, description
-;; normalizer, Claude-config reader (mtime-cached), env / secret-args
-;; resolution, server-alist builder. No real `~/.claude.json' reads;
-;; fixtures are written to per-test temp files. No real subprocesses
-;; or network calls.
-
-;;; Code:
-
-(require 'ert)
-(require 'cl-lib)
-
-(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
-(require 'ai-mcp)
-
-;; -------------------------------------------------------- fixtures
-
-(defconst test-ai-mcp--sentinel "REDACTED_TEST_SECRET"
- "Sentinel that must never appear in any user-facing output.")
-
-(defconst test-ai-mcp--fixture-json
- "{
- \"mcpServers\": {
- \"drawio\": {
- \"type\": \"stdio\",
- \"command\": \"npx\",
- \"args\": [\"-y\", \"@drawio/mcp\"]
- },
- \"google-calendar\": {
- \"type\": \"stdio\",
- \"command\": \"npx\",
- \"args\": [\"-y\", \"@cocal/google-calendar-mcp\"],
- \"env\": {
- \"GOOGLE_OAUTH_CREDENTIALS\": \"REDACTED_TEST_SECRET\"
- }
- },
- \"google-docs-personal\": {
- \"type\": \"stdio\",
- \"command\": \"npx\",
- \"args\": [\"-y\", \"@a-bonus/google-docs-mcp\"],
- \"env\": {
- \"GOOGLE_CLIENT_ID\": \"REDACTED_TEST_SECRET\",
- \"GOOGLE_CLIENT_SECRET\": \"REDACTED_TEST_SECRET\",
- \"GOOGLE_MCP_PROFILE\": \"personal\"
- }
- },
- \"figma\": {
- \"type\": \"stdio\",
- \"command\": \"npx\",
- \"args\": [\"-y\", \"figma-developer-mcp\", \"--figma-api-key=REDACTED_TEST_SECRET\", \"--stdio\"]
- },
- \"linear\": {
- \"type\": \"http\",
- \"url\": \"https://mcp.linear.app/mcp\"
- },
- \"slack-deepsat\": {
- \"type\": \"sse\",
- \"url\": \"http://127.0.0.1:13080/sse\"
- }
- }
-}"
- "Fixture matching the shape of a real ~/.claude.json mcpServers tree.")
-
-(defun test-ai-mcp--write-fixture (&optional content)
- "Write CONTENT (defaults to the standard fixture) to a temp file.
-Return the file path."
- (let ((tmp (make-temp-file "test-ai-mcp-" nil ".json")))
- (with-temp-file tmp
- (insert (or content test-ai-mcp--fixture-json)))
- tmp))
-
-(defmacro test-ai-mcp--with-fixture (var &rest body)
- "Bind VAR to a fresh fixture file path and BODY-eval. Clean up after."
- (declare (indent 1))
- `(let ((,var (test-ai-mcp--write-fixture))
- (cj/mcp--config-cache nil))
- (unwind-protect (progn ,@body)
- (when (file-exists-p ,var) (delete-file ,var)))))
-
-;; -------------------------------------------------------- redact
-
-(ert-deftest test-ai-mcp-redact-token-eq-normal ()
- "Normal: --token=VALUE has the value replaced by ***."
- (should (equal (cj/mcp--redact "--token=abc123") "--token=***")))
-
-(ert-deftest test-ai-mcp-redact-token-spaced-boundary ()
- "Boundary: --token VALUE (space separator) is also redacted."
- (should (equal (cj/mcp--redact "--token abc123") "--token ***")))
-
-(ert-deftest test-ai-mcp-redact-secret-flag-normal ()
- "Normal: --secret=VALUE is redacted."
- (should (equal (cj/mcp--redact "--secret=topsecret") "--secret=***")))
-
-(ert-deftest test-ai-mcp-redact-password-flag-normal ()
- "Normal: --password=VALUE is redacted."
- (should (equal (cj/mcp--redact "--password=hunter2") "--password=***")))
-
-(ert-deftest test-ai-mcp-redact-figma-api-key-normal ()
- "Normal: --figma-api-key=VALUE is redacted (covers the figma case)."
- (should (equal (cj/mcp--redact "--figma-api-key=figd_xyz")
- "--figma-api-key=***")))
-
-(ert-deftest test-ai-mcp-redact-authorization-header-normal ()
- "Normal: Authorization header value (scheme + token) is masked."
- (should (equal (cj/mcp--redact "Authorization: Bearer ghp_xyz123")
- "Authorization: ***")))
-
-(ert-deftest test-ai-mcp-redact-url-token-normal ()
- "Normal: ?token=VALUE in a URL is masked."
- (should (equal (cj/mcp--redact "https://api.example/v1?token=abc123&page=2")
- "https://api.example/v1?token=***&page=2")))
-
-(ert-deftest test-ai-mcp-redact-no-secrets-boundary ()
- "Boundary: a string with no known secrets is returned unchanged."
- (should (equal (cj/mcp--redact "hello world, nothing secret here")
- "hello world, nothing secret here")))
-
-(ert-deftest test-ai-mcp-redact-empty-string-boundary ()
- "Boundary: empty string returns empty string."
- (should (equal (cj/mcp--redact "") "")))
-
-(ert-deftest test-ai-mcp-redact-multiple-secrets-boundary ()
- "Boundary: multiple secrets in one string are all redacted."
- (let* ((input "--token=abc --secret=xyz --password=qwe")
- (out (cj/mcp--redact input)))
- (should (equal out "--token=*** --secret=*** --password=***"))))
-
-(ert-deftest test-ai-mcp-redact-nil-input-error ()
- "Error: nil input returns nil rather than signaling."
- (should (null (cj/mcp--redact nil))))
-
-(ert-deftest test-ai-mcp-redact-sentinel-never-leaks ()
- "Sentinel REDACTED_TEST_SECRET is replaced wherever it lives in a secret slot."
- (dolist (input (list (format "--token=%s" test-ai-mcp--sentinel)
- (format "--figma-api-key=%s" test-ai-mcp--sentinel)
- (format "Authorization: Bearer %s" test-ai-mcp--sentinel)
- (format "https://x/y?token=%s" test-ai-mcp--sentinel)))
- (let ((out (cj/mcp--redact input)))
- (should-not (string-match-p test-ai-mcp--sentinel out)))))
-
-;; -------------------------------------------------------- confirm-p
-
-(ert-deftest test-ai-mcp-confirm-p-write-pattern-normal ()
- "Normal: a write-prefixed tool name returns t."
- (should (cj/mcp--confirm-p "mcp__linear__create_issue")))
-
-(ert-deftest test-ai-mcp-confirm-p-read-pattern-normal ()
- "Normal: a read-prefixed tool name returns nil."
- (should-not (cj/mcp--confirm-p "mcp__linear__list_issues")))
-
-(ert-deftest test-ai-mcp-confirm-p-unknown-fails-closed-boundary ()
- "Boundary: a name matching neither read nor write defaults to t (fail closed)."
- (should (cj/mcp--confirm-p "mcp__linear__frobnicate")))
-
-(ert-deftest test-ai-mcp-confirm-p-explicit-remote-name-boundary ()
- "Boundary: REMOTE-NAME arg overrides the prefix-strip of GPTEL-NAME."
- ;; The gptel-name claims read, but the explicit remote-name is a write
- ;; verb, so confirm should still fire.
- (should (cj/mcp--confirm-p "mcp__linear__list_issues" "create_issue")))
-
-(ert-deftest test-ai-mcp-confirm-p-override-wins-boundary ()
- "Boundary: cj/mcp-tool-confirm-overrides wins over the classifier."
- (let ((cj/mcp-tool-confirm-overrides
- '(("mcp__linear__create_issue" . nil))))
- (should-not (cj/mcp--confirm-p "mcp__linear__create_issue"))))
-
-;; -------------------------------------------------------- normalize-description
-
-(ert-deftest test-ai-mcp-normalize-description-read-normal ()
- "Normal: a read tool gets the bare [SERVER] prefix."
- (should (equal
- (cj/mcp--normalize-description
- "linear"
- '(:name "list_issues" :description "List issues in a Linear team."))
- "[linear] List issues in a Linear team.")))
-
-(ert-deftest test-ai-mcp-normalize-description-write-normal ()
- "Normal: a write tool gets [SERVER WRITE] prefix."
- (should (equal
- (cj/mcp--normalize-description
- "linear"
- '(:name "create_issue" :description "Create a new Linear issue."))
- "[linear WRITE] Create a new Linear issue.")))
-
-(ert-deftest test-ai-mcp-normalize-description-unknown-boundary ()
- "Boundary: a tool matching neither classifier gets [SERVER ?] prefix."
- (should (equal
- (cj/mcp--normalize-description
- "google-keep"
- '(:name "frobnicate" :description "Do the frob thing."))
- "[google-keep ?] Do the frob thing.")))
-
-(ert-deftest test-ai-mcp-normalize-description-missing-upstream-boundary ()
- "Boundary: missing upstream description falls back to a placeholder."
- (should (equal
- (cj/mcp--normalize-description
- "linear"
- '(:name "list_issues"))
- "[linear] (no description provided by server)")))
-
-;; -------------------------------------------------------- read-claude-config
-
-(ert-deftest test-ai-mcp-read-claude-config-good-fixture-normal ()
- "Normal: parsing a well-formed fixture returns :ok t and the parsed data."
- (test-ai-mcp--with-fixture path
- (let ((result (cj/mcp--read-claude-config path)))
- (should (plist-get result :ok))
- (should (plist-get (plist-get result :data) :mcpServers)))))
-
-(ert-deftest test-ai-mcp-read-claude-config-missing-file-error ()
- "Error: missing file returns :ok nil with :reason missing-file."
- (let ((cj/mcp--config-cache nil)
- (path "/nonexistent/path/never-will-exist.json"))
- (let ((result (cj/mcp--read-claude-config path)))
- (should-not (plist-get result :ok))
- (should (eq (plist-get result :reason) 'missing-file)))))
-
-(ert-deftest test-ai-mcp-read-claude-config-malformed-json-error ()
- "Error: malformed JSON returns :ok nil with :reason malformed-json and a message."
- (let ((cj/mcp--config-cache nil)
- (tmp (make-temp-file "test-ai-mcp-malformed-" nil ".json")))
- (unwind-protect
- (progn
- (with-temp-file tmp (insert "{ this is not valid json ::: "))
- (let ((result (cj/mcp--read-claude-config tmp)))
- (should-not (plist-get result :ok))
- (should (eq (plist-get result :reason) 'malformed-json))
- (should (stringp (plist-get result :message)))))
- (delete-file tmp))))
-
-(ert-deftest test-ai-mcp-read-claude-config-empty-object-boundary ()
- "Boundary: an empty JSON object parses to ok with empty data plist."
- (let ((cj/mcp--config-cache nil)
- (tmp (make-temp-file "test-ai-mcp-empty-" nil ".json")))
- (unwind-protect
- (progn
- (with-temp-file tmp (insert "{}"))
- (let ((result (cj/mcp--read-claude-config tmp)))
- (should (plist-get result :ok))
- ;; :mcpServers is absent; plist-get returns nil.
- (should-not (plist-get (plist-get result :data) :mcpServers))))
- (delete-file tmp))))
-
-(ert-deftest test-ai-mcp-read-claude-config-cache-hit-boundary ()
- "Boundary: a second read with the same mtime reuses the cache.
-We detect cache reuse by mutating the cached :data alist after the first
-read and verifying the second read returns the mutated value."
- (test-ai-mcp--with-fixture path
- (let* ((first (cj/mcp--read-claude-config path))
- (cache cj/mcp--config-cache))
- (should (plist-get first :ok))
- ;; Mutate the cached :data so a cache-hit returns the marker.
- (plist-put cache :data '(:sentinel cache-was-hit))
- (let ((second (cj/mcp--read-claude-config path)))
- (should (equal (plist-get second :data) '(:sentinel cache-was-hit)))))))
-
-(ert-deftest test-ai-mcp-read-claude-config-cache-invalidate-on-mtime-boundary ()
- "Boundary: changing the file's mtime forces a reparse."
- (test-ai-mcp--with-fixture path
- (let* ((first (cj/mcp--read-claude-config path))
- (cache cj/mcp--config-cache))
- (should (plist-get first :ok))
- ;; Poison the cache, then bump mtime; the next read should reparse.
- (plist-put cache :data '(:sentinel cache-was-hit))
- (set-file-times path (time-add (current-time) 2))
- ;; Update the cache var since set-file-times changed file mtime.
- (setq cj/mcp--config-cache cache)
- (let ((second (cj/mcp--read-claude-config path)))
- ;; Real reparse should give us the real data, not the sentinel.
- (should (plist-get (plist-get second :data) :mcpServers))))))
-
-(ert-deftest test-ai-mcp-read-claude-config-missing-mcpservers-boundary ()
- "Boundary: a valid JSON without :mcpServers parses but the subtree is nil."
- (let ((cj/mcp--config-cache nil)
- (tmp (make-temp-file "test-ai-mcp-no-mcp-" nil ".json")))
- (unwind-protect
- (progn
- (with-temp-file tmp (insert "{\"other\": 1}"))
- (let ((result (cj/mcp--read-claude-config tmp)))
- (should (plist-get result :ok))
- (should-not (plist-get (plist-get result :data) :mcpServers))))
- (delete-file tmp))))
-
-;; -------------------------------------------------------- get-env / get-secret-arg
-
-(ert-deftest test-ai-mcp-get-env-known-server-with-env-normal ()
- "Normal: env-bearing server returns its env plist."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (env (cj/mcp--get-env "google-calendar")))
- (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS)
- test-ai-mcp--sentinel)))))
-
-(ert-deftest test-ai-mcp-get-env-known-server-without-env-boundary ()
- "Boundary: a server with no env subtree returns nil."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path))
- (should-not (cj/mcp--get-env "drawio")))))
-
-(ert-deftest test-ai-mcp-get-env-unknown-server-error ()
- "Error: unknown server returns nil without signaling."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path))
- (should-not (cj/mcp--get-env "no-such-server")))))
-
-(ert-deftest test-ai-mcp-get-secret-arg-figma-normal ()
- "Normal: figma's --figma-api-key= value is extracted from args."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (value (cj/mcp--get-secret-arg "figma" "--figma-api-key")))
- (should (equal value test-ai-mcp--sentinel)))))
-
-(ert-deftest test-ai-mcp-get-secret-arg-missing-flag-error ()
- "Error: a flag not in the server's args returns nil."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (value (cj/mcp--get-secret-arg "figma" "--no-such-flag")))
- (should (null value)))))
-
-;; -------------------------------------------------------- build-server-alist
-
-(ert-deftest test-ai-mcp-build-server-alist-all-enabled-normal ()
- "Normal: with default specs and all-enabled list, alist has all 9 entries."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist)))
- (should (= (length alist) 9))
- ;; Every name appears.
- (dolist (name '("linear" "notion" "figma" "slack-deepsat" "drawio"
- "google-calendar" "google-docs-personal"
- "google-docs-work" "google-keep"))
- (should (assoc name alist))))))
-
-(ert-deftest test-ai-mcp-build-server-alist-filter-by-enabled-boundary ()
- "Boundary: enabled subset of names produces a subset alist."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist
- cj/mcp-server-specs
- '("drawio" "linear"))))
- (should (= (length alist) 2))
- (should (assoc "drawio" alist))
- (should (assoc "linear" alist))
- (should-not (assoc "figma" alist)))))
-
-(ert-deftest test-ai-mcp-build-server-alist-stdio-shape-normal ()
- "Normal: a stdio entry has :type, :command, :args (no :url)."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist
- cj/mcp-server-specs '("drawio"))))
- (let ((entry (cdr (assoc "drawio" alist))))
- (should (equal (plist-get entry :type) "stdio"))
- (should (equal (plist-get entry :command) "npx"))
- (should (listp (plist-get entry :args)))
- (should-not (plist-get entry :url))))))
-
-(ert-deftest test-ai-mcp-build-server-alist-http-shape-normal ()
- "Normal: an http entry has :type and :url (no :command)."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist
- cj/mcp-server-specs '("linear"))))
- (let ((entry (cdr (assoc "linear" alist))))
- (should (equal (plist-get entry :type) "http"))
- (should (equal (plist-get entry :url) "https://mcp.linear.app/mcp"))
- (should-not (plist-get entry :command))))))
-
-(ert-deftest test-ai-mcp-build-server-alist-sse-shape-normal ()
- "Normal: an sse entry has :type and :url."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist
- cj/mcp-server-specs '("slack-deepsat"))))
- (let ((entry (cdr (assoc "slack-deepsat" alist))))
- (should (equal (plist-get entry :type) "sse"))
- (should (equal (plist-get entry :url)
- "http://127.0.0.1:13080/sse"))))))
-
-(ert-deftest test-ai-mcp-build-server-alist-env-merge-normal ()
- "Normal: env-bearing server has its env plist merged into the entry."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist
- cj/mcp-server-specs '("google-calendar"))))
- (let* ((entry (cdr (assoc "google-calendar" alist)))
- (env (plist-get entry :env)))
- (should env)
- (should (equal (plist-get env :GOOGLE_OAUTH_CREDENTIALS)
- test-ai-mcp--sentinel))))))
-
-(ert-deftest test-ai-mcp-build-server-alist-secret-args-splice-normal ()
- "Normal: figma's --figma-api-key= is spliced into :args from Claude config."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (alist (cj/mcp--build-server-alist
- cj/mcp-server-specs '("figma"))))
- (let* ((entry (cdr (assoc "figma" alist)))
- (args (plist-get entry :args))
- (api-arg (cl-find-if
- (lambda (a) (string-prefix-p "--figma-api-key=" a))
- args)))
- (should api-arg)
- (should (equal api-arg (format "--figma-api-key=%s"
- test-ai-mcp--sentinel)))))))
-
-(ert-deftest test-ai-mcp-build-server-alist-no-mutation-boundary ()
- "Boundary: building the alist does not mutate `cj/mcp-server-specs'."
- (test-ai-mcp--with-fixture path
- (let* ((cj/mcp-claude-config path)
- (snapshot (copy-tree cj/mcp-server-specs)))
- (cj/mcp--build-server-alist)
- (should (equal cj/mcp-server-specs snapshot)))))
-
-(provide 'test-ai-mcp-helpers)
-;;; test-ai-mcp-helpers.el ends here