sqrl-client 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.
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "io/console"
6
+
7
+ module SQRL
8
+ module Client
9
+ # The +sqrl-client+ command-line tool.
10
+ #
11
+ # sqrl-client identity create --out bot.sqrl
12
+ # sqrl-client login https://example.com/login --identity bot.sqrl --cookies-out jar.txt
13
+ # sqrl-client conformance https://example.com/login --report junit.xml
14
+ #
15
+ # Exit codes: 0 success, 1 protocol failure (the site refused), 2 transport failure,
16
+ # 3 usage error.
17
+ class CLI
18
+ # Exit status: success.
19
+ EXIT_OK = 0
20
+ # Exit status: the server refused (a TIF failure) or the identity could not be used.
21
+ EXIT_PROTOCOL = 1
22
+ # Exit status: network or TLS failure.
23
+ EXIT_TRANSPORT = 2
24
+ # Exit status: bad arguments.
25
+ EXIT_USAGE = 3
26
+
27
+ # Help text.
28
+ USAGE = <<~TEXT
29
+ Usage: sqrl-client <command> [options]
30
+
31
+ Commands:
32
+ identity create --out FILE [--password PW | --password-file F] [--pw-seconds N] [--rescue-seconds N]
33
+ identity import --textual FILE --rescue-code CODE --out FILE [--password PW]
34
+ identity show --identity FILE [--password PW] [--json]
35
+ identity export --identity FILE [--password PW] --format s4-text|textual|binary [--out FILE]
36
+ identity rekey --identity FILE --password PW --rescue-code CODE [--out FILE] [--rescue-seconds N]
37
+ identity change-password --identity FILE --password OLD --new-password NEW [--pw-seconds N] [--out FILE]
38
+ identity previous --identity FILE [--password PW] list previous (superseded) identities
39
+ login URL perform a SQRL login (creates the account if the site does not know you)
40
+ query URL ask the site whether it knows the identity
41
+ disable URL disable SQRL login for the account
42
+ enable URL re-enable it (needs --rescue-code or an --iuk-hex identity)
43
+ remove URL remove the SQRL association (same requirement)
44
+ conformance URL run the server conformance suite against a login page
45
+ version
46
+
47
+ Identity options (login/query/disable/enable/remove):
48
+ --identity FILE S4 identity file (or set SQRL_IDENTITY_FILE)
49
+ --password PW password (or SQRL_PASSWORD / --password-file F / SQRL_PASSWORD_FILE)
50
+ --password-file F
51
+ --rescue-code CODE 24-digit rescue code (or SQRL_RESCUE_CODE), for enable/remove
52
+ --iuk-hex HEX ephemeral identity from a raw 64-hex IUK (or SQRL_IUK_HEX)
53
+
54
+ Login options:
55
+ --no-cps do not request a Client Provided Session
56
+ --alt-id ID use an Alt-ID
57
+ --refuse-unknown fail instead of creating an account on an unknown site
58
+ --ask N answer a server ask= with button N (0, 1 or 2)
59
+ --sqrlonly / --hardlock / --noiptest
60
+ --cookies-out FILE write the session cookies after login
61
+ --cookies-format F netscape (default) or json
62
+ --insecure loopback testing mode: accept plain http and qrl:// URLs
63
+ (not part of SQRL; for the extension's test server)
64
+ --no-verify-tls do not verify TLS certificates (testing only)
65
+ --ca-file FILE trust this CA bundle or self-signed certificate
66
+ --json machine-readable output
67
+ -v, --verbose protocol trace on stderr
68
+
69
+ Conformance options:
70
+ --report FILE write a JUnit XML report
71
+ --feature NAME=on|off toggle a feature (cps, poll, rekey, remove, ask, sin, alt_id, negative, cps_single_use, concurrency)
72
+ --only CASE[,CASE] run only these cases
73
+ --ask-button N button to answer asks with (default 1)
74
+ TEXT
75
+
76
+ # Run the command line.
77
+ # @param argv [Array<String>]
78
+ # @param out [IO]
79
+ # @param err [IO]
80
+ # @return [Integer] exit status
81
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
82
+ new(out: out, err: err).run(argv)
83
+ end
84
+
85
+ def initialize(out: $stdout, err: $stderr)
86
+ @out = out
87
+ @err = err
88
+ end
89
+
90
+ # @return [Integer] exit code
91
+ def run(argv)
92
+ argv = argv.dup
93
+ command = argv.shift
94
+ case command
95
+ when "identity" then identity_command(argv)
96
+ when "login", "query", "disable", "enable", "remove" then protocol_command(command.to_sym, argv)
97
+ when "conformance" then conformance_command(argv)
98
+ when "version", "--version", "-V"
99
+ @out.puts "sqrl-client #{SQRL::VERSION}"
100
+ EXIT_OK
101
+ when nil, "help", "--help", "-h"
102
+ @out.puts USAGE
103
+ command.nil? ? EXIT_USAGE : EXIT_OK
104
+ else
105
+ @err.puts "unknown command #{command.inspect}\n\n#{USAGE}"
106
+ EXIT_USAGE
107
+ end
108
+ rescue OptionParser::ParseError, ArgumentError => e
109
+ @err.puts "error: #{e.message}"
110
+ EXIT_USAGE
111
+ rescue TransportError => e
112
+ @err.puts "transport error: #{e.message}"
113
+ EXIT_TRANSPORT
114
+ rescue AskRequired => e
115
+ @err.puts "#{e.message} (use --ask N)"
116
+ EXIT_PROTOCOL
117
+ rescue LoginFailed => e
118
+ @err.puts "failed (#{e.reason}, tif 0x#{e.tif&.to_hex}): #{e.message}"
119
+ EXIT_PROTOCOL
120
+ rescue SQRL::Error => e
121
+ @err.puts "error: #{e.message}"
122
+ EXIT_PROTOCOL
123
+ end
124
+
125
+ private
126
+
127
+ # ---- identity ------------------------------------------------------------------------
128
+
129
+ def identity_command(argv)
130
+ sub = argv.shift
131
+ opts = { pw_seconds: nil, rescue_seconds: nil }
132
+ parser = OptionParser.new do |o|
133
+ o.on("--out FILE") { |v| opts[:out] = v }
134
+ o.on("--identity FILE") { |v| opts[:identity] = v }
135
+ o.on("--password PW") { |v| opts[:password] = v }
136
+ o.on("--password-file FILE") { |v| opts[:password] = File.read(v).chomp }
137
+ o.on("--textual FILE") { |v| opts[:textual] = File.read(v) }
138
+ o.on("--rescue-code CODE") { |v| opts[:rescue_code] = v }
139
+ o.on("--pw-seconds N", Integer) { |v| opts[:pw_seconds] = v }
140
+ o.on("--rescue-seconds N", Float) { |v| opts[:rescue_seconds] = v }
141
+ o.on("--new-password PW") { |v| opts[:new_password] = v }
142
+ o.on("--new-password-file FILE") { |v| opts[:new_password] = File.read(v).chomp }
143
+ o.on("--format FORMAT", %w[s4-text textual binary]) { |v| opts[:format] = v }
144
+ o.on("--json") { opts[:json] = true }
145
+ end
146
+ parser.parse!(argv)
147
+
148
+ case sub
149
+ when "create"
150
+ raise ArgumentError, "--out FILE is required" unless opts[:out]
151
+
152
+ password = opts[:password] || ENV["SQRL_PASSWORD"] || prompt_password("New identity password: ")
153
+ id = Identity.create(password: password, path: opts[:out], pw_seconds: opts[:pw_seconds],
154
+ rescue_seconds: opts[:rescue_seconds])
155
+ if opts[:json]
156
+ @out.puts JSON.generate(path: opts[:out], rescue_code: id.rescue_code, fingerprint: id.fingerprint)
157
+ else
158
+ @out.puts "Identity written to #{opts[:out]} (fingerprint #{id.fingerprint})."
159
+ @out.puts "Rescue code (write it down, it is shown once): #{id.rescue_code}"
160
+ end
161
+ EXIT_OK
162
+ when "import"
163
+ unless opts[:textual] && opts[:rescue_code] && opts[:out]
164
+ raise ArgumentError,
165
+ "--textual FILE, --rescue-code CODE and --out FILE are required"
166
+ end
167
+
168
+ password = opts[:password] || ENV["SQRL_PASSWORD"] || prompt_password("Password for the imported identity: ")
169
+ id = Identity.from_textual(opts[:textual], rescue_code: opts[:rescue_code], password: password)
170
+ id.save(opts[:out])
171
+ @out.puts opts[:json] ? JSON.generate(path: opts[:out],
172
+ fingerprint: id.fingerprint) : "Identity written to #{opts[:out]} (fingerprint #{id.fingerprint})."
173
+ EXIT_OK
174
+ when "show"
175
+ id = load_identity(identity: opts[:identity], password: opts[:password])
176
+ info = { fingerprint: id.fingerprint, previous_identities: id.previous_count, iuk_known: id.iuk?,
177
+ edition: id.s4&.edition, file: id.path }
178
+ @out.puts opts[:json] ? JSON.generate(info) : info.map { |k, v| "#{k}: #{v}" }.join("\n")
179
+ EXIT_OK
180
+ when "export" then identity_export(opts)
181
+ when "rekey" then identity_rekey(opts)
182
+ when "change-password" then identity_change_password(opts)
183
+ when "previous" then identity_previous(opts)
184
+ else
185
+ raise ArgumentError, "identity needs one of: create, import, show, export, rekey, change-password, previous"
186
+ end
187
+ end
188
+
189
+ # The identity file in one of its interchange forms:
190
+ # s4-text the SQRLDATA text form of the whole file (password + rescue blocks)
191
+ # textual the base56 "textual identity" (rescue blocks only; needs the rescue code to import)
192
+ # binary the raw sqrldata file (requires --out)
193
+ def identity_export(opts)
194
+ format = opts[:format] or raise ArgumentError, "--format s4-text|textual|binary is required"
195
+ id = load_identity(identity: opts[:identity], password: opts[:password])
196
+ raise IdentityLocked, "ephemeral identities have no file to export" unless id.s4
197
+
198
+ content =
199
+ case format
200
+ when "s4-text" then id.s4.to_text
201
+ when "textual" then Textual.format_for_display(Textual.encode(id.s4.rescue_blocks_to_bytes))
202
+ when "binary"
203
+ raise ArgumentError, "--out FILE is required for the binary format" unless opts[:out]
204
+
205
+ id.s4.to_bytes
206
+ end
207
+ if opts[:out]
208
+ File.binwrite(opts[:out], content)
209
+ File.chmod(0o600, opts[:out])
210
+ @out.puts opts[:json] ? JSON.generate(path: opts[:out], format: format,
211
+ bytes: content.bytesize) : "Wrote #{format} identity to #{opts[:out]}."
212
+ else
213
+ @out.puts content
214
+ end
215
+ EXIT_OK
216
+ end
217
+
218
+ # A fresh Identity Unlock Key; the current one becomes the most recent previous
219
+ # identity, so sites re-key the account on the next login. Prints the new rescue code.
220
+ def identity_rekey(opts)
221
+ id = load_identity(identity: opts[:identity], password: opts[:password])
222
+ rescue_code = opts[:rescue_code] || ENV.fetch("SQRL_RESCUE_CODE", nil) or raise ArgumentError,
223
+ "--rescue-code CODE is required to re-key"
224
+ password = opts[:new_password] || opts[:password] || ENV.fetch("SQRL_PASSWORD", nil)
225
+ id.rekey!(password: password, rescue_code: rescue_code, rescue_seconds: opts[:rescue_seconds])
226
+ id.save(opts[:out]) if opts[:out]
227
+ if opts[:json]
228
+ @out.puts JSON.generate(path: id.path, fingerprint: id.fingerprint, previous_identities: id.previous_count,
229
+ rescue_code: id.rescue_code)
230
+ else
231
+ @out.puts "Identity re-keyed and written to #{id.path} (fingerprint #{id.fingerprint}, #{id.previous_count} previous)."
232
+ @out.puts "New rescue code (write it down, it is shown once): #{id.rescue_code}"
233
+ end
234
+ EXIT_OK
235
+ end
236
+
237
+ # Re-encrypt the password block under a new password (the rescue block is untouched).
238
+ def identity_change_password(opts)
239
+ new_password = opts[:new_password] or raise ArgumentError,
240
+ "--new-password PW (or --new-password-file) is required"
241
+ id = load_identity(identity: opts[:identity], password: opts[:password])
242
+ raise IdentityLocked, "ephemeral identities have no password to change" unless id.s4
243
+
244
+ settings = id.s4.settings
245
+ settings.pw_verify_seconds = opts[:pw_seconds] if opts[:pw_seconds]
246
+ id.s4.set_password(id.unlocked, new_password, settings)
247
+ id.save(opts[:out] || id.path)
248
+ @out.puts opts[:json] ? JSON.generate(path: id.path,
249
+ fingerprint: id.fingerprint) : "Password changed; identity written to #{id.path}."
250
+ EXIT_OK
251
+ end
252
+
253
+ # Previous (superseded) identities carried by the file, most recent first.
254
+ def identity_previous(opts)
255
+ id = load_identity(identity: opts[:identity], password: opts[:password])
256
+ rows = (0...id.previous_count).map { |i| { index: i, fingerprint: id.previous_identity(i).fingerprint } }
257
+ if opts[:json]
258
+ @out.puts JSON.generate(current: id.fingerprint, previous: rows)
259
+ else
260
+ @out.puts "current: #{id.fingerprint}"
261
+ @out.puts(rows.empty? ? "previous: none" : rows.map do |r|
262
+ "previous[#{r[:index]}]: #{r[:fingerprint]}"
263
+ end.join("\n"))
264
+ end
265
+ EXIT_OK
266
+ end
267
+
268
+ # ---- login / query / disable / enable / remove ---------------------------------------
269
+
270
+ def protocol_command(command, argv)
271
+ opts = { cps: true, options: {}, unknown_site: :create }
272
+ parser = OptionParser.new do |o|
273
+ o.on("--identity FILE") { |v| opts[:identity] = v }
274
+ o.on("--password PW") { |v| opts[:password] = v }
275
+ o.on("--password-file FILE") { |v| opts[:password] = File.read(v).chomp }
276
+ o.on("--rescue-code CODE") { |v| opts[:rescue_code] = v }
277
+ o.on("--iuk-hex HEX") { |v| opts[:iuk_hex] = v }
278
+ o.on("--no-cps") { opts[:cps] = false }
279
+ o.on("--alt-id ID") { |v| opts[:alt_id] = v }
280
+ o.on("--refuse-unknown") { opts[:unknown_site] = :refuse }
281
+ o.on("--ask N", Integer) { |v| opts[:ask] = v }
282
+ o.on("--sqrlonly") { opts[:options][:sqrlonly] = true }
283
+ o.on("--hardlock") { opts[:options][:hardlock] = true }
284
+ o.on("--noiptest") { opts[:options][:noiptest] = true }
285
+ o.on("--cookies-out FILE") { |v| opts[:cookies_out] = v }
286
+ o.on("--cookies-format FORMAT", %w[netscape json]) { |v| opts[:cookies_format] = v }
287
+ o.on("--insecure") { opts[:insecure] = true }
288
+ o.on("--no-verify-tls") { opts[:verify_tls] = false }
289
+ o.on("--ca-file FILE") { |v| opts[:ca_file] = v }
290
+ o.on("--json") { opts[:json] = true }
291
+ o.on("-v", "--verbose") { opts[:verbose] = true }
292
+ end
293
+ parser.parse!(argv)
294
+ target = argv.shift
295
+ raise ArgumentError, "#{command} needs a URL" unless target
296
+
297
+ identity = load_identity(**opts.slice(:identity, :password, :iuk_hex))
298
+ logger = opts[:verbose] ? stderr_logger : nil
299
+ transport = Transport.new(allow_http: opts[:insecure] ? :loopback : false, verify_tls: opts.fetch(:verify_tls, true),
300
+ ca_file: opts[:ca_file], logger: logger)
301
+ agent = Agent.new(identity: identity, transport: transport, cps: opts[:cps], unknown_site: opts[:unknown_site],
302
+ options: opts[:options], alt_id: opts[:alt_id], logger: logger, insecure: opts[:insecure],
303
+ ask_handler: opts.key?(:ask) ? ->(_ask) { opts[:ask] } : nil)
304
+ result =
305
+ case command
306
+ when :login then agent.login(target)
307
+ when :query then agent.query(target)
308
+ when :disable then agent.disable(target)
309
+ when :enable then agent.enable(target, rescue_code: opts[:rescue_code] || ENV.fetch("SQRL_RESCUE_CODE", nil))
310
+ when :remove then agent.remove(target, rescue_code: opts[:rescue_code] || ENV.fetch("SQRL_RESCUE_CODE", nil))
311
+ end
312
+ write_cookies(transport, opts) if opts[:cookies_out]
313
+ if opts[:json]
314
+ @out.puts JSON.generate(result.to_h)
315
+ else
316
+ @out.puts describe(result)
317
+ end
318
+ EXIT_OK
319
+ end
320
+
321
+ def describe(result)
322
+ lines = ["#{result.command}: #{result.state.kind}#{" (disabled)" if result.state.disabled?} tif=0x#{result.tif&.to_hex}"]
323
+ lines << "idk: #{result.idk}"
324
+ if result.command != :query
325
+ notes = [(" (account created)" if result.created?), (" (account re-keyed)" if result.rekeyed?)].compact.join
326
+ lines << "authenticated: #{result.authenticated?}#{notes}"
327
+ end
328
+ lines << "cps url: #{result.cps_url}" if result.cps_url
329
+ lines << "landing: HTTP #{result.landing.status} #{result.landing.final_url}" if result.landing
330
+ lines.join("\n")
331
+ end
332
+
333
+ def write_cookies(transport, opts)
334
+ content = opts[:cookies_format] == "json" ? JSON.pretty_generate(transport.cookies_as_hashes) : transport.cookies_netscape
335
+ File.write(opts[:cookies_out], content)
336
+ File.chmod(0o600, opts[:cookies_out])
337
+ end
338
+
339
+ # ---- conformance ---------------------------------------------------------------------
340
+
341
+ def conformance_command(argv)
342
+ opts = { features: {}, ask_button: 1 }
343
+ parser = OptionParser.new do |o|
344
+ o.on("--report FILE") { |v| opts[:report] = v }
345
+ o.on("--feature NAME=VALUE") do |v|
346
+ name, value = v.split("=", 2)
347
+ opts[:features][name.to_sym] = %w[on true yes 1].include?(value.to_s.downcase)
348
+ end
349
+ o.on("--only CASES") { |v| opts[:only] = v.split(",").map(&:strip) }
350
+ o.on("--ask-button N", Integer) { |v| opts[:ask_button] = v }
351
+ o.on("--insecure") { opts[:insecure] = true }
352
+ o.on("--no-verify-tls") { opts[:verify_tls] = false }
353
+ o.on("--ca-file FILE") { |v| opts[:ca_file] = v }
354
+ o.on("--json") { opts[:json] = true }
355
+ o.on("-v", "--verbose") { opts[:verbose] = true }
356
+ end
357
+ parser.parse!(argv)
358
+ login_url = argv.shift
359
+ raise ArgumentError, "conformance needs the login page URL" unless login_url
360
+
361
+ require "sqrl/client/conformance"
362
+ logger = opts[:verbose] ? stderr_logger : nil
363
+ transport_factory = lambda do
364
+ Transport.new(allow_http: opts[:insecure] ? :loopback : false, verify_tls: opts.fetch(:verify_tls, true),
365
+ ca_file: opts[:ca_file], logger: logger)
366
+ end
367
+ runner = Conformance::Runner.new(login_url: login_url, features: opts[:features], only: opts[:only],
368
+ ask_button: opts[:ask_button], transport_factory: transport_factory,
369
+ logger: logger, insecure: opts[:insecure] ? true : false)
370
+ report = runner.run
371
+ File.write(opts[:report], report.to_junit_xml) if opts[:report]
372
+ @out.puts(opts[:json] ? JSON.generate(report.to_h) : report.to_s)
373
+ report.passed? ? EXIT_OK : EXIT_PROTOCOL
374
+ end
375
+
376
+ # ---- helpers ---------------------------------------------------------------------------
377
+
378
+ def load_identity(identity: nil, password: nil, iuk_hex: nil)
379
+ iuk_hex ||= ENV.fetch("SQRL_IUK_HEX", nil)
380
+ return Identity.ephemeral(Util.hex_decode(iuk_hex)) if iuk_hex && !iuk_hex.empty?
381
+
382
+ file = identity || ENV.fetch("SQRL_IDENTITY_FILE", nil)
383
+ if file.nil?
384
+ raise ArgumentError,
385
+ "an identity is required (--identity FILE, --iuk-hex HEX, SQRL_IDENTITY_FILE or SQRL_IUK_HEX)"
386
+ end
387
+
388
+ password ||= ENV.fetch("SQRL_PASSWORD", nil)
389
+ password ||= File.read(ENV["SQRL_PASSWORD_FILE"]).chomp if ENV["SQRL_PASSWORD_FILE"]
390
+ password ||= prompt_password("Identity password: ")
391
+ Identity.load(file, password: password)
392
+ end
393
+
394
+ def prompt_password(label)
395
+ raise ArgumentError, "a password is required (--password, --password-file or SQRL_PASSWORD)" unless $stdin.tty?
396
+
397
+ @err.print label
398
+ pw = $stdin.noecho(&:gets).to_s.chomp
399
+ @err.puts
400
+ pw
401
+ end
402
+
403
+ def stderr_logger
404
+ require "logger"
405
+ Logger.new(@err, level: Logger::DEBUG, formatter: ->(_sev, _time, _prog, msg) { "#{msg}\n" })
406
+ end
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ module Client
5
+ module Conformance
6
+ # The outcome of one conformance case.
7
+ Case = Struct.new(:name, :group, :status, :message, :duration, :transcript, :error, keyword_init: true) do
8
+ def passed? = status == :passed
9
+ def failed? = status == :failed
10
+ def skipped? = status == :skipped
11
+
12
+ # @return [Hash] the values as a plain Hash
13
+ def to_h
14
+ { name: name, group: group, status: status, message: message, duration: duration&.round(3) }
15
+ end
16
+
17
+ # @return [String] one line per case
18
+ def to_s
19
+ mark = { passed: "PASS", failed: "FAIL", skipped: "SKIP" }[status]
20
+ line = "#{mark} #{group}/#{name}"
21
+ line += " (#{duration.round(2)}s)" if duration
22
+ line += "\n #{message}" if message && !passed?
23
+ line
24
+ end
25
+ end
26
+
27
+ # Every case of a run.
28
+ class Report
29
+ attr_reader :cases, :login_url, :started_at, :finished_at
30
+
31
+ def initialize(login_url:)
32
+ @login_url = login_url
33
+ @cases = []
34
+ @started_at = Time.now
35
+ @finished_at = nil
36
+ end
37
+
38
+ # Record a finished case.
39
+ # @api private
40
+ def <<(kase)
41
+ @cases << kase
42
+ self
43
+ end
44
+
45
+ # Mark the run complete and record its duration.
46
+ # @api private
47
+ def finish!
48
+ @finished_at = Time.now
49
+ self
50
+ end
51
+
52
+ # @return [Array<Case>] cases that passed
53
+ def passed = @cases.select(&:passed?)
54
+ # @return [Array<Case>] cases that failed
55
+ def failed = @cases.select(&:failed?)
56
+ # @return [Array<Case>] cases that were skipped
57
+ def skipped = @cases.select(&:skipped?)
58
+
59
+ # True when nothing failed (skips do not count against the server).
60
+ def passed? = failed.empty?
61
+
62
+ # @return [Float, nil] seconds the run took
63
+ def duration = ((@finished_at || Time.now) - @started_at)
64
+
65
+ # @return [String] one-line totals
66
+ def summary
67
+ "#{passed.size} passed, #{failed.size} failed, #{skipped.size} skipped in #{duration.round(1)}s"
68
+ end
69
+
70
+ # @return [String] one line per case
71
+ def to_s
72
+ lines = ["SQRL conformance: #{@login_url}"]
73
+ lines.concat(@cases.map(&:to_s))
74
+ lines << summary
75
+ lines.join("\n")
76
+ end
77
+
78
+ # @return [Hash] the values as a plain Hash
79
+ def to_h
80
+ { login_url: @login_url, summary: summary, passed: passed?, cases: @cases.map(&:to_h) }
81
+ end
82
+
83
+ # JUnit XML, for CI dashboards.
84
+ def to_junit_xml
85
+ esc = ->(s) { s.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;") }
86
+ out = +%(<?xml version="1.0" encoding="UTF-8"?>\n)
87
+ out << %(<testsuite name="sqrl-conformance" tests="#{@cases.size}" failures="#{failed.size}" ) \
88
+ "skipped=\"#{skipped.size}\" time=\"#{duration.round(3)}\">\n"
89
+ @cases.each do |c|
90
+ out << %( <testcase classname="#{esc.call(c.group)}" name="#{esc.call(c.name)}" time="#{(c.duration || 0).round(3)}">)
91
+ out << %(<failure message="#{esc.call(c.message)}"/>) if c.failed?
92
+ out << %(<skipped message="#{esc.call(c.message)}"/>) if c.skipped?
93
+ out << "</testcase>\n"
94
+ end
95
+ out << "</testsuite>\n"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqrl/client"
4
+ require "sqrl/client/conformance"
5
+
6
+ # RSpec shared examples running the conformance suite case by case.
7
+ #
8
+ # require "sqrl/client/conformance/rspec"
9
+ #
10
+ # RSpec.describe "my SQRL site" do
11
+ # let(:login_url) { "http://127.0.0.1:9292/login" }
12
+ # let(:conformance_features) { { ask: false } } # optional
13
+ # let(:conformance_options) { { success_matcher: ... } } # optional Runner keywords
14
+ # it_behaves_like "a SQRL server"
15
+ # end
16
+ RSpec.shared_examples "a SQRL server" do
17
+ let(:conformance_features) { {} } unless method_defined?(:conformance_features)
18
+ let(:conformance_options) { {} } unless method_defined?(:conformance_options)
19
+ let(:conformance_runner) do
20
+ SQRL::Client::Conformance::Runner.new(login_url: login_url, features: conformance_features, **conformance_options)
21
+ end
22
+
23
+ SQRL::Client::Conformance::Runner::CASES.each do |name, (group, _feature)|
24
+ it "#{group}: #{name.to_s.tr("_", " ")}" do
25
+ kase = conformance_runner.run_case(name)
26
+ skip(kase.message) if kase.skipped?
27
+ raise kase.error || RSpec::Expectations::ExpectationNotMetError.new(kase.message) if kase.failed?
28
+ end
29
+ end
30
+ end