From 1878ef21bfd2a6ca64f46349c686e3629d308b4a Mon Sep 17 00:00:00 2001 From: Lloyd Watkin Date: Mon, 21 Sep 2026 08:33:38 +0100 Subject: [PATCH] Resolve permit_params in controller context, and close three form gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the four findings in #18. `RecordWriter#resolve_permitted` and `FormDescription#permitted_names` each built a bare `@config.controller.new` to ask a resource what it permits. ActiveAdmin `instance_exec`s a block-form `permit_params` on the controller, so a block reading `current_admin_user` — the documented way to vary the writable set by user — raised `NameError` on that instance. Both call sites rescue to nil, and nil is how "this resource never declared permit_params" is signalled, so `create`, `update` and `describe_form` all refused such a resource outright and said something untrue about why. Both now take the controller from `ControllerDispatcher#controller_with_mcp_user`, which is the context the `permission:` proc fix already established. A form block declaring no inputs of its own was described as `source: "form"` with an empty attribute list, because `[]` is truthy. A bare `f.inputs` is legal and Formtastic only expands it at render time, so the block describes nothing and the description now falls back to `permit_params`. `FormFieldCollector#inputs` ignored its options, so `inputs for: :author` flattened the author's fields into the record's own attributes, where a client would read them as writable and `create` and `update` would drop them. It now reports a nested group, as `has_many` already did. The `permit_params` probe recovers names by offering the controller every model column, so a permitted param that is not a column — `tag_ids`, `*_attributes` — never appears on the fallback path. That limit is now written into the README rather than left undocumented. Each fix is covered end to end by a new fixture resource — `Newsletter`, `Bulletin` and `Dispatch` — and every new e2e example was watched failing against the unfixed library first. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 +++++- lib/activeadmin_mcp/form_description.rb | 30 ++++--- lib/activeadmin_mcp/form_field_collector.rb | 31 +++++-- lib/activeadmin_mcp/record_writer.rb | 9 +- spec/activeadmin_mcp/form_description_spec.rb | 35 ++++++++ .../form_field_collector_spec.rb | 38 ++++++++ spec/activeadmin_mcp/record_writer_spec.rb | 24 ++++++ spec/e2e/fixture_app/README.md | 17 ++++ spec/e2e/fixture_app/app/admin/bulletins.rb | 14 +++ spec/e2e/fixture_app/app/admin/dispatches.rb | 18 ++++ spec/e2e/fixture_app/app/admin/newsletters.rb | 19 ++++ spec/e2e/fixture_app/app/models/bulletin.rb | 6 ++ spec/e2e/fixture_app/app/models/dispatch.rb | 8 ++ spec/e2e/fixture_app/app/models/newsletter.rb | 6 ++ .../20260101000006_create_newsletters.rb | 11 +++ .../20260101000007_create_bulletins.rb | 10 +++ .../20260101000008_create_dispatches.rb | 10 +++ spec/e2e/fixture_app/db/seeds.rb | 24 ++++++ spec/e2e/mcp_tools_spec.rb | 86 +++++++++++++++++++ spec/support/active_admin.rb | 44 ++++++++++ 20 files changed, 446 insertions(+), 20 deletions(-) create mode 100644 spec/e2e/fixture_app/app/admin/bulletins.rb create mode 100644 spec/e2e/fixture_app/app/admin/dispatches.rb create mode 100644 spec/e2e/fixture_app/app/admin/newsletters.rb create mode 100644 spec/e2e/fixture_app/app/models/bulletin.rb create mode 100644 spec/e2e/fixture_app/app/models/dispatch.rb create mode 100644 spec/e2e/fixture_app/app/models/newsletter.rb create mode 100644 spec/e2e/fixture_app/db/migrate/20260101000006_create_newsletters.rb create mode 100644 spec/e2e/fixture_app/db/migrate/20260101000007_create_bulletins.rb create mode 100644 spec/e2e/fixture_app/db/migrate/20260101000008_create_dispatches.rb diff --git a/README.md b/README.md index 5f4f26d..f2b448d 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,10 @@ action, so a write from MCP is the same write the admin UI makes: so a `slug` sent to a `Post` permitting only `title` and `body` never reaches the record. A resource that declares no `permit_params` at all (like `Tag`) is refused outright, with a message saying so — ActiveAdmin cannot - write such a resource through its own forms either. + write such a resource through its own forms either. A block-form + `permit_params` is evaluated in controller context as the authenticated MCP + user, so a block varying the writable set by user gets the same answer it + would give that user in admin. - **Your callbacks run** — ActiveAdmin's `before_build`, `before_create`, `before_save`, `after_update` and friends all fire, because the controller action is what fires them. A `before_create` that fills in a `slug` the form @@ -154,8 +157,14 @@ at render time, so there is nothing to read. The response's `source` says which of the two you are looking at. Either way every field is annotated from the model with the column type it is -stored in and whether the model validates its presence. `has_many` blocks are -reported under `nested` rather than flattened in with the record's own fields. +stored in and whether the model validates its presence. An associated record's +fields — declared with `has_many`, or with `inputs for: :author` — are reported +under `nested` rather than flattened in with the record's own, because a write +against the parent would drop them. + +A form block that declares no inputs of its own is described from +`permit_params` too. A bare `f.inputs` is legal, and Formtastic only expands it +against the model at render time, so there is nothing in the block to read. `action:` selects the gate, not the shape — ActiveAdmin uses one form block for both. `"new"` (the default) requires the resource to register `create` and pass @@ -164,7 +173,7 @@ never submit tells you nothing you can act on, so it is refused with the same messages `create` and `update` use: `Author` is not creatable, and `Tag` declares no permitted params. -Two limits worth knowing: +Three limits worth knowing: - A `collection:` that is an `ActiveRecord::Relation` or a proc is omitted rather than evaluated. Describing a form should not fire a query, and a @@ -174,6 +183,15 @@ Two limits worth knowing: - A field a form block declares but `permit_params` omits is described and then silently dropped on write. This cannot arise on the `permit_params` fallback path. +- On the `permit_params` fallback path, only attributes backed by a database + column are reported. ActiveAdmin keeps no list of the params it permits, only + a method that filters against them, so the names are recovered by offering + that method every column the model has and seeing which survive. A permitted + param that is not a column — `tag_ids`, or a `*_attributes` key from + `accepts_nested_attributes_for` — is never offered and so never appears, + even though `create` and `update` will happily write it. Declare a + `form do ... end` block on such a resource and `describe_form` reads it in + full, associations included. ### Running member, collection and batch actions diff --git a/lib/activeadmin_mcp/form_description.rb b/lib/activeadmin_mcp/form_description.rb index 4ecee09..067184e 100644 --- a/lib/activeadmin_mcp/form_description.rb +++ b/lib/activeadmin_mcp/form_description.rb @@ -4,11 +4,12 @@ module ActiveadminMcp # from column names. # # The description is read from the resource's own `form do ... end` block - # when it declares one. When it does not, ActiveAdmin renders a bare - # `f.inputs` that Formtastic only expands at render time — there is nothing - # to introspect — so the description is derived from the resource's - # `permit_params` instead, which is what `create` and `update` enforce - # anyway. The payload says which of the two it is. + # when it declares one with inputs in it. When it does not — no form block, + # or a block whose only `f.inputs` is the bare one Formtastic expands at + # render time — there is nothing to introspect, so the description is + # derived from the resource's `permit_params` instead, which is what + # `create` and `update` enforce anyway. The payload says which of the two + # it is. # # Either way each field is annotated from the model: the column type it is # stored in, and whether the model validates its presence. @@ -30,7 +31,7 @@ def call(action: "new") return refusal if refusal declared = declared_inputs - return describe(action, "form", declared) if declared + return describe(action, "form", declared) if declared&.any? permitted = permitted_inputs return permit_params_refusal unless permitted @@ -104,9 +105,17 @@ def form_block # ActiveAdmin stores no list of the params it permits, only a method that # filters against them, so the permitted names are recovered by offering it - # every column the model has and seeing which survive. A resource that - # never declared permit_params has no such method to answer, and is - # reported as unwritable rather than described. + # every column the model has and seeing which survive. A permitted param + # that is not a column — `tag_ids`, `*_attributes` — is therefore never + # offered and never reported; a resource declaring those should declare a + # form block, which is read in full. A resource that never declared + # permit_params has no method to answer at all, and is reported as + # unwritable rather than described. + # + # The controller comes from the dispatcher with the MCP user injected, + # because ActiveAdmin instance_execs a block-form permit_params on the + # controller and a block varying the writable set by user raises NameError + # on a bare instance. def permitted_inputs names = permitted_names return nil unless names @@ -116,7 +125,8 @@ def permitted_inputs def permitted_names param_key = @config.param_key.to_sym - controller = @config.controller.new + controller = ControllerDispatcher.new(config: @config, current_user: @current_user) + .controller_with_mcp_user controller.params = ActionController::Parameters.new( param_key => @resource[:model].column_names.index_with { nil } ) diff --git a/lib/activeadmin_mcp/form_field_collector.rb b/lib/activeadmin_mcp/form_field_collector.rb index 8f7567f..7b90c2b 100644 --- a/lib/activeadmin_mcp/form_field_collector.rb +++ b/lib/activeadmin_mcp/form_field_collector.rb @@ -32,7 +32,15 @@ def input(name, *_args, **options, &_block) self end - def inputs(*_args, **_opts, &block) + # An `inputs for: :author` block declares the associated record's fields, + # not the record being described, so it is recorded as a group of its own + # exactly as `has_many` is. Without the `for:` option the block is simply a + # way of grouping the record's own fields under a heading, and is descended + # into. + def inputs(*_args, **options, &block) + association = association_name(options[:for]) + return nested(association, &block) if association + instance_exec(self, &block) if block self end @@ -41,11 +49,10 @@ def inputs(*_args, **_opts, &block) # can tell an association's fields from the record's own. Flattening them # would advertise `body` as an attribute of the parent record. def has_many(name, *_args, **_opts, &block) - return self unless name.respond_to?(:to_sym) - return self if declared?(name.to_sym) + association = association_name(name) + return self unless association - @inputs << { name: name.to_sym, nested: block ? self.class.new.collect(&block) : [] } - self + nested(association, &block) end def method_missing(_name, *_args, **_opts, &block) @@ -59,6 +66,20 @@ def respond_to_missing?(_name, _include_private = false) private + def nested(association, &block) + return self if declared?(association) + + @inputs << { name: association, nested: block ? self.class.new.collect(&block) : [] } + self + end + + # Formtastic accepts the association on its own, and also as an array + # pairing it with the object to build the fields from. + def association_name(declared) + name = declared.is_a?(Array) ? declared.first : declared + name.to_sym if name.respond_to?(:to_sym) + end + def declared?(name) @inputs.any? { |input| input[:name] == name } end diff --git a/lib/activeadmin_mcp/record_writer.rb b/lib/activeadmin_mcp/record_writer.rb index 7ff7ce1..2c299c6 100644 --- a/lib/activeadmin_mcp/record_writer.rb +++ b/lib/activeadmin_mcp/record_writer.rb @@ -133,9 +133,16 @@ def permit_refusal(permitted) # Asks the resource's own controller what it would permit, or nil when it # has nothing to say because permit_params was never declared. + # + # The controller comes from the dispatcher with the MCP user injected, + # because ActiveAdmin instance_execs a block-form permit_params on the + # controller: a block varying the writable set by user raises NameError + # on a bare instance, and the rescue below would report a resource that + # permits plenty as one that permits nothing. def resolve_permitted(attributes) param_key = @config.param_key.to_sym - controller = @config.controller.new + controller = ControllerDispatcher.new(config: @config, current_user: @current_user) + .controller_with_mcp_user controller.params = ActionController::Parameters.new(param_key => attributes) permitted = controller.send(:permitted_params) scoped = permitted && permitted[param_key] diff --git a/spec/activeadmin_mcp/form_description_spec.rb b/spec/activeadmin_mcp/form_description_spec.rb index fbdd800..86ba627 100644 --- a/spec/activeadmin_mcp/form_description_spec.rb +++ b/spec/activeadmin_mcp/form_description_spec.rb @@ -85,6 +85,41 @@ def attribute(result, name) end end + # ActiveAdmin instance_execs a block-form permit_params on the controller, + # so the permitted set can only be resolved in controller context. Resolved + # anywhere else the block raises, and a resource that permits plenty is + # reported as permitting nothing at all. + describe "a resource whose permit_params is declared as a block" do + it "describes the attributes the block permits for the MCP user" do + expect(describe_form("Roster")[:attributes].map { |attribute| attribute[:name] }).to eq( + %w[name notes] + ) + end + + it "says the description was derived from the resource's permitted params" do + expect(describe_form("Roster")[:source]).to eq("permit_params") + end + + it "does not mistake a block it could not evaluate for a resource that declared no permit_params" do + expect(describe_form("Roster")[:error]).to be_nil + end + end + + # A bare f.inputs is expanded by Formtastic at render time against the + # model, so a form block declaring nothing of its own describes nothing — + # but the resource's permitted params still do. + describe "a resource whose form block declares no inputs of its own" do + it "falls back to the resource's permitted params rather than reporting an empty form" do + expect(describe_form("Bulletin")[:source]).to eq("permit_params") + end + + it "describes the attributes those permitted params accept" do + expect(describe_form("Bulletin")[:attributes].map { |attribute| attribute[:name] }).to eq( + %w[headline body] + ) + end + end + describe "choosing which form to describe" do it "describes the new form by default" do expect(describe_form("Shift")[:action]).to eq("new") diff --git a/spec/activeadmin_mcp/form_field_collector_spec.rb b/spec/activeadmin_mcp/form_field_collector_spec.rb index d175e7a..bc177b9 100644 --- a/spec/activeadmin_mcp/form_field_collector_spec.rb +++ b/spec/activeadmin_mcp/form_field_collector_spec.rb @@ -73,6 +73,44 @@ def collect(&block) ) end + describe "an inputs block declared for an association" do + it "records the association as a nested group rather than flattening its fields into the parent" do + inputs = collect do |_f| + input :title + inputs for: :author do |a| + a.input :name + end + end + + expect(inputs).to eq( + [ + { name: :title }, + { name: :author, nested: [{ name: :name }] }, + ] + ) + end + + it "records the association named by Formtastic's array form, which passes the object alongside it" do + inputs = collect do |_f| + inputs "Author", for: [:author, Object.new] do |a| + a.input :name + end + end + + expect(inputs).to eq([{ name: :author, nested: [{ name: :name }] }]) + end + + it "still descends into an inputs block that names no association, which is how forms group their own fields" do + inputs = collect do |_f| + inputs "Details" do + input :title + end + end + + expect(inputs).to eq([{ name: :title }]) + end + end + describe "a collection: of allowed values" do it "records a literal array of values" do inputs = collect { |_f| input :status, as: :select, collection: %w[draft published] } diff --git a/spec/activeadmin_mcp/record_writer_spec.rb b/spec/activeadmin_mcp/record_writer_spec.rb index ecb76d9..06af45d 100644 --- a/spec/activeadmin_mcp/record_writer_spec.rb +++ b/spec/activeadmin_mcp/record_writer_spec.rb @@ -6,10 +6,12 @@ let(:volunteers) { ActiveadminMcp::ResourceRegistry.find("Volunteer") } let(:notes) { ActiveadminMcp::ResourceRegistry.find("Note") } let(:sightings) { ActiveadminMcp::ResourceRegistry.find("Sighting") } + let(:rosters) { ActiveadminMcp::ResourceRegistry.find("Roster") } after do Volunteer.delete_all Sighting.delete_all + Roster.delete_all AdminUser.delete_all end @@ -165,6 +167,28 @@ def writer(resource) end end + # ActiveAdmin instance_execs a block-form permit_params on the controller, + # so a block reading current_admin_user can only be resolved in controller + # context. Resolved anywhere else it raises, and a resource that permits + # plenty is refused as one that permits nothing. + describe "a resource whose permit_params is declared as a block" do + it "creates the record, writing the attributes the block permits for the MCP user" do + result = writer(rosters).create(attributes: { "name" => "Weekends", "notes" => "Two shifts" }) + + expect(result[:error]).to be_nil + expect(Roster.find(result[:id])).to have_attributes(name: "Weekends", notes: "Two shifts") + end + + it "updates the record rather than refusing it as declaring no permit_params" do + roster = Roster.create!(name: "Weekends") + + result = writer(rosters).update(id: roster.id, attributes: { "name" => "Weekdays" }) + + expect(result[:error]).to be_nil + expect(roster.reload.name).to eq("Weekdays") + end + end + # Neutralising the namespace's authentication_method must not neutralise # authorization: a user the adapter refuses must not be able to write. describe "when the authorization adapter denies everything" do diff --git a/spec/e2e/fixture_app/README.md b/spec/e2e/fixture_app/README.md index 8816f93..2df3b40 100644 --- a/spec/e2e/fixture_app/README.md +++ b/spec/e2e/fixture_app/README.md @@ -41,6 +41,23 @@ Each one exists to give a claim in the README something to bite on: tool. - `app/admin/authors.rb` registers `actions :index, :show`, so the suite can prove `update` refuses a resource the admin UI would not let you edit. +- `app/admin/newsletters.rb` declares its `permit_params` in the **block** + form, and the block reads `current_admin_user`, so the suite can prove the + permitted set is resolved in controller context. Asked of a bare controller + instance the block raises `NameError`, which reads as "this resource + declared no `permit_params`" and refuses `create`, `update` and + `describe_form` outright. Its `secret_note` is permitted to nobody, so + there is something the block withholds as well as something it grants. +- `app/admin/bulletins.rb` declares a form block containing a bare `f.inputs` + and nothing else — legal, because Formtastic expands it against the model + at render time — so the suite can prove `describe_form` falls back to + `permit_params` rather than reporting a form with no fields at all. +- `app/admin/dispatches.rb` declares a form block whose second `f.inputs` + names an association with `for:`, so the suite can prove the associated + record's fields are reported as a `nested` group rather than flattened in + with the dispatch's own — where a client would read them as attributes a + write against a dispatch could set, and `create` and `update` would drop + them. - `app/models/*.rb` allowlist `ransackable_attributes`, which Ransack 4 requires before it will filter on an attribute at all. - `db/migrate/*.rb` create the `authors` and `posts` tables, and add the diff --git a/spec/e2e/fixture_app/app/admin/bulletins.rb b/spec/e2e/fixture_app/app/admin/bulletins.rb new file mode 100644 index 0000000..068c79e --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/bulletins.rb @@ -0,0 +1,14 @@ +# Declares a form block that declares no inputs of its own: `f.inputs` with no +# block is legal, and Formtastic expands it against the model at render time. +# There is nothing in it to read, so the e2e suite can prove `describe_form` +# falls back to the resource's permitted params rather than reporting a form +# whose every field is missing — which a client reads as "nothing may be +# written here". +ActiveAdmin.register Bulletin do + permit_params :headline, :body + + form do |f| + f.inputs + f.actions + end +end diff --git a/spec/e2e/fixture_app/app/admin/dispatches.rb b/spec/e2e/fixture_app/app/admin/dispatches.rb new file mode 100644 index 0000000..636ebc2 --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/dispatches.rb @@ -0,0 +1,18 @@ +# Declares a form block whose second `f.inputs` names an association with +# `for:`, so the e2e suite can prove `describe_form` reports the associated +# record's fields as a nested group rather than flattening them in with the +# dispatch's own. `name` belongs to the author, and `create` and `update` +# would drop it from a write against a dispatch. +ActiveAdmin.register Dispatch do + permit_params :headline, :author_id + + form do |f| + f.inputs "Dispatch" do + f.input :headline + end + f.inputs "Author", for: :author do |a| + a.input :name + end + f.actions + end +end diff --git a/spec/e2e/fixture_app/app/admin/newsletters.rb b/spec/e2e/fixture_app/app/admin/newsletters.rb new file mode 100644 index 0000000..a69fa59 --- /dev/null +++ b/spec/e2e/fixture_app/app/admin/newsletters.rb @@ -0,0 +1,19 @@ +# Declares its `permit_params` in the BLOCK form, which ActiveAdmin +# instance_execs on the controller, and the block reads `current_admin_user` +# — the documented way an application varies the writable set by user. So the +# e2e suite can prove the permitted set is resolved in controller context: a +# caller asking a bare controller instance gets a NameError, reads that as +# "this resource declared no permit_params", and refuses `create`, `update` +# and `describe_form` outright. +# +# E2E_ADMIN_EMAIL is only set for the process that seeds the database, not for +# the running server, so the seeded admin's email is fixed here rather than +# read from the environment: it has to match AppBuilder::ADMIN_EMAIL. +# +# `secret_note` is permitted to nobody, so the suite has something the block +# withholds as well as something it grants. +ActiveAdmin.register Newsletter do + permit_params do + current_admin_user&.email == "admin@example.com" ? %i[title body] : %i[title] + end +end diff --git a/spec/e2e/fixture_app/app/models/bulletin.rb b/spec/e2e/fixture_app/app/models/bulletin.rb new file mode 100644 index 0000000..422e695 --- /dev/null +++ b/spec/e2e/fixture_app/app/models/bulletin.rb @@ -0,0 +1,6 @@ +class Bulletin < ApplicationRecord + # See the note in post.rb: Ransack 4 requires an explicit allowlist. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/app/models/dispatch.rb b/spec/e2e/fixture_app/app/models/dispatch.rb new file mode 100644 index 0000000..b27ec08 --- /dev/null +++ b/spec/e2e/fixture_app/app/models/dispatch.rb @@ -0,0 +1,8 @@ +class Dispatch < ApplicationRecord + belongs_to :author, optional: true + + # See the note in post.rb: Ransack 4 requires an explicit allowlist. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/app/models/newsletter.rb b/spec/e2e/fixture_app/app/models/newsletter.rb new file mode 100644 index 0000000..a582bf8 --- /dev/null +++ b/spec/e2e/fixture_app/app/models/newsletter.rb @@ -0,0 +1,6 @@ +class Newsletter < ApplicationRecord + # See the note in post.rb: Ransack 4 requires an explicit allowlist. + def self.ransackable_attributes(_auth_object = nil) + column_names + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000006_create_newsletters.rb b/spec/e2e/fixture_app/db/migrate/20260101000006_create_newsletters.rb new file mode 100644 index 0000000..3b9e1fc --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000006_create_newsletters.rb @@ -0,0 +1,11 @@ +class CreateNewsletters < ActiveRecord::Migration[7.2] + def change + create_table :newsletters do |t| + t.string :title + t.text :body + t.string :secret_note + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000007_create_bulletins.rb b/spec/e2e/fixture_app/db/migrate/20260101000007_create_bulletins.rb new file mode 100644 index 0000000..4ff16cd --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000007_create_bulletins.rb @@ -0,0 +1,10 @@ +class CreateBulletins < ActiveRecord::Migration[7.2] + def change + create_table :bulletins do |t| + t.string :headline + t.text :body + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/migrate/20260101000008_create_dispatches.rb b/spec/e2e/fixture_app/db/migrate/20260101000008_create_dispatches.rb new file mode 100644 index 0000000..5d12fdb --- /dev/null +++ b/spec/e2e/fixture_app/db/migrate/20260101000008_create_dispatches.rb @@ -0,0 +1,10 @@ +class CreateDispatches < ActiveRecord::Migration[7.2] + def change + create_table :dispatches do |t| + t.references :author + t.string :headline + + t.timestamps + end + end +end diff --git a/spec/e2e/fixture_app/db/seeds.rb b/spec/e2e/fixture_app/db/seeds.rb index d942e22..7398e2b 100644 --- a/spec/e2e/fixture_app/db/seeds.rb +++ b/spec/e2e/fixture_app/db/seeds.rb @@ -41,3 +41,27 @@ # something to refuse. Nothing mutates it: the examples that name it assert it # is unchanged. Tag.find_or_initialize_by(name: "fantasy").save! + +# Written through the MCP tools by the examples covering a block-form +# permit_params, so seeded restoratively on a title no example rewrites. Two +# rows, so an example rewriting one can assert the other was left alone. +{ + "The Weekly Dispatch" => "Everything that happened.", + "The Monthly Review" => "Everything that did not.", +}.each do |title, body| + Newsletter.find_or_initialize_by(title: title).tap do |newsletter| + newsletter.body = body + newsletter.secret_note = "Not for the newsletter" + newsletter.save! + end +end + +# Described but never written by the examples that name them: one carries a +# form block declaring no inputs, the other a form block naming an +# association with `for:`. +Bulletin.find_or_initialize_by(headline: "Library closed on Monday").save! + +Dispatch.find_or_initialize_by(headline: "From the archives").tap do |dispatch| + dispatch.author = Author.find_by(email: "ursula@example.com") + dispatch.save! +end diff --git a/spec/e2e/mcp_tools_spec.rb b/spec/e2e/mcp_tools_spec.rb index 23735a0..7244d2e 100644 --- a/spec/e2e/mcp_tools_spec.rb +++ b/spec/e2e/mcp_tools_spec.rb @@ -104,6 +104,45 @@ def attribute(result, name) end end + context "for a resource whose form block declares no inputs of its own" do + let(:result) { client.call_tool("describe_form", resource: "Bulletin") } + + it "falls back to the resource's permitted params rather than describing a form with no fields" do + expect(result["source"]).to eq("permit_params") + end + + it "describes the attributes those permitted params accept" do + expect(result["attributes"].map { |attribute| attribute["name"] }).to eq(%w[headline body]) + end + end + + context "for a resource whose form block declares an inputs block for an association" do + let(:result) { client.call_tool("describe_form", resource: "Dispatch") } + + it "reports the association's fields as a nested group, since they belong to the associated record" do + expect(result["nested"]).to eq( + [{ "name" => "author", "attributes" => [{ "name" => "name" }] }] + ) + end + + it "keeps the association's fields out of the attributes a write against this resource may set" do + expect(result["attributes"].map { |attribute| attribute["name"] }).to eq(%w[headline]) + end + end + + context "for a resource whose permitted params are declared as a block" do + let(:result) { client.call_tool("describe_form", resource: "Newsletter") } + + it "describes the attributes the block permits the MCP user, rather than refusing the resource" do + expect(result["error"]).to be_nil + expect(result["attributes"].map { |attribute| attribute["name"] }).to eq(%w[title body]) + end + + it "omits an attribute the block permits nobody" do + expect(result["attributes"].map { |attribute| attribute["name"] }).not_to include("secret_note") + end + end + it "describes the edit form when asked for it" do result = client.call_tool("describe_form", resource: "Post", action: "edit") @@ -174,6 +213,21 @@ def posts expect(client.call_tool("query", resource: "Author")["count"]).to eq(2) end + it "creates a record for a resource whose permitted params are declared as a block reading the current user" do + result = client.call_tool( + "create", + resource: "Newsletter", + attributes: { title: "The Quarterly", body: "Occasionally." } + ) + + expect(result["error"]).to be_nil + expect(result["created"]).to contain_exactly("title", "body") + + created = client.call_tool("query", resource: "Newsletter", q: { id_eq: result["id"] })["records"].first + expect(created["title"]).to eq("The Quarterly") + expect(created["body"]).to eq("Occasionally.") + end + it "refuses to create a record for a resource that declares no permitted params" do result = client.call_tool("create", resource: "Tag", attributes: { name: "science-fiction" }) @@ -231,6 +285,38 @@ def posts expect(reread["title"]).to eq("Small Gods") end + context "for a resource whose permitted params are declared as a block reading the current user" do + def newsletter(title) + client.call_tool("query", resource: "Newsletter", q: { title_eq: title })["records"].first + end + + it "updates the named record, rather than refusing it as a resource that declared no permitted params" do + result = client.call_tool( + "update", + resource: "Newsletter", + id: newsletter("The Weekly Dispatch")["id"], + attributes: { body: "Rather less, this week." } + ) + + expect(result["error"]).to be_nil + expect(result["updated"]).to eq(["body"]) + expect(newsletter("The Weekly Dispatch")["body"]).to eq("Rather less, this week.") + expect(newsletter("The Monthly Review")["body"]).to eq("Everything that did not.") + end + + it "drops an attribute the block permits nobody" do + result = client.call_tool( + "update", + resource: "Newsletter", + id: newsletter("The Weekly Dispatch")["id"], + attributes: { body: "Revised.", secret_note: "tampered" } + ) + + expect(result["updated"]).to eq(["body"]) + expect(newsletter("The Weekly Dispatch")["secret_note"]).to eq("Not for the newsletter") + end + end + it "refuses to update a resource that declares no permitted params" do tag_id = client.call_tool("query", resource: "Tag")["records"].first["id"] diff --git a/spec/support/active_admin.rb b/spec/support/active_admin.rb index fda158c..c90d421 100644 --- a/spec/support/active_admin.rb +++ b/spec/support/active_admin.rb @@ -47,6 +47,26 @@ t.timestamps end + # Registered with a BLOCK-form permit_params whose block reads + # current_admin_user, so a spec can prove the permitted set is resolved in + # controller context. ActiveAdmin instance_execs the block on the + # controller, so a bare instance raises NameError on it. + create_table :rosters, force: true do |t| + t.string :name + t.string :notes + t.timestamps + end + + # Registered with a form block that declares no inputs of its own, which is + # legal — Formtastic expands a bare f.inputs at render time — so a spec can + # prove the description falls back to permitted params rather than + # reporting nothing may be written. + create_table :bulletins, force: true do |t| + t.string :headline + t.string :body + t.timestamps + end + create_table :admin_users, force: true do |t| t.string :email t.timestamps @@ -63,6 +83,8 @@ class Shift < ActiveRecord::Base validates :name, presence: true end class Sighting < ActiveRecord::Base; end +class Roster < ActiveRecord::Base; end +class Bulletin < ActiveRecord::Base; end class AdminUser < ActiveRecord::Base; end require "active_admin" @@ -211,6 +233,28 @@ def self.included(dsl) ActiveAdmin.register Sighting do end +# Block-form permit_params is how an application varies the writable set by +# user, and the block reads a method only the controller has, so any caller +# resolving the permitted set outside controller context raises NameError and +# sees a resource that permits nothing. +ActiveAdmin.register Roster do + permit_params do + current_admin_user.email == "admin@example.com" ? %i[name notes] : %i[name] + end +end + +# A form block declaring no inputs of its own. Formtastic expands the bare +# f.inputs at render time against the model, so there is nothing here to read +# and the permitted params are the better description. +ActiveAdmin.register Bulletin do + permit_params :headline, :body + + form do |f| + f.inputs + f.actions + end +end + # An authorization adapter that denies everything, used to prove that # neutralising the namespace's authentication_method (see # ControllerDispatcher#controller_with_mcp_user) does not also neutralise