diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..225ae02 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + rspec: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: ["3.1", "3.2", "3.3", "3.4"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run specs + run: bundle exec rspec diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..5be63fc --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--format documentation diff --git a/active_admin_mcp.gemspec b/active_admin_mcp.gemspec index 479ebaa..14a31bf 100644 --- a/active_admin_mcp.gemspec +++ b/active_admin_mcp.gemspec @@ -24,4 +24,7 @@ Gem::Specification.new do |spec| spec.add_dependency "rails", ">= 6.1" spec.add_dependency "activeadmin", ">= 2.0" + + spec.add_development_dependency "rspec", "~> 3.0" + spec.add_development_dependency "sqlite3", ">= 1.4" end diff --git a/spec/active_admin_mcp/api_token_spec.rb b/spec/active_admin_mcp/api_token_spec.rb new file mode 100644 index 0000000..f239fef --- /dev/null +++ b/spec/active_admin_mcp/api_token_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "spec_helper" +require "support/active_record" + +RSpec.describe ActiveAdminMcp::ApiToken do + let(:user) { User.create!(email: "admin@example.com") } + + after do + described_class.delete_all + User.delete_all + end + + describe ".digest" do + it "returns the SHA256 hex digest of the raw token" do + expect(described_class.digest("secret")) + .to eq(Digest::SHA256.hexdigest("secret")) + end + end + + describe "token generation on create" do + subject(:token) { described_class.create!(user: user) } + + it "assigns a raw token with the aamcp_ prefix" do + expect(token.raw_token).to match(/\Aaamcp_[0-9a-f]{64}\z/) + end + + it "stores only the digest of the raw token, never the raw value" do + expect(token.token_digest).to eq(described_class.digest(token.raw_token)) + expect(token.reload.read_attribute(:token_digest)).not_to include(token.raw_token) + end + end + + describe "validations" do + it "requires a user" do + record = described_class.new + expect(record).not_to be_valid + expect(record.errors[:user_id]).to be_present + end + + it "rejects a duplicate token digest" do + # Force both records to generate the same raw token (and therefore the + # same digest) so the uniqueness validation has something to reject. + allow(SecureRandom).to receive(:hex).and_return("f" * 64) + + described_class.create!(user: user) + duplicate = described_class.new(user: user) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:token_digest]).to be_present + end + end + + describe ".find_by_raw_token" do + it "finds the token matching the raw value" do + token = described_class.create!(user: user) + expect(described_class.find_by_raw_token(token.raw_token)).to eq(token) + end + + it "returns nil for an unknown raw token" do + described_class.create!(user: user) + expect(described_class.find_by_raw_token("aamcp_unknown")).to be_nil + end + + it "returns nil for a blank token without querying" do + expect(described_class.find_by_raw_token("")).to be_nil + expect(described_class.find_by_raw_token(nil)).to be_nil + end + end + + describe "#touch_last_used!" do + subject(:token) { described_class.create!(user: user) } + + it "sets last_used_at when it has never been used" do + expect { token.touch_last_used! } + .to change { token.reload.last_used_at }.from(nil) + end + + it "refreshes last_used_at when the throttle window has passed" do + token.update_column(:last_used_at, 10.minutes.ago) + + expect { token.touch_last_used! } + .to(change { token.reload.last_used_at }) + end + + it "does not update within the throttle window" do + recent = 1.minute.ago + token.update_column(:last_used_at, recent) + + expect { token.touch_last_used! } + .not_to(change { token.reload.last_used_at }) + end + end +end diff --git a/spec/active_admin_mcp/configuration_spec.rb b/spec/active_admin_mcp/configuration_spec.rb new file mode 100644 index 0000000..6d33837 --- /dev/null +++ b/spec/active_admin_mcp/configuration_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveAdminMcp::Configuration do + subject(:config) { described_class.new } + + describe "defaults" do + it "uses the expected default values" do + expect(config.authentication_method).to be_nil + expect(config.user_class).to eq("User") + expect(config.current_user_method).to eq(:current_admin_user) + expect(config.menu_parent).to be_nil + expect(config.mount_path).to eq("/mcp") + expect(config.mount_strategy).to eq(:prepend) + expect(config.auth_header_name).to eq("Authorization") + end + end + + describe "#mount_strategy=" do + it "accepts each supported strategy" do + described_class::MOUNT_STRATEGIES.each do |strategy| + config.mount_strategy = strategy + expect(config.mount_strategy).to eq(strategy) + end + end + + it "raises ArgumentError for an unsupported strategy" do + expect { config.mount_strategy = :sideways } + .to raise_error(ArgumentError, /Invalid mount strategy: sideways/) + end + + it "lists the valid strategies in the error message" do + expect { config.mount_strategy = :nope } + .to raise_error(ArgumentError, /prepend, append, none/) + end + end + + describe "#authentication_enabled?" do + it "is true only when the method is :devise_token" do + config.authentication_method = :devise_token + expect(config.authentication_enabled?).to be(true) + end + + it "is false when no authentication method is set" do + config.authentication_method = nil + expect(config.authentication_enabled?).to be(false) + end + + it "is false for an unrecognised authentication method" do + config.authentication_method = :something_else + expect(config.authentication_enabled?).to be(false) + end + end +end diff --git a/spec/active_admin_mcp/request_handler_spec.rb b/spec/active_admin_mcp/request_handler_spec.rb new file mode 100644 index 0000000..41a8bc5 --- /dev/null +++ b/spec/active_admin_mcp/request_handler_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveAdminMcp::RequestHandler do + subject(:handler) { described_class.new } + + def handle(method, params = nil, id: 1) + request = { "id" => id, "method" => method } + request["params"] = params if params + handler.handle(request) + end + + describe "#handle" do + describe "initialize" do + it "returns the protocol version, server info and capabilities" do + result = handle("initialize")[:result] + + expect(result[:protocolVersion]).to eq(described_class::PROTOCOL_VERSION) + expect(result[:serverInfo]).to eq(name: "active-admin-mcp", version: ActiveAdminMcp::VERSION) + expect(result[:capabilities]).to eq(tools: {}) + end + + it "echoes the request id and jsonrpc version" do + response = handle("initialize", id: 42) + + expect(response[:jsonrpc]).to eq("2.0") + expect(response[:id]).to eq(42) + end + end + + describe "notifications/initialized" do + it "returns nil so the controller sends no content" do + expect(handle("notifications/initialized")).to be_nil + end + end + + describe "ping" do + it "returns an empty result" do + expect(handle("ping")[:result]).to eq({}) + end + end + + describe "tools/list" do + it "advertises the list_resources and query tools" do + tools = handle("tools/list")[:result][:tools] + + expect(tools.map { |t| t[:name] }).to contain_exactly("list_resources", "query") + end + + it "marks resource as required on the query tool" do + tools = handle("tools/list")[:result][:tools] + query = tools.find { |t| t[:name] == "query" } + + expect(query[:inputSchema][:required]).to eq(["resource"]) + end + end + + describe "unknown method" do + it "returns a -32601 Method not found error" do + response = handle("does/not/exist") + + expect(response[:error][:code]).to eq(-32_601) + expect(response[:error][:message]).to eq("Method not found: does/not/exist") + end + end + end + + describe "tools/call" do + def call_tool(name, arguments = {}) + response = handle("tools/call", { "name" => name, "arguments" => arguments }) + text = response[:result][:content].first[:text] + JSON.parse(text) + end + + describe "list_resources" do + it "returns the registry's resources" do + resources = [{ name: "User", table: "users", attributes: %w[id email] }] + allow(ActiveAdminMcp::ResourceRegistry).to receive(:all).and_return(resources) + + expect(call_tool("list_resources")).to eq("resources" => [ + { "name" => "User", "table" => "users", "attributes" => %w[id email] }, + ]) + end + end + + describe "query" do + let(:records) { [{ "id" => 1, "name" => "john" }] } + let(:relation) { double("relation", limit: records) } + let(:model) { double("model") } + + before do + allow(records).to receive(:as_json).and_return(records) + allow(records).to receive(:size).and_return(records.length) + allow(model).to receive(:ransack).and_return(double("search", result: relation)) + allow(ActiveAdminMcp::ResourceRegistry).to receive(:find) + .with("User").and_return(name: "User", model: model) + end + + it "returns matching records with a count" do + result = call_tool("query", "resource" => "User", "q" => { "name_cont" => "john" }) + + expect(result).to eq("resource" => "User", "count" => 1, "records" => records) + end + + it "defaults the limit to 25 when none is given" do + expect(relation).to receive(:limit).with(25).and_return(records) + call_tool("query", "resource" => "User") + end + + it "caps the limit at 100" do + expect(relation).to receive(:limit).with(100).and_return(records) + call_tool("query", "resource" => "User", "limit" => 500) + end + + it "passes an empty Ransack query when none is provided" do + expect(model).to receive(:ransack).with({}).and_return(double("search", result: relation)) + call_tool("query", "resource" => "User") + end + + it "returns an error when the resource is not found" do + allow(ActiveAdminMcp::ResourceRegistry).to receive(:find).with("Ghost").and_return(nil) + + expect(call_tool("query", "resource" => "Ghost")) + .to eq("error" => "Resource not found: Ghost") + end + end + + describe "an unknown tool" do + it "returns an error naming the tool" do + expect(call_tool("frobnicate")).to eq("error" => "Unknown tool: frobnicate") + end + end + end +end diff --git a/spec/active_admin_mcp/resource_registry_spec.rb b/spec/active_admin_mcp/resource_registry_spec.rb new file mode 100644 index 0000000..f606021 --- /dev/null +++ b/spec/active_admin_mcp/resource_registry_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveAdminMcp::ResourceRegistry do + # Builds a stand-in ActiveAdmin resource model class. `ransackable` and + # `table` toggle whether it looks queryable/backed to the registry. + def build_model(name:, columns: %w[id], ransackable: true, table: true) + Class.new do + define_singleton_method(:name) { name } + define_singleton_method(:table_name) { name.tableize } + define_singleton_method(:column_names) { columns } + define_singleton_method(:table_exists?) { table } + define_singleton_method(:ransack) { |*| } if ransackable + end + end + + def stub_active_admin(models) + resources = models.map { |m| double("resource", resource_class: m) } + namespace = double("namespace", resources: resources) + application = double("application", namespaces: { admin: namespace }) + stub_const("ActiveAdmin", double("ActiveAdmin", application: application)) + end + + context "when ActiveAdmin is not defined" do + it "returns an empty list of resources" do + hide_const("ActiveAdmin") + expect(described_class.all).to eq([]) + end + + it "finds nothing" do + hide_const("ActiveAdmin") + expect(described_class.find("User")).to be_nil + end + end + + describe ".all" do + it "returns name, table and non-sensitive attributes for each resource" do + stub_active_admin([ + build_model(name: "User", columns: %w[id email encrypted_password api_key]), + ]) + + expect(described_class.all).to eq([ + { name: "User", table: "users", attributes: %w[id email] }, + ]) + end + + it "filters out sensitive attributes" do + stub_active_admin([ + build_model(name: "Account", + columns: %w[id password_digest reset_password_token secret name]), + ]) + + attributes = described_class.all.first[:attributes] + expect(attributes).to eq(%w[id name]) + end + + it "skips resources whose backing table does not exist" do + stub_active_admin([ + build_model(name: "User"), + build_model(name: "Ghost", table: false), + ]) + + expect(described_class.all.map { |r| r[:name] }).to eq(["User"]) + end + + it "skips resources whose model is not Ransack-searchable" do + stub_active_admin([ + build_model(name: "User"), + build_model(name: "Legacy", ransackable: false), + ]) + + expect(described_class.all.map { |r| r[:name] }).to eq(["User"]) + end + + it "returns an empty list when there is no :admin namespace" do + application = double("application", namespaces: {}) + stub_const("ActiveAdmin", double("ActiveAdmin", application: application)) + + expect(described_class.all).to eq([]) + end + end + + describe ".find" do + it "returns the name and model class for a known resource" do + user = build_model(name: "User") + stub_active_admin([user]) + + expect(described_class.find("User")).to eq(name: "User", model: user) + end + + it "returns nil for an unknown resource" do + stub_active_admin([build_model(name: "User")]) + + expect(described_class.find("Nope")).to be_nil + end + + it "does not find resources whose table does not exist" do + stub_active_admin([build_model(name: "Ghost", table: false)]) + + expect(described_class.find("Ghost")).to be_nil + end + end +end diff --git a/spec/active_admin_mcp_spec.rb b/spec/active_admin_mcp_spec.rb new file mode 100644 index 0000000..24cd120 --- /dev/null +++ b/spec/active_admin_mcp_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe ActiveAdminMcp do + describe ".config" do + it "returns a Configuration instance" do + expect(described_class.config).to be_a(ActiveAdminMcp::Configuration) + end + + it "memoizes the same instance across calls" do + expect(described_class.config).to be(described_class.config) + end + end + + describe ".configure" do + it "yields the configuration object" do + expect { |b| described_class.configure(&b) } + .to yield_with_args(described_class.config) + end + + it "persists changes made in the block" do + described_class.configure { |c| c.mount_path = "/custom" } + expect(described_class.config.mount_path).to eq("/custom") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..5e57bd9 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails" +require "active_record" +require "action_controller" +require "active_admin_mcp" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed + + # Reset the memoized global configuration between examples so that + # config-touching specs don't leak state into one another. + config.after do + ActiveAdminMcp.instance_variable_set(:@config, nil) + end +end diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb new file mode 100644 index 0000000..b992a74 --- /dev/null +++ b/spec/support/active_record.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "active_record" + +# Spin up an in-memory SQLite database with just the tables the ApiToken +# model needs. Loaded only by specs that exercise the ActiveRecord model. +ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") + +ActiveRecord::Schema.verbose = false +ActiveRecord::Schema.define do + create_table :users, force: true do |t| + t.string :email + t.timestamps + end + + create_table :mcp_api_tokens, force: true do |t| + t.references :user, null: false + t.string :token_digest, null: false + t.string :name + t.datetime :last_used_at + t.timestamps + end + + add_index :mcp_api_tokens, :token_digest, unique: true +end + +# The ApiToken belongs_to :user, class_name: ActiveAdminMcp.config.user_class +# (defaults to "User"), so a matching constant must exist. +class User < ActiveRecord::Base +end + +# The model lives under app/ and is normally loaded by Rails eager-loading; +# require it explicitly for the specs. +require_relative "../../app/models/active_admin_mcp/api_token"