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.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +138 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +184 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +716 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- 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
|