Commit a107d9d

mo khan <mo@mokhan.ca>
2026-01-28 05:09:36
test: backfill tests
1 parent 4b622d9
lib/elelem/mcp.rb
@@ -82,7 +82,7 @@ module Elelem
       end
 
       def logger
-        @logger ||= Logger.new("mcp.log")
+        @logger ||= Logger.new(File.expand_path("~/.elelem/mcp.log"))
       end
 
       private
spec/elelem/mcp/http_server_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::HttpServer do
+  let(:url) { "https://mcp.example.com/sse" }
+  let(:http) { double("http") }
+
+  describe "#parse_sse" do
+    subject { described_class.allocate }
+
+    it "parses single SSE event" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("data: {\"result\": \"ok\"}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "result" => "ok" })
+    end
+
+    it "parses chunked SSE events" do
+      response = double
+      chunks = ["data: {\"id\"", ": 1}\n\ndata: {\"id\": 2}\n\n"]
+      allow(response).to receive(:read_body) do |&block|
+        chunks.each { |c| block.call(c) }
+      end
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "id" => 2 })
+    end
+
+    it "ignores non-data lines" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("event: message\ndata: {\"value\": 42}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "value" => 42 })
+    end
+
+    it "returns last event when multiple present" do
+      response = double
+      allow(response).to receive(:read_body).and_yield("data: {\"n\": 1}\n\ndata: {\"n\": 2}\n\n")
+
+      result = subject.send(:parse_sse, response)
+      expect(result).to eq({ "n" => 2 })
+    end
+  end
+
+  describe "#parse_response" do
+    subject { described_class.allocate }
+
+    it "parses JSON response" do
+      response = double(content_type: "application/json", body: '{"tools": []}')
+      result = subject.send(:parse_response, response)
+      expect(result).to eq({ "tools" => [] })
+    end
+
+    it "parses SSE response" do
+      response = double(content_type: "text/event-stream")
+      allow(response).to receive(:read_body).and_yield("data: {\"ok\": true}\n\n")
+
+      result = subject.send(:parse_response, response)
+      expect(result).to eq({ "ok" => true })
+    end
+
+    it "returns nil for empty body" do
+      response = double(content_type: "application/json", body: "")
+      result = subject.send(:parse_response, response)
+      expect(result).to be_nil
+    end
+  end
+
+  describe "#request_headers" do
+    subject do
+      server = described_class.allocate
+      server.instance_variable_set(:@headers, {})
+      server.instance_variable_set(:@session_id, nil)
+      server.instance_variable_set(:@access_token, nil)
+      server
+    end
+
+    it "includes Accept header" do
+      headers = subject.send(:request_headers)
+      expect(headers["Accept"]).to eq("application/json, text/event-stream")
+    end
+
+    it "includes session ID when set" do
+      subject.instance_variable_set(:@session_id, "abc123")
+      headers = subject.send(:request_headers)
+      expect(headers["Mcp-Session-Id"]).to eq("abc123")
+    end
+
+    it "includes authorization when access_token set" do
+      subject.instance_variable_set(:@access_token, "token123")
+      headers = subject.send(:request_headers)
+      expect(headers["Authorization"]).to eq("Bearer token123")
+    end
+  end
+end
spec/elelem/mcp/oauth_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::OAuth do
+  let(:resource_url) { "https://mcp.example.com/sse" }
+  let(:http) { double("http") }
+  let(:storage) { instance_double(Elelem::MCP::TokenStorage) }
+
+  subject { described_class.new(resource_url, http: http) }
+
+  before do
+    allow(Elelem::MCP::TokenStorage).to receive(:new).and_return(storage)
+  end
+
+  describe "#token" do
+    context "with valid cached token" do
+      it "returns cached access_token" do
+        allow(storage).to receive(:load).with(resource_url).and_return({
+          access_token: "cached_token",
+          expires_at: Time.now.to_i + 3600
+        })
+
+        expect(subject.token).to eq("cached_token")
+      end
+    end
+
+    context "with expired token and refresh_token" do
+      let(:auth_metadata) do
+        {
+          "authorization_endpoint" => "https://auth.example.com/authorize",
+          "token_endpoint" => "https://auth.example.com/token",
+          "registration_endpoint" => "https://auth.example.com/register"
+        }
+      end
+
+      it "refreshes using refresh_token" do
+        allow(storage).to receive(:load).with(resource_url).and_return({
+          access_token: "old_token",
+          refresh_token: "refresh_abc",
+          expires_at: Time.now.to_i - 100
+        })
+
+        allow(storage).to receive(:load_client).with(resource_url).and_return({
+          client_id: "elelem-123"
+        })
+
+        resource_response = double(body: { "authorization_servers" => ["https://auth.example.com"] }.to_json)
+        auth_response = double(body: auth_metadata.to_json)
+        token_response = double(body: { "access_token" => "new_token", "expires_in" => 3600 }.to_json)
+
+        allow(http).to receive(:get).with("https://mcp.example.com/.well-known/oauth-protected-resource").and_yield(resource_response)
+        allow(http).to receive(:get).with("https://auth.example.com/.well-known/oauth-authorization-server").and_yield(auth_response)
+        allow(http).to receive(:post).and_yield(token_response)
+        allow(storage).to receive(:save)
+
+        expect(subject.token).to eq("new_token")
+      end
+    end
+  end
+
+  describe "PKCE generation" do
+    it "generates valid verifier and challenge" do
+      verifier, challenge = subject.send(:generate_pkce)
+
+      expect(verifier.length).to be >= 43
+      expect(challenge.length).to be >= 43
+      expect(challenge).not_to include("+", "/", "=")
+
+      expected_challenge = Base64.urlsafe_encode64(
+        Digest::SHA256.digest(verifier),
+        padding: false
+      )
+      expect(challenge).to eq(expected_challenge)
+    end
+  end
+
+  describe "expiration check" do
+    it "considers token expired when within 60 seconds of expiry" do
+      stored = { expires_at: Time.now.to_i + 30 }
+      expect(subject.send(:expired?, stored)).to be true
+    end
+
+    it "considers token valid when more than 60 seconds remain" do
+      stored = { expires_at: Time.now.to_i + 120 }
+      expect(subject.send(:expired?, stored)).to be false
+    end
+
+    it "considers token valid when no expires_at" do
+      stored = { expires_at: nil }
+      expect(subject.send(:expired?, stored)).to be false
+    end
+  end
+end
spec/elelem/mcp/token_storage_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::MCP::TokenStorage do
+  subject { described_class.new }
+
+  let(:resource_url) { "https://example.com/mcp" }
+  let(:token_dir) { File.expand_path("~/.config/elelem/tokens") }
+
+  after do
+    Dir.glob(File.join(token_dir, "*.json")).each { |f| File.delete(f) rescue nil }
+  end
+
+  describe "#save and #load" do
+    it "stores and retrieves tokens" do
+      subject.save(resource_url, access_token: "abc123", refresh_token: "refresh456", expires_in: 3600)
+
+      stored = subject.load(resource_url)
+      expect(stored[:access_token]).to eq("abc123")
+      expect(stored[:refresh_token]).to eq("refresh456")
+      expect(stored[:expires_at]).to be > Time.now.to_i
+    end
+
+    it "returns nil for unknown resource" do
+      expect(subject.load("https://unknown.com")).to be_nil
+    end
+
+    it "handles missing refresh_token" do
+      subject.save(resource_url, access_token: "abc123")
+
+      stored = subject.load(resource_url)
+      expect(stored[:access_token]).to eq("abc123")
+      expect(stored[:refresh_token]).to be_nil
+    end
+  end
+
+  describe "#save_client and #load_client" do
+    it "stores and retrieves client registration" do
+      client_data = { "client_id" => "elelem-123", "client_secret" => nil }
+      subject.save_client(resource_url, client_data)
+
+      stored = subject.load_client(resource_url)
+      expect(stored[:client_id]).to eq("elelem-123")
+    end
+
+    it "returns nil for unknown client" do
+      expect(subject.load_client("https://unknown.com")).to be_nil
+    end
+  end
+
+  describe "file permissions" do
+    it "creates token files with 0600 permissions" do
+      subject.save(resource_url, access_token: "secret")
+
+      files = Dir.glob(File.join(token_dir, "*.json"))
+      expect(files).not_to be_empty
+      files.each do |f|
+        mode = File.stat(f).mode & 0o777
+        expect(mode).to eq(0o600)
+      end
+    end
+  end
+end
spec/elelem/toolbox_spec.rb
@@ -60,4 +60,43 @@ RSpec.describe Elelem::Toolbox do
       expect(result[:output]).to include("a")
     end
   end
+
+  describe "hooks" do
+    it "runs tool-specific before hooks" do
+      called = false
+      subject.before("read") { |_args| called = true }
+      subject.run("read", { "path" => __FILE__ })
+      expect(called).to be true
+    end
+
+    it "runs tool-specific after hooks" do
+      result_seen = nil
+      subject.after("read") { |_args, result| result_seen = result }
+      subject.run("read", { "path" => __FILE__ })
+      expect(result_seen[:content]).to include("RSpec.describe")
+    end
+
+    it "runs global before hooks with tool_name" do
+      tool_names = []
+      subject.before { |_args, tool_name:| tool_names << tool_name }
+      subject.run("read", { "path" => __FILE__ })
+      subject.run("write", { "path" => "/dev/null", "content" => "" })
+      expect(tool_names).to eq(["read", "write"])
+    end
+
+    it "runs global after hooks with tool_name" do
+      tool_names = []
+      subject.after { |_args, _result, tool_name:| tool_names << tool_name }
+      subject.run("read", { "path" => __FILE__ })
+      expect(tool_names).to eq(["read"])
+    end
+
+    it "runs global hooks before tool-specific hooks" do
+      order = []
+      subject.before { |_args, tool_name:| order << :global }
+      subject.before("read") { |_args| order << :specific }
+      subject.run("read", { "path" => __FILE__ })
+      expect(order).to eq([:global, :specific])
+    end
+  end
 end