clickwrap 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,676 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # Test helpers for Minitest.
5
+ # Include in your test helper to get evidence-related test utilities.
6
+ #
7
+ # @example Include in test_helper.rb
8
+ # require "clickwrap/test_helpers"
9
+ #
10
+ # class ActiveSupport::TestCase
11
+ # include Clickwrap::TestHelpers
12
+ #
13
+ # setup { Clickwrap::Testing.reset! }
14
+ # teardown { Clickwrap::Testing.reset! }
15
+ # end
16
+ #
17
+ # @example Using in tests
18
+ # test "signup records the terms agreement" do
19
+ # receipt = submit_clickwrap(:signup, actor: @user,
20
+ # answers: { terms: true, privacy_notice: true })
21
+ #
22
+ # assert_clickwrap_current :signup, actor: @user
23
+ # assert_clickwrap_agreed_to :terms, actor: @user
24
+ # assert_clickwrap_acknowledged :privacy_notice, actor: @user
25
+ # assert_clickwrap_receipt_verifies receipt
26
+ # end
27
+ #
28
+ # @example Proving the atomicity promise
29
+ # test "a failed evidence write rolls the account back" do
30
+ # Clickwrap::Testing.fail_next_event_write do
31
+ # assert_raises(Clickwrap::EventWriteFailed) { perform_signup }
32
+ # end
33
+ #
34
+ # assert_not User.exists?(email: "person@example.com")
35
+ # assert_no_clickwrap_event :signup
36
+ # end
37
+ #
38
+ # ===========================================================================
39
+ # EVERY HELPER HERE GOES THROUGH THE REAL PATH.
40
+ #
41
+ # `submit_clickwrap` presents the policy through the actual Presenter, takes
42
+ # the signed token it produced, builds an actual `Clickwrap::Submission` from
43
+ # it, and calls the actual `Clickwrap.capture!`. It does not insert rows.
44
+ #
45
+ # That is not fastidiousness. Hand-built evidence rows are the single most
46
+ # effective way to make a suite green while the product is broken: they skip
47
+ # the manifest, the digests, the answer validation, the idempotency key, the
48
+ # projection, and the very checks the gem exists to perform, and then every
49
+ # assertion downstream is checking that a fixture matches itself. Evidence
50
+ # created by these helpers is internally consistent because it was created
51
+ # the same way real evidence is.
52
+ #
53
+ # The deliberately-broken cases — an expired presentation, a stale revision,
54
+ # another actor's token — are built by taking a REAL presentation and
55
+ # changing exactly one thing about it, so what fails is the server's own
56
+ # check rather than a mock standing in for it.
57
+ # ===========================================================================
58
+ module TestHelpers
59
+ # Opt-in class macro for integration tests that must reach real commit
60
+ # callbacks instead of Rails' normal transactional-test rollback boundary.
61
+ module ClassMethods
62
+ # Sensitive exports deliberately refuse to reveal data from inside an
63
+ # outer transaction: their access audit must commit before bytes leave
64
+ # the process. Rails wraps tests in transactions by default, so tests of
65
+ # that production contract can opt into real commits explicitly.
66
+ #
67
+ # This also installs deterministic cleanup around each such test. It is a
68
+ # test-only facility; production code never truncates host tables.
69
+ def use_real_database_commits!
70
+ self.use_transactional_tests = false
71
+
72
+ setup do
73
+ reset_clickwrap_test_database!
74
+ publish_clickwrap_documents!
75
+ end
76
+
77
+ teardown { reset_clickwrap_test_database! }
78
+ end
79
+ end
80
+
81
+ def self.included(base)
82
+ base.extend(ClassMethods)
83
+ end
84
+
85
+ # --- Presenting and capturing ---------------------------------------------
86
+
87
+ # Presents a policy server-side and returns the Presenter::Result, whose
88
+ # `token` is a real signed presentation token.
89
+ #
90
+ # @param policy_key [Symbol, String] the policy to present
91
+ # @param actor [Object, nil] the record acting, or nil for a registration flow
92
+ # @param submit_button_text [String] the exact call to action, recorded in the manifest
93
+ # @return [Clickwrap::Presenter::Result]
94
+ def present_clickwrap(policy_key, actor: nil, subject: nil, tenant: nil, locale: nil,
95
+ submit_button_text: "Continue", capture_channel: :web_browser,
96
+ prospective_actor: nil, registration_flow_id: nil, acting_for: nil)
97
+ registration_flow_id ||= SecureRandom.uuid if prospective_actor
98
+
99
+ Presenter.new(
100
+ policy: Clickwrap.policy!(policy_key),
101
+ actor: actor,
102
+ prospective_actor: prospective_actor,
103
+ registration_flow_id: registration_flow_id,
104
+ subject: subject,
105
+ tenant: tenant,
106
+ acting_for: acting_for,
107
+ locale: locale,
108
+ submit_button_text: submit_button_text,
109
+ capture_channel: capture_channel
110
+ ).present
111
+ end
112
+ module_function :present_clickwrap
113
+ # module_function makes the INSTANCE copy private — fine for included test
114
+ # usage (implicit receiver) while enabling the module-level call, for
115
+ # suites whose own factory names would collide with this module's.
116
+ public :present_clickwrap
117
+
118
+ # Builds a real Submission from a presentation result.
119
+ #
120
+ # @param presentation_result [Clickwrap::Presenter::Result]
121
+ # @param answers [Hash] statement key => answer
122
+ # @return [Clickwrap::Submission]
123
+ def submission_for(presentation_result, answers = {})
124
+ Submission.new(
125
+ presentation_token: presentation_result.token,
126
+ answers: answers.transform_keys(&:to_s)
127
+ )
128
+ end
129
+ module_function :submission_for
130
+ public :submission_for
131
+
132
+ # The one-line form of the pattern below, for integration tests: GET the
133
+ # page that renders a clickwrap presentation, read the signed token and
134
+ # controls back out of it, return the params for the POST.
135
+ #
136
+ # post user_registration_path, params: {
137
+ # user: { ... },
138
+ # **clickwrap_params_from(new_user_registration_path)
139
+ # }
140
+ def clickwrap_params_from(path, answers: {}, form_css_selector: nil)
141
+ unless respond_to?(:get)
142
+ raise ArgumentError,
143
+ "clickwrap_params_from drives a real GET, so it needs an integration test " \
144
+ "(ActionDispatch::IntegrationTest). In other tests, build the submission with " \
145
+ "present_clickwrap + submission_for instead."
146
+ end
147
+
148
+ get path
149
+ clickwrap_submission_params_from(
150
+ response,
151
+ answers: answers,
152
+ form_css_selector: form_css_selector
153
+ )
154
+ end
155
+ module_function :clickwrap_params_from
156
+ public :clickwrap_params_from
157
+
158
+ # For integration tests that POST a form the application really rendered —
159
+ # the signed presentation token is minted per render and bound to the
160
+ # session, so a test cannot fabricate it; it has to read it back out of
161
+ # the page, exactly like a browser:
162
+ #
163
+ # get new_user_registration_path
164
+ # post user_registration_path, params: {
165
+ # user: { email: "person@example.com", password: "..." },
166
+ # **clickwrap_submission_params_from(response)
167
+ # }
168
+ #
169
+ # Every control the page rendered is answered affirmatively by default —
170
+ # what a person completing the form normally does. Decline or skip one
171
+ # explicitly with `answers:`:
172
+ #
173
+ # clickwrap_submission_params_from(response, answers: { product_updates: false })
174
+ #
175
+ # A page with several independent clickwrap forms must name the exact form.
176
+ # The helper refuses ambiguity rather than mixing one form's token with
177
+ # another form's answers:
178
+ #
179
+ # clickwrap_submission_params_from(
180
+ # response,
181
+ # form_css_selector: "form[action='/withdrawals/confirm']"
182
+ # )
183
+ #
184
+ # @param rendered [String, #body] the HTML, or the integration response
185
+ # @param answers [Hash] statement key => true/false/String override
186
+ # @param form_css_selector [String, nil] exact CSS selector for one form
187
+ # @return [Hash] params ready to merge into the POST
188
+ def clickwrap_submission_params_from(rendered, answers: {}, form_css_selector: nil)
189
+ require "nokogiri"
190
+
191
+ html = rendered.respond_to?(:body) ? rendered.body : rendered.to_s
192
+ page = Nokogiri::HTML(html)
193
+ scope = clickwrap_test_form_scope(page, form_css_selector)
194
+
195
+ token_fields = scope.css('input[name="clickwrap_submission[presentation_token]"]')
196
+ if token_fields.many?
197
+ raise ArgumentError,
198
+ "The rendered page carries #{token_fields.size} clickwrap presentation tokens. " \
199
+ "Pass form_css_selector: with a CSS selector that matches exactly one form."
200
+ end
201
+
202
+ token = token_fields.first&.[]("value")
203
+ if token.to_s.empty?
204
+ raise ArgumentError,
205
+ "The rendered page carries no clickwrap presentation token. GET the page that " \
206
+ "renders `form.clickwrap` (or `form.clickwrap_fields`) first, then pass that " \
207
+ "response to clickwrap_submission_params_from."
208
+ end
209
+
210
+ overrides = answers.transform_keys(&:to_s)
211
+ controls_by_statement = scope.css(%([name^="clickwrap_submission[answers]["]))
212
+ .group_by { |control| control["name"][/\[answers\]\[([^\]]+)\]/, 1] }
213
+
214
+ answered = controls_by_statement.each_with_object({}) do |(key, controls), result|
215
+ next if key.nil?
216
+
217
+ answer = if overrides.key?(key)
218
+ normalize_rendered_control_answer(controls, overrides.fetch(key))
219
+ else
220
+ default_rendered_control_answer(controls)
221
+ end
222
+ result[key] = answer unless answer.nil?
223
+ end
224
+
225
+ { "clickwrap_submission" => { "presentation_token" => token, "answers" => answered.compact } }
226
+ end
227
+ module_function :clickwrap_submission_params_from
228
+ public :clickwrap_submission_params_from
229
+
230
+ def clickwrap_test_form_scope(page, form_css_selector)
231
+ return page if form_css_selector.nil?
232
+
233
+ forms = page.css(form_css_selector.to_s)
234
+ unless forms.one? && forms.first.name == "form"
235
+ raise ArgumentError,
236
+ "form_css_selector must match exactly one <form>; it matched #{forms.size}. " \
237
+ "Received #{form_css_selector.inspect}."
238
+ end
239
+
240
+ forms.first
241
+ rescue Nokogiri::CSS::SyntaxError => error
242
+ raise ArgumentError,
243
+ "form_css_selector must be valid CSS. #{error.message}"
244
+ end
245
+ module_function :clickwrap_test_form_scope
246
+ private :clickwrap_test_form_scope
247
+
248
+ # A checkbox's affirmative value is conventionally "1". A radio group's
249
+ # value is the exact choice key the server offered — sometimes "yes", but
250
+ # just as legitimately "employee", "contractor", or another domain word.
251
+ # Reading the rendered value keeps this integration helper faithful to the
252
+ # real form instead of fabricating a checkbox answer for every control.
253
+ def default_rendered_control_answer(controls)
254
+ first = controls.first
255
+ first["type"] == "radio" ? first["value"].to_s : "1"
256
+ end
257
+ module_function :default_rendered_control_answer
258
+
259
+ def normalize_rendered_control_answer(controls, value)
260
+ return normalize_test_answer(value) unless controls.first["type"] == "radio"
261
+ return nil if value.nil?
262
+ return controls.first["value"].to_s if value == true
263
+
264
+ if value == false
265
+ negative = controls.find do |control|
266
+ control["value"].to_s.in?(%w[no decline false 0])
267
+ end
268
+ return (negative || controls.last)["value"].to_s
269
+ end
270
+
271
+ value.to_s
272
+ end
273
+ module_function :normalize_rendered_control_answer
274
+ private :default_rendered_control_answer, :normalize_rendered_control_answer
275
+
276
+ def normalize_test_answer(value)
277
+ case value
278
+ when true then "1"
279
+ when false then "0"
280
+ when nil then nil
281
+ else value.to_s
282
+ end
283
+ end
284
+ module_function :normalize_test_answer
285
+ private :normalize_test_answer
286
+
287
+ # Presents, answers, and captures — the everyday way to get real evidence
288
+ # into a test database.
289
+ #
290
+ # An empty `answers:` affirms every required statement and leaves every
291
+ # optional one alone, which is what a person completing the form normally
292
+ # does. Optional controls are never auto-answered: leaving one unselected
293
+ # creates no grant, and a helper that quietly granted an optional consent
294
+ # would hide exactly the bug that distinction exists to catch.
295
+ #
296
+ # A different verb from the CONTROLLER helper because it is a different
297
+ # act. This one SUBMITS a presentation it just built — it is a test factory
298
+ # verb, like `create_user`, and a factory that cannot deliver raises, since
299
+ # in a test a failed capture is a failed test.
300
+ # `Clickwrap::ControllerHelpers#capture_clickwrap` CAPTURES a submission a
301
+ # person actually sent, follows `save`/`save!`, and absorbs refusals,
302
+ # because in a controller a refused submission is a person to answer rather
303
+ # than a bug.
304
+ #
305
+ # They used to share the name `capture_clickwrap` and contradict each other
306
+ # about what "no" means. Both modules can end up on one object, and a
307
+ # helper whose failure mode depends on which module won is not a helper.
308
+ #
309
+ # @return [Clickwrap::Receipt]
310
+ def submit_clickwrap(policy_key, actor:, answers: {}, subject: nil, tenant: nil, locale: nil,
311
+ capture_channel: :web_browser, http_request: nil, acting_for: nil)
312
+ presentation = present_clickwrap(policy_key, actor: actor, subject: subject,
313
+ tenant: tenant, locale: locale,
314
+ acting_for: acting_for,
315
+ capture_channel: capture_channel)
316
+
317
+ result = Clickwrap.capture!(
318
+ policy_key,
319
+ actor: actor,
320
+ subject: subject,
321
+ tenant: tenant,
322
+ acting_for: acting_for,
323
+ http_request: http_request,
324
+ capture_channel: capture_channel,
325
+ submission: submission_for(presentation, default_clickwrap_answers(policy_key, answers))
326
+ )
327
+
328
+ committed_test_receipt(result)
329
+ end
330
+ module_function :submit_clickwrap
331
+ public :submit_clickwrap
332
+
333
+ # The same, with a protected action in the same transaction. Use it to
334
+ # prove that your domain write and its evidence commit together.
335
+ #
336
+ # @return [Clickwrap::Receipt]
337
+ def submit_clickwrap_and(policy_key, actor:, answers: {}, subject: nil, tenant: nil,
338
+ capture_channel: :web_browser, http_request: nil, acting_for: nil, &)
339
+ presentation = present_clickwrap(policy_key, actor: actor, subject: subject, tenant: tenant,
340
+ acting_for: acting_for,
341
+ capture_channel: capture_channel)
342
+
343
+ result = Clickwrap.capture_and!(
344
+ policy_key,
345
+ actor: actor,
346
+ subject: subject,
347
+ tenant: tenant,
348
+ acting_for: acting_for,
349
+ http_request: http_request,
350
+ capture_channel: capture_channel,
351
+ submission: submission_for(presentation, default_clickwrap_answers(policy_key, answers)),
352
+ &
353
+ )
354
+
355
+ committed_test_receipt(result)
356
+ end
357
+ module_function :submit_clickwrap_and
358
+ public :submit_clickwrap_and
359
+
360
+ # Transactional test wrappers intentionally never commit. The production
361
+ # API therefore returns PendingReceipt inside them, correctly, while tests
362
+ # still need to inspect the rows they just created. This test-only adapter
363
+ # exposes a Receipt projection without changing the pending object's
364
+ # `committed?` answer or weakening the production finality contract.
365
+ def committed_test_receipt(result)
366
+ return result unless result.is_a?(PendingReceipt)
367
+
368
+ Receipt.new(result.event)
369
+ end
370
+ module_function :committed_test_receipt
371
+ private :committed_test_receipt
372
+
373
+ def reset_clickwrap_test_database!
374
+ connection = ::ActiveRecord::Base.connection
375
+ protected_tables = %w[ar_internal_metadata schema_migrations]
376
+ tables = connection.tables - protected_tables
377
+
378
+ connection.disable_referential_integrity do
379
+ tables.each { |table| connection.execute("DELETE FROM #{connection.quote_table_name(table)}") }
380
+ end
381
+ end
382
+ private :reset_clickwrap_test_database!
383
+
384
+ # --- The harder cases -----------------------------------------------------
385
+
386
+ # A presentation whose manifest has ALREADY EXPIRED.
387
+ #
388
+ # Built by taking a real presentation and rewriting its issue and expiry
389
+ # times into the past, then signing it with the manifest verifier directly
390
+ # rather than through `to_token`.
391
+ #
392
+ # That last part is the subtle bit and it is deliberate. `to_token` passes
393
+ # the manifest's `expires_at` to the message verifier, so an
394
+ # already-expired manifest produces a token the signature layer refuses
395
+ # before Clickwrap's own expiry check ever runs — and the test would then
396
+ # be asserting that `ActiveSupport::MessageVerifier` works. Signing without
397
+ # the message-level TTL leaves a perfectly valid signature over a manifest
398
+ # that is plainly out of date, so what rejects it is the server's own
399
+ # `PresentationExpired` path. Real check, not a mocked one.
400
+ #
401
+ # @return [Clickwrap::Presenter::Result] whose token is expired
402
+ def expired_clickwrap_presentation_for(policy_key, **)
403
+ presentation = present_clickwrap(policy_key, **)
404
+ window = Clickwrap.config.presentation_valid_for
405
+ now = Clickwrap.now
406
+
407
+ expired = PresentationManifest.new(
408
+ presentation.manifest.to_h.merge(
409
+ "issued_at" => Receipt.format_time(now - (window * 2)),
410
+ "expires_at" => Receipt.format_time(now - window)
411
+ )
412
+ )
413
+
414
+ presentation.with(manifest: expired, token: clickwrap_token_without_message_expiry(expired))
415
+ end
416
+ module_function :expired_clickwrap_presentation_for
417
+ public :expired_clickwrap_presentation_for
418
+
419
+ # A token bound to a policy revision that is no longer on file — the token
420
+ # a browser holds when the policy was edited and redeployed between the GET
421
+ # and the POST.
422
+ #
423
+ # The revision digest is replaced with a derived one rather than by
424
+ # deleting the real PolicyRevision row, so the fixture other tests rely on
425
+ # survives. Capture rejects it with `:stale_policy_revision`.
426
+ #
427
+ # @return [String] a signed presentation token
428
+ def stale_clickwrap_token_for(policy_key, **)
429
+ presentation = present_clickwrap(policy_key, **)
430
+ attributes = presentation.manifest.to_h
431
+ superseded = Digest.digest("superseded:#{attributes.dig("policy", "revision")}")
432
+
433
+ PresentationManifest
434
+ .new(attributes.merge("policy" => attributes["policy"].merge("revision" => superseded)))
435
+ .to_token
436
+ end
437
+ module_function :stale_clickwrap_token_for
438
+ public :stale_clickwrap_token_for
439
+
440
+ # A token legitimately issued to somebody else, for the swapped-token case.
441
+ # Submit it while capturing as `actor` and Capture rejects it with
442
+ # `:presentation_actor_mismatch`.
443
+ #
444
+ # @return [String] a signed presentation token bound to `other_actor`
445
+ def other_actors_clickwrap_token_for(policy_key, actor:, other_actor:)
446
+ if Reference.actor(actor) == Reference.actor(other_actor)
447
+ raise ArgumentError,
448
+ "other_actors_clickwrap_token_for was given the same actor twice, so the token it " \
449
+ "returned would be the actor's own and the mismatch it exists to trigger could " \
450
+ "never happen. Pass two different records."
451
+ end
452
+
453
+ present_clickwrap(policy_key, actor: other_actor).token
454
+ end
455
+ module_function :other_actors_clickwrap_token_for
456
+ public :other_actors_clickwrap_token_for
457
+
458
+ # Submits the SAME presentation twice, which is what a double-click, a
459
+ # retried request, and a replayed token all look like to the server.
460
+ #
461
+ # Returns both results. An identical repeat returns the original receipt
462
+ # without running anything twice, so the two share an `event_id`; a repeat
463
+ # with different answers raises `Clickwrap::ReplayRejected`.
464
+ #
465
+ # @return [Array<Clickwrap::Receipt>] the first and second results
466
+ def submit_clickwrap_twice(policy_key, actor:, answers: {}, subject: nil, tenant: nil)
467
+ presentation = present_clickwrap(policy_key, actor: actor, subject: subject, tenant: tenant)
468
+ submission = submission_for(presentation, default_clickwrap_answers(policy_key, answers))
469
+
470
+ options = { actor: actor, subject: subject, tenant: tenant,
471
+ capture_channel: "web_browser", submission: submission }
472
+
473
+ [Clickwrap.capture!(policy_key, **options), Clickwrap.capture!(policy_key, **options)]
474
+ end
475
+ module_function :submit_clickwrap_twice
476
+ public :submit_clickwrap_twice
477
+
478
+ # --- System tests ---------------------------------------------------------
479
+
480
+ # Checks every required control the policy declares, on the page currently
481
+ # rendered, and leaves optional ones alone.
482
+ #
483
+ # complete_clickwrap :signup
484
+ # click_button "Create account"
485
+ #
486
+ # Controls are found by their form-field name rather than by an id, so this
487
+ # keeps working after you eject the views and restyle everything: the name
488
+ # is part of the submission contract, the markup around it is yours.
489
+ def complete_clickwrap(policy_key)
490
+ unless respond_to?(:page)
491
+ raise NoMethodError,
492
+ "complete_clickwrap drives a rendered page and needs Capybara, so it works in a " \
493
+ "system test. In a model or integration test use submit_clickwrap, which goes " \
494
+ "through the same presenter and capture path without a browser."
495
+ end
496
+
497
+ Clickwrap.policy!(policy_key).statements.reject(&:optional?).each do |statement|
498
+ complete_clickwrap_statement(statement)
499
+ end
500
+ end
501
+
502
+ # --- Assertions -----------------------------------------------------------
503
+
504
+ # Assert that an actor currently satisfies a whole policy.
505
+ def assert_clickwrap_current(policy_key, actor:, subject: nil, tenant: nil)
506
+ result = Clickwrap.verify(policy_key, actor: actor, subject: subject, tenant: tenant)
507
+
508
+ assert result.success?,
509
+ "Expected #{clickwrap_actor_label(actor)} to currently satisfy the #{policy_key} " \
510
+ "policy, but verification failed with #{result.error.inspect}: #{result.message}. " \
511
+ "#{clickwrap_state_summary(actor, policy_key)}"
512
+ end
513
+
514
+ # Assert that an actor does NOT currently satisfy a policy.
515
+ def refute_clickwrap_current(policy_key, actor:, subject: nil, tenant: nil)
516
+ result = Clickwrap.verify(policy_key, actor: actor, subject: subject, tenant: tenant)
517
+
518
+ refute result.success?,
519
+ "Expected #{clickwrap_actor_label(actor)} NOT to satisfy the #{policy_key} policy, " \
520
+ "but verification succeeded against event #{result.event_id}. " \
521
+ "#{clickwrap_state_summary(actor, policy_key)}"
522
+ end
523
+
524
+ def assert_clickwrap_agreed_to(statement_key, actor:, subject: nil, tenant: nil)
525
+ assert_clickwrap_kind("agreement", :agreed_to?, statement_key, actor, subject, tenant)
526
+ end
527
+
528
+ def assert_clickwrap_acknowledged(statement_key, actor:, subject: nil, tenant: nil)
529
+ assert_clickwrap_kind("acknowledgment", :acknowledged?, statement_key, actor, subject, tenant)
530
+ end
531
+
532
+ def assert_clickwrap_consented_to(purpose_key, actor:, subject: nil, tenant: nil)
533
+ assert_clickwrap_kind("consent", :consented_to?, purpose_key, actor, subject, tenant)
534
+ end
535
+
536
+ def assert_clickwrap_declared(statement_key, actor:, subject: nil, tenant: nil)
537
+ assert_clickwrap_kind("declaration", :declared?, statement_key, actor, subject, tenant)
538
+ end
539
+
540
+ def assert_clickwrap_authorized(statement_key, actor:, subject: nil, tenant: nil)
541
+ assert_clickwrap_kind("authorization", :authorized?, statement_key, actor, subject, tenant)
542
+ end
543
+
544
+ # Assert that an actor is exempted from a policy — a deliberately separate
545
+ # question from having agreed to it. An exemption records that no human
546
+ # action occurred, so it can never satisfy `assert_clickwrap_agreed_to`,
547
+ # and a test that expects it to has found a real bug rather than a helper
548
+ # limitation.
549
+ def assert_clickwrap_exempted_from(policy_key, actor:, subject: nil, tenant: nil)
550
+ proxy = ActorProxy.new(actor)
551
+
552
+ assert proxy.exempted_from?(policy_key, subject: subject, tenant: tenant),
553
+ "Expected #{clickwrap_actor_label(actor)} to be exempted from #{policy_key}, but no " \
554
+ "exemption event was recorded for them. An exemption is created explicitly with " \
555
+ "Clickwrap.exempt!(#{policy_key.to_sym.inspect}, actor:, because:), and the policy " \
556
+ "must permit one. #{clickwrap_state_summary(actor, policy_key)}"
557
+ end
558
+
559
+ # Assert that a receipt still verifies: its digest covers its bytes, its
560
+ # document versions still match what was published, and the event has not
561
+ # been disposed of.
562
+ def assert_clickwrap_receipt_verifies(receipt)
563
+ result = receipt.verify
564
+
565
+ assert result.success?,
566
+ "Expected receipt #{receipt.event_id} to verify, but it failed with " \
567
+ "#{result.error.inspect}: #{result.message}. A digest failure means the event row's " \
568
+ "meaningful bytes changed after it was written; a document failure means a " \
569
+ "published version was edited in place instead of republished under a new label."
570
+ end
571
+
572
+ # Assert that no event exists for a policy — the other half of a fault
573
+ # injection test, where the point is that nothing was recorded.
574
+ def assert_no_clickwrap_event(policy_key, actor: nil)
575
+ scope = Event.for_policy(policy_key)
576
+ scope = scope.for_actor(Reference.actor(actor)) if actor
577
+ found = scope.chronological.to_a
578
+ listed = found.join("; ")
579
+ whose = actor ? " and #{clickwrap_actor_label(actor)}" : ""
580
+
581
+ assert found.empty?,
582
+ "Expected no Clickwrap event for #{policy_key}#{whose}, but found " \
583
+ "#{found.length}: #{listed}. If this followed a fault injection block, the evidence " \
584
+ "write was not rolled back with the action it was supposed to commit alongside."
585
+ end
586
+
587
+ private
588
+
589
+ # Required statements get an affirmative answer; optional ones are left
590
+ # untouched. Anything the caller passed wins over both.
591
+ # Explicit beats implicit, and the rule is deliberately all-or-nothing.
592
+ #
593
+ # `answers: {}` affirms every required statement, which is what a person
594
+ # completing the form normally does and what most tests want. But the moment
595
+ # a test names ANY answer, it gets exactly what it named and nothing else —
596
+ # because the tests that matter most here are the ones that deliberately
597
+ # leave a required statement unanswered, and a helper that quietly filled it
598
+ # in would turn "the server refuses an incomplete submission" into a test
599
+ # that passes for the wrong reason.
600
+ def default_clickwrap_answers(policy_key, answers)
601
+ return answers.transform_keys(&:to_s) if answers.present?
602
+
603
+ Clickwrap.policy!(policy_key).required_statements.to_h do |statement|
604
+ [statement.key, statement.choices ? clickwrap_granting_choice(statement) : "1"]
605
+ end
606
+ end
607
+ module_function :default_clickwrap_answers
608
+
609
+ # The choice a policy declared as meaning "grant"; otherwise the first one
610
+ # offered, so a statement with domain-specific choice names still works.
611
+ def clickwrap_granting_choice(statement)
612
+ statement.choices.find { |_, meaning| meaning == "grant" }&.first || statement.choices.keys.first
613
+ end
614
+ module_function :clickwrap_granting_choice
615
+
616
+ def clickwrap_token_without_message_expiry(manifest)
617
+ PresentationManifest.verifier.generate(
618
+ manifest.to_h, purpose: PresentationManifest::SIGNING_PURPOSE
619
+ )
620
+ end
621
+ module_function :clickwrap_token_without_message_expiry
622
+
623
+ def assert_clickwrap_kind(kind, predicate, statement_key, actor, subject, tenant)
624
+ satisfied = ActorProxy.new(actor).public_send(predicate, statement_key,
625
+ subject: subject, tenant: tenant)
626
+
627
+ assert satisfied,
628
+ "Expected #{clickwrap_actor_label(actor)} to have a current #{kind} for " \
629
+ "#{statement_key.inspect}, and they do not. Remember that an exemption never " \
630
+ "satisfies a human-action question, an optional control left unselected creates no " \
631
+ "grant, and a withdrawn, expired, superseded, or consumed record is recorded but " \
632
+ "not current. #{clickwrap_statement_summary(actor, statement_key)}"
633
+ end
634
+
635
+ def clickwrap_actor_label(actor)
636
+ return "(no actor)" if actor.nil?
637
+
638
+ Reference.actor(actor)
639
+ rescue StandardError
640
+ actor.inspect
641
+ end
642
+
643
+ def clickwrap_state_summary(actor, policy_key)
644
+ states = StatementState
645
+ .for_actor(clickwrap_actor_label(actor))
646
+ .for_policy(policy_key)
647
+ .map { |state| "#{state.statement_key}=#{state.state}" }
648
+
649
+ return "No statement states are recorded for them under #{policy_key}." if states.empty?
650
+
651
+ "Recorded states: #{states.join(", ")}."
652
+ end
653
+
654
+ def clickwrap_statement_summary(actor, statement_key)
655
+ states = StatementState
656
+ .for_actor(clickwrap_actor_label(actor))
657
+ .for_statement(statement_key)
658
+ .map { |state| "#{state.kind} #{state.state} (expires #{state.expires_at || "never"})" }
659
+
660
+ return "Nothing is recorded for #{statement_key.inspect} at all." if states.empty?
661
+
662
+ "Recorded: #{states.join("; ")}."
663
+ end
664
+
665
+ def complete_clickwrap_statement(statement)
666
+ name = "clickwrap_submission[answers][#{statement.key}]"
667
+
668
+ if statement.choices
669
+ value = clickwrap_granting_choice(statement)
670
+ page.find(:css, "input[name='#{name}'][value='#{value}']", visible: :all).set(true)
671
+ else
672
+ page.find(:css, "input[type='checkbox'][name='#{name}']", visible: :all).set(true)
673
+ end
674
+ end
675
+ end
676
+ end