Module: Clickwrap::TestHelpers

Defined in:
lib/clickwrap/test_helpers.rb

Overview

Test helpers for Minitest. Include in your test helper to get evidence-related test utilities.

=========================================================================== EVERY HELPER HERE GOES THROUGH THE REAL PATH.

submit_clickwrap presents the policy through the actual Presenter, takes the signed token it produced, builds an actual Clickwrap::Submission from it, and calls the actual Clickwrap.capture!. It does not insert rows.

That is not fastidiousness. Hand-built evidence rows are the single most effective way to make a suite green while the product is broken: they skip the manifest, the digests, the answer validation, the idempotency key, the projection, and the very checks the gem exists to perform, and then every assertion downstream is checking that a fixture matches itself. Evidence created by these helpers is internally consistent because it was created the same way real evidence is.

The deliberately-broken cases — an expired presentation, a stale revision, another actor's token — are built by taking a REAL presentation and changing exactly one thing about it, so what fails is the server's own check rather than a mock standing in for it.

Examples:

Include in test_helper.rb

require "clickwrap/test_helpers"

class ActiveSupport::TestCase
  include Clickwrap::TestHelpers

  setup    { Clickwrap::Testing.reset! }
  teardown { Clickwrap::Testing.reset! }
end

Using in tests

test "signup records the terms agreement" do
  receipt = submit_clickwrap(:signup, actor: @user,
                              answers: { terms: true, privacy_notice: true })

  assert_clickwrap_current :signup, actor: @user
  assert_clickwrap_agreed_to :terms, actor: @user
  assert_clickwrap_acknowledged :privacy_notice, actor: @user
  assert_clickwrap_receipt_verifies receipt
end

Proving the atomicity promise

test "a failed evidence write rolls the account back" do
  Clickwrap::Testing.fail_next_event_write do
    assert_raises(Clickwrap::EventWriteFailed) {  }
  end

  assert_not User.exists?(email: "person@example.com")
  assert_no_clickwrap_event :signup
end

Defined Under Namespace

Modules: ClassMethods

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.clickwrap_granting_choice(statement) ⇒ Object

The choice a policy declared as meaning "grant"; otherwise the first one offered, so a statement with domain-specific choice names still works.



611
612
613
# File 'lib/clickwrap/test_helpers.rb', line 611

def clickwrap_granting_choice(statement)
  statement.choices.find { |_, meaning| meaning == "grant" }&.first || statement.choices.keys.first
end

.clickwrap_params_from(path, answers: {}, form_css_selector: nil) ⇒ Object

The one-line form of the pattern below, for integration tests: GET the page that renders a clickwrap presentation, read the signed token and controls back out of it, return the params for the POST.

post user_registration_path, params: {
user: { ... },
**clickwrap_params_from(new_user_registration_path)
}


140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/clickwrap/test_helpers.rb', line 140

def clickwrap_params_from(path, answers: {}, form_css_selector: nil)
  unless respond_to?(:get)
    raise ArgumentError,
          "clickwrap_params_from drives a real GET, so it needs an integration test " \
          "(ActionDispatch::IntegrationTest). In other tests, build the submission with " \
          "present_clickwrap + submission_for instead."
  end

  get path
  clickwrap_submission_params_from(
    response,
    answers: answers,
    form_css_selector: form_css_selector
  )
end

.clickwrap_submission_params_from(rendered, answers: {}, form_css_selector: nil) ⇒ Hash

For integration tests that POST a form the application really rendered — the signed presentation token is minted per render and bound to the session, so a test cannot fabricate it; it has to read it back out of the page, exactly like a browser:

get new_user_registration_path
post user_registration_path, params: {
user: { email: "person@example.com", password: "..." },
**clickwrap_submission_params_from(response)
}

Every control the page rendered is answered affirmatively by default — what a person completing the form normally does. Decline or skip one explicitly with answers::

clickwrap_submission_params_from(response, answers: { product_updates: false })

A page with several independent clickwrap forms must name the exact form. The helper refuses ambiguity rather than mixing one form's token with another form's answers:

clickwrap_submission_params_from(
response,
form_css_selector: "form[action='/withdrawals/confirm']"
)

Parameters:

  • rendered (String, #body)

    the HTML, or the integration response

  • answers (Hash) (defaults to: {})

    statement key => true/false/String override

  • form_css_selector (String, nil) (defaults to: nil)

    exact CSS selector for one form

Returns:

  • (Hash)

    params ready to merge into the POST



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/clickwrap/test_helpers.rb', line 188

def clickwrap_submission_params_from(rendered, answers: {}, form_css_selector: nil)
  require "nokogiri"

  html = rendered.respond_to?(:body) ? rendered.body : rendered.to_s
  page = Nokogiri::HTML(html)
  scope = clickwrap_test_form_scope(page, form_css_selector)

  token_fields = scope.css('input[name="clickwrap_submission[presentation_token]"]')
  if token_fields.many?
    raise ArgumentError,
          "The rendered page carries #{token_fields.size} clickwrap presentation tokens. " \
          "Pass form_css_selector: with a CSS selector that matches exactly one form."
  end

  token = token_fields.first&.[]("value")
  if token.to_s.empty?
    raise ArgumentError,
          "The rendered page carries no clickwrap presentation token. GET the page that " \
          "renders `form.clickwrap` (or `form.clickwrap_fields`) first, then pass that " \
          "response to clickwrap_submission_params_from."
  end

  overrides = answers.transform_keys(&:to_s)
  controls_by_statement = scope.css(%([name^="clickwrap_submission[answers]["]))
                               .group_by { |control| control["name"][/\[answers\]\[([^\]]+)\]/, 1] }

  answered = controls_by_statement.each_with_object({}) do |(key, controls), result|
    next if key.nil?

    answer = if overrides.key?(key)
               normalize_rendered_control_answer(controls, overrides.fetch(key))
             else
               default_rendered_control_answer(controls)
             end
    result[key] = answer unless answer.nil?
  end

  { "clickwrap_submission" => { "presentation_token" => token, "answers" => answered.compact } }
end

.clickwrap_test_form_scope(page, form_css_selector) ⇒ Object



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/clickwrap/test_helpers.rb', line 230

def clickwrap_test_form_scope(page, form_css_selector)
  return page if form_css_selector.nil?

  forms = page.css(form_css_selector.to_s)
  unless forms.one? && forms.first.name == "form"
    raise ArgumentError,
          "form_css_selector must match exactly one <form>; it matched #{forms.size}. " \
          "Received #{form_css_selector.inspect}."
  end

  forms.first
rescue Nokogiri::CSS::SyntaxError => error
  raise ArgumentError,
        "form_css_selector must be valid CSS. #{error.message}"
end

.clickwrap_token_without_message_expiry(manifest) ⇒ Object



616
617
618
619
620
# File 'lib/clickwrap/test_helpers.rb', line 616

def clickwrap_token_without_message_expiry(manifest)
  PresentationManifest.verifier.generate(
    manifest.to_h, purpose: PresentationManifest::SIGNING_PURPOSE
  )
end

.committed_test_receipt(result) ⇒ Object

Transactional test wrappers intentionally never commit. The production API therefore returns PendingReceipt inside them, correctly, while tests still need to inspect the rows they just created. This test-only adapter exposes a Receipt projection without changing the pending object's committed? answer or weakening the production finality contract.



365
366
367
368
369
# File 'lib/clickwrap/test_helpers.rb', line 365

def committed_test_receipt(result)
  return result unless result.is_a?(PendingReceipt)

  Receipt.new(result.event)
end

.default_clickwrap_answers(policy_key, answers) ⇒ Object

Required statements get an affirmative answer; optional ones are left untouched. Anything the caller passed wins over both. Explicit beats implicit, and the rule is deliberately all-or-nothing.

answers: {} affirms every required statement, which is what a person completing the form normally does and what most tests want. But the moment a test names ANY answer, it gets exactly what it named and nothing else — because the tests that matter most here are the ones that deliberately leave a required statement unanswered, and a helper that quietly filled it in would turn "the server refuses an incomplete submission" into a test that passes for the wrong reason.



600
601
602
603
604
605
606
# File 'lib/clickwrap/test_helpers.rb', line 600

def default_clickwrap_answers(policy_key, answers)
  return answers.transform_keys(&:to_s) if answers.present?

  Clickwrap.policy!(policy_key).required_statements.to_h do |statement|
    [statement.key, statement.choices ? clickwrap_granting_choice(statement) : "1"]
  end
end

.default_rendered_control_answer(controls) ⇒ Object

A checkbox's affirmative value is conventionally "1". A radio group's value is the exact choice key the server offered — sometimes "yes", but just as legitimately "employee", "contractor", or another domain word. Reading the rendered value keeps this integration helper faithful to the real form instead of fabricating a checkbox answer for every control.



253
254
255
256
# File 'lib/clickwrap/test_helpers.rb', line 253

def default_rendered_control_answer(controls)
  first = controls.first
  first["type"] == "radio" ? first["value"].to_s : "1"
end

.expired_clickwrap_presentation_for(policy_key) ⇒ Clickwrap::Presenter::Result

A presentation whose manifest has ALREADY EXPIRED.

Built by taking a real presentation and rewriting its issue and expiry times into the past, then signing it with the manifest verifier directly rather than through to_token.

That last part is the subtle bit and it is deliberate. to_token passes the manifest's expires_at to the message verifier, so an already-expired manifest produces a token the signature layer refuses before Clickwrap's own expiry check ever runs — and the test would then be asserting that ActiveSupport::MessageVerifier works. Signing without the message-level TTL leaves a perfectly valid signature over a manifest that is plainly out of date, so what rejects it is the server's own PresentationExpired path. Real check, not a mocked one.

Returns:



402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/clickwrap/test_helpers.rb', line 402

def expired_clickwrap_presentation_for(policy_key, **)
  presentation = present_clickwrap(policy_key, **)
  window = Clickwrap.config.presentation_valid_for
  now = Clickwrap.now

  expired = PresentationManifest.new(
    presentation.manifest.to_h.merge(
      "issued_at" => Receipt.format_time(now - (window * 2)),
      "expires_at" => Receipt.format_time(now - window)
    )
  )

  presentation.with(manifest: expired, token: clickwrap_token_without_message_expiry(expired))
end

.included(base) ⇒ Object



81
82
83
# File 'lib/clickwrap/test_helpers.rb', line 81

def self.included(base)
  base.extend(ClassMethods)
end

.normalize_rendered_control_answer(controls, value) ⇒ Object



259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/clickwrap/test_helpers.rb', line 259

def normalize_rendered_control_answer(controls, value)
  return normalize_test_answer(value) unless controls.first["type"] == "radio"
  return nil if value.nil?
  return controls.first["value"].to_s if value == true

  if value == false
    negative = controls.find do |control|
      control["value"].to_s.in?(%w[no decline false 0])
    end
    return (negative || controls.last)["value"].to_s
  end

  value.to_s
end

.normalize_test_answer(value) ⇒ Object



276
277
278
279
280
281
282
283
# File 'lib/clickwrap/test_helpers.rb', line 276

def normalize_test_answer(value)
  case value
  when true then "1"
  when false then "0"
  when nil then nil
  else value.to_s
  end
end

.other_actors_clickwrap_token_for(policy_key, actor:, other_actor:) ⇒ String

A token legitimately issued to somebody else, for the swapped-token case. Submit it while capturing as actor and Capture rejects it with :presentation_actor_mismatch.

Returns:

  • (String)

    a signed presentation token bound to other_actor



445
446
447
448
449
450
451
452
453
454
# File 'lib/clickwrap/test_helpers.rb', line 445

def other_actors_clickwrap_token_for(policy_key, actor:, other_actor:)
  if Reference.actor(actor) == Reference.actor(other_actor)
    raise ArgumentError,
          "other_actors_clickwrap_token_for was given the same actor twice, so the token it " \
          "returned would be the actor's own and the mismatch it exists to trigger could " \
          "never happen. Pass two different records."
  end

  present_clickwrap(policy_key, actor: other_actor).token
end

.present_clickwrap(policy_key, actor: nil, subject: nil, tenant: nil, locale: nil, submit_button_text: "Continue", capture_channel: :web_browser, prospective_actor: nil, registration_flow_id: nil, acting_for: nil) ⇒ Clickwrap::Presenter::Result

Presents a policy server-side and returns the Presenter::Result, whose token is a real signed presentation token.

Parameters:

  • policy_key (Symbol, String)

    the policy to present

  • actor (Object, nil) (defaults to: nil)

    the record acting, or nil for a registration flow

  • submit_button_text (String) (defaults to: "Continue")

    the exact call to action, recorded in the manifest

Returns:



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/clickwrap/test_helpers.rb', line 94

def present_clickwrap(policy_key, actor: nil, subject: nil, tenant: nil, locale: nil,
                      submit_button_text: "Continue", capture_channel: :web_browser,
                      prospective_actor: nil, registration_flow_id: nil, acting_for: nil)
  registration_flow_id ||= SecureRandom.uuid if prospective_actor

  Presenter.new(
    policy: Clickwrap.policy!(policy_key),
    actor: actor,
    prospective_actor: prospective_actor,
    registration_flow_id: registration_flow_id,
    subject: subject,
    tenant: tenant,
    acting_for: acting_for,
    locale: locale,
    submit_button_text: submit_button_text,
    capture_channel: capture_channel
  ).present
end

.stale_clickwrap_token_for(policy_key) ⇒ String

A token bound to a policy revision that is no longer on file — the token a browser holds when the policy was edited and redeployed between the GET and the POST.

The revision digest is replaced with a derived one rather than by deleting the real PolicyRevision row, so the fixture other tests rely on survives. Capture rejects it with :stale_policy_revision.

Returns:

  • (String)

    a signed presentation token



428
429
430
431
432
433
434
435
436
# File 'lib/clickwrap/test_helpers.rb', line 428

def stale_clickwrap_token_for(policy_key, **)
  presentation = present_clickwrap(policy_key, **)
  attributes = presentation.manifest.to_h
  superseded = Digest.digest("superseded:#{attributes.dig("policy", "revision")}")

  PresentationManifest
    .new(attributes.merge("policy" => attributes["policy"].merge("revision" => superseded)))
    .to_token
end

.submission_for(presentation_result, answers = {}) ⇒ Clickwrap::Submission

Builds a real Submission from a presentation result.

Parameters:

Returns:



123
124
125
126
127
128
# File 'lib/clickwrap/test_helpers.rb', line 123

def submission_for(presentation_result, answers = {})
  Submission.new(
    presentation_token: presentation_result.token,
    answers: answers.transform_keys(&:to_s)
  )
end

.submit_clickwrap(policy_key, actor:, answers: {}, subject: nil, tenant: nil, locale: nil, capture_channel: :web_browser, http_request: nil, acting_for: nil) ⇒ Clickwrap::Receipt

Presents, answers, and captures — the everyday way to get real evidence into a test database.

An empty answers: affirms every required statement and leaves every optional one alone, which is what a person completing the form normally does. Optional controls are never auto-answered: leaving one unselected creates no grant, and a helper that quietly granted an optional consent would hide exactly the bug that distinction exists to catch.

A different verb from the CONTROLLER helper because it is a different act. This one SUBMITS a presentation it just built — it is a test factory verb, like create_user, and a factory that cannot deliver raises, since in a test a failed capture is a failed test. Clickwrap::ControllerHelpers#capture_clickwrap CAPTURES a submission a person actually sent, follows save/save!, and absorbs refusals, because in a controller a refused submission is a person to answer rather than a bug.

They used to share the name capture_clickwrap and contradict each other about what "no" means. Both modules can end up on one object, and a helper whose failure mode depends on which module won is not a helper.

Returns:



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/clickwrap/test_helpers.rb', line 310

def submit_clickwrap(policy_key, actor:, answers: {}, subject: nil, tenant: nil, locale: nil,
                     capture_channel: :web_browser, http_request: nil, acting_for: nil)
  presentation = present_clickwrap(policy_key, actor: actor, subject: subject,
                                               tenant: tenant, locale: locale,
                                               acting_for: acting_for,
                                               capture_channel: capture_channel)

  result = Clickwrap.capture!(
    policy_key,
    actor: actor,
    subject: subject,
    tenant: tenant,
    acting_for: acting_for,
    http_request: http_request,
    capture_channel: capture_channel,
    submission: submission_for(presentation, default_clickwrap_answers(policy_key, answers))
  )

  committed_test_receipt(result)
end

.submit_clickwrap_and(policy_key, actor:, answers: {}, subject: nil, tenant: nil, capture_channel: :web_browser, http_request: nil, acting_for: nil) ⇒ Clickwrap::Receipt

The same, with a protected action in the same transaction. Use it to prove that your domain write and its evidence commit together.

Returns:



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/clickwrap/test_helpers.rb', line 337

def submit_clickwrap_and(policy_key, actor:, answers: {}, subject: nil, tenant: nil,
                         capture_channel: :web_browser, http_request: nil, acting_for: nil, &)
  presentation = present_clickwrap(policy_key, actor: actor, subject: subject, tenant: tenant,
                                               acting_for: acting_for,
                                               capture_channel: capture_channel)

  result = Clickwrap.capture_and!(
    policy_key,
    actor: actor,
    subject: subject,
    tenant: tenant,
    acting_for: acting_for,
    http_request: http_request,
    capture_channel: capture_channel,
    submission: submission_for(presentation, default_clickwrap_answers(policy_key, answers)),
    &
  )

  committed_test_receipt(result)
end

.submit_clickwrap_twice(policy_key, actor:, answers: {}, subject: nil, tenant: nil) ⇒ Array<Clickwrap::Receipt>

Submits the SAME presentation twice, which is what a double-click, a retried request, and a replayed token all look like to the server.

Returns both results. An identical repeat returns the original receipt without running anything twice, so the two share an event_id; a repeat with different answers raises Clickwrap::ReplayRejected.

Returns:



466
467
468
469
470
471
472
473
474
# File 'lib/clickwrap/test_helpers.rb', line 466

def submit_clickwrap_twice(policy_key, actor:, answers: {}, subject: nil, tenant: nil)
  presentation = present_clickwrap(policy_key, actor: actor, subject: subject, tenant: tenant)
  submission = submission_for(presentation, default_clickwrap_answers(policy_key, answers))

  options = { actor: actor, subject: subject, tenant: tenant,
              capture_channel: "web_browser", submission: submission }

  [Clickwrap.capture!(policy_key, **options), Clickwrap.capture!(policy_key, **options)]
end

Instance Method Details

#assert_clickwrap_acknowledged(statement_key, actor:, subject: nil, tenant: nil) ⇒ Object



528
529
530
# File 'lib/clickwrap/test_helpers.rb', line 528

def assert_clickwrap_acknowledged(statement_key, actor:, subject: nil, tenant: nil)
  assert_clickwrap_kind("acknowledgment", :acknowledged?, statement_key, actor, subject, tenant)
end

#assert_clickwrap_agreed_to(statement_key, actor:, subject: nil, tenant: nil) ⇒ Object



524
525
526
# File 'lib/clickwrap/test_helpers.rb', line 524

def assert_clickwrap_agreed_to(statement_key, actor:, subject: nil, tenant: nil)
  assert_clickwrap_kind("agreement", :agreed_to?, statement_key, actor, subject, tenant)
end

#assert_clickwrap_authorized(statement_key, actor:, subject: nil, tenant: nil) ⇒ Object



540
541
542
# File 'lib/clickwrap/test_helpers.rb', line 540

def assert_clickwrap_authorized(statement_key, actor:, subject: nil, tenant: nil)
  assert_clickwrap_kind("authorization", :authorized?, statement_key, actor, subject, tenant)
end

#assert_clickwrap_consented_to(purpose_key, actor:, subject: nil, tenant: nil) ⇒ Object



532
533
534
# File 'lib/clickwrap/test_helpers.rb', line 532

def assert_clickwrap_consented_to(purpose_key, actor:, subject: nil, tenant: nil)
  assert_clickwrap_kind("consent", :consented_to?, purpose_key, actor, subject, tenant)
end

#assert_clickwrap_current(policy_key, actor:, subject: nil, tenant: nil) ⇒ Object

Assert that an actor currently satisfies a whole policy.



505
506
507
508
509
510
511
512
# File 'lib/clickwrap/test_helpers.rb', line 505

def assert_clickwrap_current(policy_key, actor:, subject: nil, tenant: nil)
  result = Clickwrap.verify(policy_key, actor: actor, subject: subject, tenant: tenant)

  assert result.success?,
         "Expected #{clickwrap_actor_label(actor)} to currently satisfy the #{policy_key} " \
         "policy, but verification failed with #{result.error.inspect}: #{result.message}. " \
         "#{clickwrap_state_summary(actor, policy_key)}"
end

#assert_clickwrap_declared(statement_key, actor:, subject: nil, tenant: nil) ⇒ Object



536
537
538
# File 'lib/clickwrap/test_helpers.rb', line 536

def assert_clickwrap_declared(statement_key, actor:, subject: nil, tenant: nil)
  assert_clickwrap_kind("declaration", :declared?, statement_key, actor, subject, tenant)
end

#assert_clickwrap_exempted_from(policy_key, actor:, subject: nil, tenant: nil) ⇒ Object

Assert that an actor is exempted from a policy — a deliberately separate question from having agreed to it. An exemption records that no human action occurred, so it can never satisfy assert_clickwrap_agreed_to, and a test that expects it to has found a real bug rather than a helper limitation.



549
550
551
552
553
554
555
556
557
# File 'lib/clickwrap/test_helpers.rb', line 549

def assert_clickwrap_exempted_from(policy_key, actor:, subject: nil, tenant: nil)
  proxy = ActorProxy.new(actor)

  assert proxy.exempted_from?(policy_key, subject: subject, tenant: tenant),
         "Expected #{clickwrap_actor_label(actor)} to be exempted from #{policy_key}, but no " \
         "exemption event was recorded for them. An exemption is created explicitly with " \
         "Clickwrap.exempt!(#{policy_key.to_sym.inspect}, actor:, because:), and the policy " \
         "must permit one. #{clickwrap_state_summary(actor, policy_key)}"
end

#assert_clickwrap_receipt_verifies(receipt) ⇒ Object

Assert that a receipt still verifies: its digest covers its bytes, its document versions still match what was published, and the event has not been disposed of.



562
563
564
565
566
567
568
569
570
# File 'lib/clickwrap/test_helpers.rb', line 562

def assert_clickwrap_receipt_verifies(receipt)
  result = receipt.verify

  assert result.success?,
         "Expected receipt #{receipt.event_id} to verify, but it failed with " \
         "#{result.error.inspect}: #{result.message}. A digest failure means the event row's " \
         "meaningful bytes changed after it was written; a document failure means a " \
         "published version was edited in place instead of republished under a new label."
end

#assert_no_clickwrap_event(policy_key, actor: nil) ⇒ Object

Assert that no event exists for a policy — the other half of a fault injection test, where the point is that nothing was recorded.



574
575
576
577
578
579
580
581
582
583
584
585
# File 'lib/clickwrap/test_helpers.rb', line 574

def assert_no_clickwrap_event(policy_key, actor: nil)
  scope = Event.for_policy(policy_key)
  scope = scope.for_actor(Reference.actor(actor)) if actor
  found = scope.chronological.to_a
  listed = found.join("; ")
  whose = actor ? " and #{clickwrap_actor_label(actor)}" : ""

  assert found.empty?,
         "Expected no Clickwrap event for #{policy_key}#{whose}, but found " \
         "#{found.length}: #{listed}. If this followed a fault injection block, the evidence " \
         "write was not rolled back with the action it was supposed to commit alongside."
end

#clickwrap_params_from(path, answers: {}, form_css_selector: nil) ⇒ Object

The one-line form of the pattern below, for integration tests: GET the page that renders a clickwrap presentation, read the signed token and controls back out of it, return the params for the POST.

post user_registration_path, params: {
user: { ... },
**clickwrap_params_from(new_user_registration_path)
}


140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/clickwrap/test_helpers.rb', line 140

def clickwrap_params_from(path, answers: {}, form_css_selector: nil)
  unless respond_to?(:get)
    raise ArgumentError,
          "clickwrap_params_from drives a real GET, so it needs an integration test " \
          "(ActionDispatch::IntegrationTest). In other tests, build the submission with " \
          "present_clickwrap + submission_for instead."
  end

  get path
  clickwrap_submission_params_from(
    response,
    answers: answers,
    form_css_selector: form_css_selector
  )
end

#clickwrap_submission_params_from(rendered, answers: {}, form_css_selector: nil) ⇒ Hash

For integration tests that POST a form the application really rendered — the signed presentation token is minted per render and bound to the session, so a test cannot fabricate it; it has to read it back out of the page, exactly like a browser:

get new_user_registration_path
post user_registration_path, params: {
user: { email: "person@example.com", password: "..." },
**clickwrap_submission_params_from(response)
}

Every control the page rendered is answered affirmatively by default — what a person completing the form normally does. Decline or skip one explicitly with answers::

clickwrap_submission_params_from(response, answers: { product_updates: false })

A page with several independent clickwrap forms must name the exact form. The helper refuses ambiguity rather than mixing one form's token with another form's answers:

clickwrap_submission_params_from(
response,
form_css_selector: "form[action='/withdrawals/confirm']"
)

Parameters:

  • rendered (String, #body)

    the HTML, or the integration response

  • answers (Hash) (defaults to: {})

    statement key => true/false/String override

  • form_css_selector (String, nil) (defaults to: nil)

    exact CSS selector for one form

Returns:

  • (Hash)

    params ready to merge into the POST



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/clickwrap/test_helpers.rb', line 188

def clickwrap_submission_params_from(rendered, answers: {}, form_css_selector: nil)
  require "nokogiri"

  html = rendered.respond_to?(:body) ? rendered.body : rendered.to_s
  page = Nokogiri::HTML(html)
  scope = clickwrap_test_form_scope(page, form_css_selector)

  token_fields = scope.css('input[name="clickwrap_submission[presentation_token]"]')
  if token_fields.many?
    raise ArgumentError,
          "The rendered page carries #{token_fields.size} clickwrap presentation tokens. " \
          "Pass form_css_selector: with a CSS selector that matches exactly one form."
  end

  token = token_fields.first&.[]("value")
  if token.to_s.empty?
    raise ArgumentError,
          "The rendered page carries no clickwrap presentation token. GET the page that " \
          "renders `form.clickwrap` (or `form.clickwrap_fields`) first, then pass that " \
          "response to clickwrap_submission_params_from."
  end

  overrides = answers.transform_keys(&:to_s)
  controls_by_statement = scope.css(%([name^="clickwrap_submission[answers]["]))
                               .group_by { |control| control["name"][/\[answers\]\[([^\]]+)\]/, 1] }

  answered = controls_by_statement.each_with_object({}) do |(key, controls), result|
    next if key.nil?

    answer = if overrides.key?(key)
               normalize_rendered_control_answer(controls, overrides.fetch(key))
             else
               default_rendered_control_answer(controls)
             end
    result[key] = answer unless answer.nil?
  end

  { "clickwrap_submission" => { "presentation_token" => token, "answers" => answered.compact } }
end

#complete_clickwrap(policy_key) ⇒ Object

Checks every required control the policy declares, on the page currently rendered, and leaves optional ones alone.

complete_clickwrap :signup
click_button "Create account"

Controls are found by their form-field name rather than by an id, so this keeps working after you eject the views and restyle everything: the name is part of the submission contract, the markup around it is yours.



489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/clickwrap/test_helpers.rb', line 489

def complete_clickwrap(policy_key)
  unless respond_to?(:page)
    raise NoMethodError,
          "complete_clickwrap drives a rendered page and needs Capybara, so it works in a " \
          "system test. In a model or integration test use submit_clickwrap, which goes " \
          "through the same presenter and capture path without a browser."
  end

  Clickwrap.policy!(policy_key).statements.reject(&:optional?).each do |statement|
    complete_clickwrap_statement(statement)
  end
end

#expired_clickwrap_presentation_for(policy_key) ⇒ Clickwrap::Presenter::Result

A presentation whose manifest has ALREADY EXPIRED.

Built by taking a real presentation and rewriting its issue and expiry times into the past, then signing it with the manifest verifier directly rather than through to_token.

That last part is the subtle bit and it is deliberate. to_token passes the manifest's expires_at to the message verifier, so an already-expired manifest produces a token the signature layer refuses before Clickwrap's own expiry check ever runs — and the test would then be asserting that ActiveSupport::MessageVerifier works. Signing without the message-level TTL leaves a perfectly valid signature over a manifest that is plainly out of date, so what rejects it is the server's own PresentationExpired path. Real check, not a mocked one.

Returns:



402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/clickwrap/test_helpers.rb', line 402

def expired_clickwrap_presentation_for(policy_key, **)
  presentation = present_clickwrap(policy_key, **)
  window = Clickwrap.config.presentation_valid_for
  now = Clickwrap.now

  expired = PresentationManifest.new(
    presentation.manifest.to_h.merge(
      "issued_at" => Receipt.format_time(now - (window * 2)),
      "expires_at" => Receipt.format_time(now - window)
    )
  )

  presentation.with(manifest: expired, token: clickwrap_token_without_message_expiry(expired))
end

#other_actors_clickwrap_token_for(policy_key, actor:, other_actor:) ⇒ String

A token legitimately issued to somebody else, for the swapped-token case. Submit it while capturing as actor and Capture rejects it with :presentation_actor_mismatch.

Returns:

  • (String)

    a signed presentation token bound to other_actor



445
446
447
448
449
450
451
452
453
454
# File 'lib/clickwrap/test_helpers.rb', line 445

def other_actors_clickwrap_token_for(policy_key, actor:, other_actor:)
  if Reference.actor(actor) == Reference.actor(other_actor)
    raise ArgumentError,
          "other_actors_clickwrap_token_for was given the same actor twice, so the token it " \
          "returned would be the actor's own and the mismatch it exists to trigger could " \
          "never happen. Pass two different records."
  end

  present_clickwrap(policy_key, actor: other_actor).token
end

#present_clickwrap(policy_key, actor: nil, subject: nil, tenant: nil, locale: nil, submit_button_text: "Continue", capture_channel: :web_browser, prospective_actor: nil, registration_flow_id: nil, acting_for: nil) ⇒ Clickwrap::Presenter::Result

Presents a policy server-side and returns the Presenter::Result, whose token is a real signed presentation token.

Parameters:

  • policy_key (Symbol, String)

    the policy to present

  • actor (Object, nil) (defaults to: nil)

    the record acting, or nil for a registration flow

  • submit_button_text (String) (defaults to: "Continue")

    the exact call to action, recorded in the manifest

Returns:



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/clickwrap/test_helpers.rb', line 94

def present_clickwrap(policy_key, actor: nil, subject: nil, tenant: nil, locale: nil,
                      submit_button_text: "Continue", capture_channel: :web_browser,
                      prospective_actor: nil, registration_flow_id: nil, acting_for: nil)
  registration_flow_id ||= SecureRandom.uuid if prospective_actor

  Presenter.new(
    policy: Clickwrap.policy!(policy_key),
    actor: actor,
    prospective_actor: prospective_actor,
    registration_flow_id: registration_flow_id,
    subject: subject,
    tenant: tenant,
    acting_for: acting_for,
    locale: locale,
    submit_button_text: submit_button_text,
    capture_channel: capture_channel
  ).present
end

#refute_clickwrap_current(policy_key, actor:, subject: nil, tenant: nil) ⇒ Object

Assert that an actor does NOT currently satisfy a policy.



515
516
517
518
519
520
521
522
# File 'lib/clickwrap/test_helpers.rb', line 515

def refute_clickwrap_current(policy_key, actor:, subject: nil, tenant: nil)
  result = Clickwrap.verify(policy_key, actor: actor, subject: subject, tenant: tenant)

  refute result.success?,
         "Expected #{clickwrap_actor_label(actor)} NOT to satisfy the #{policy_key} policy, " \
         "but verification succeeded against event #{result.event_id}. " \
         "#{clickwrap_state_summary(actor, policy_key)}"
end

#stale_clickwrap_token_for(policy_key) ⇒ String

A token bound to a policy revision that is no longer on file — the token a browser holds when the policy was edited and redeployed between the GET and the POST.

The revision digest is replaced with a derived one rather than by deleting the real PolicyRevision row, so the fixture other tests rely on survives. Capture rejects it with :stale_policy_revision.

Returns:

  • (String)

    a signed presentation token



428
429
430
431
432
433
434
435
436
# File 'lib/clickwrap/test_helpers.rb', line 428

def stale_clickwrap_token_for(policy_key, **)
  presentation = present_clickwrap(policy_key, **)
  attributes = presentation.manifest.to_h
  superseded = Digest.digest("superseded:#{attributes.dig("policy", "revision")}")

  PresentationManifest
    .new(attributes.merge("policy" => attributes["policy"].merge("revision" => superseded)))
    .to_token
end

#submission_for(presentation_result, answers = {}) ⇒ Clickwrap::Submission

Builds a real Submission from a presentation result.

Parameters:

Returns:



123
124
125
126
127
128
# File 'lib/clickwrap/test_helpers.rb', line 123

def submission_for(presentation_result, answers = {})
  Submission.new(
    presentation_token: presentation_result.token,
    answers: answers.transform_keys(&:to_s)
  )
end

#submit_clickwrap(policy_key, actor:, answers: {}, subject: nil, tenant: nil, locale: nil, capture_channel: :web_browser, http_request: nil, acting_for: nil) ⇒ Clickwrap::Receipt

Presents, answers, and captures — the everyday way to get real evidence into a test database.

An empty answers: affirms every required statement and leaves every optional one alone, which is what a person completing the form normally does. Optional controls are never auto-answered: leaving one unselected creates no grant, and a helper that quietly granted an optional consent would hide exactly the bug that distinction exists to catch.

A different verb from the CONTROLLER helper because it is a different act. This one SUBMITS a presentation it just built — it is a test factory verb, like create_user, and a factory that cannot deliver raises, since in a test a failed capture is a failed test. Clickwrap::ControllerHelpers#capture_clickwrap CAPTURES a submission a person actually sent, follows save/save!, and absorbs refusals, because in a controller a refused submission is a person to answer rather than a bug.

They used to share the name capture_clickwrap and contradict each other about what "no" means. Both modules can end up on one object, and a helper whose failure mode depends on which module won is not a helper.

Returns:



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
# File 'lib/clickwrap/test_helpers.rb', line 310

def submit_clickwrap(policy_key, actor:, answers: {}, subject: nil, tenant: nil, locale: nil,
                     capture_channel: :web_browser, http_request: nil, acting_for: nil)
  presentation = present_clickwrap(policy_key, actor: actor, subject: subject,
                                               tenant: tenant, locale: locale,
                                               acting_for: acting_for,
                                               capture_channel: capture_channel)

  result = Clickwrap.capture!(
    policy_key,
    actor: actor,
    subject: subject,
    tenant: tenant,
    acting_for: acting_for,
    http_request: http_request,
    capture_channel: capture_channel,
    submission: submission_for(presentation, default_clickwrap_answers(policy_key, answers))
  )

  committed_test_receipt(result)
end

#submit_clickwrap_and(policy_key, actor:, answers: {}, subject: nil, tenant: nil, capture_channel: :web_browser, http_request: nil, acting_for: nil) ⇒ Clickwrap::Receipt

The same, with a protected action in the same transaction. Use it to prove that your domain write and its evidence commit together.

Returns:



337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/clickwrap/test_helpers.rb', line 337

def submit_clickwrap_and(policy_key, actor:, answers: {}, subject: nil, tenant: nil,
                         capture_channel: :web_browser, http_request: nil, acting_for: nil, &)
  presentation = present_clickwrap(policy_key, actor: actor, subject: subject, tenant: tenant,
                                               acting_for: acting_for,
                                               capture_channel: capture_channel)

  result = Clickwrap.capture_and!(
    policy_key,
    actor: actor,
    subject: subject,
    tenant: tenant,
    acting_for: acting_for,
    http_request: http_request,
    capture_channel: capture_channel,
    submission: submission_for(presentation, default_clickwrap_answers(policy_key, answers)),
    &
  )

  committed_test_receipt(result)
end

#submit_clickwrap_twice(policy_key, actor:, answers: {}, subject: nil, tenant: nil) ⇒ Array<Clickwrap::Receipt>

Submits the SAME presentation twice, which is what a double-click, a retried request, and a replayed token all look like to the server.

Returns both results. An identical repeat returns the original receipt without running anything twice, so the two share an event_id; a repeat with different answers raises Clickwrap::ReplayRejected.

Returns:



466
467
468
469
470
471
472
473
474
# File 'lib/clickwrap/test_helpers.rb', line 466

def submit_clickwrap_twice(policy_key, actor:, answers: {}, subject: nil, tenant: nil)
  presentation = present_clickwrap(policy_key, actor: actor, subject: subject, tenant: tenant)
  submission = submission_for(presentation, default_clickwrap_answers(policy_key, answers))

  options = { actor: actor, subject: subject, tenant: tenant,
              capture_channel: "web_browser", submission: submission }

  [Clickwrap.capture!(policy_key, **options), Clickwrap.capture!(policy_key, **options)]
end