amritk-scalar-test 0.1.1
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 +7 -0
- data/LICENSE +201 -0
- data/README.md +125 -0
- data/SECURITY.md +8 -0
- data/SKILL.md +53 -0
- data/lib/amritk-scalar-test/client.rb +21 -0
- data/lib/amritk-scalar-test/errors.rb +89 -0
- data/lib/amritk-scalar-test/file_part.rb +14 -0
- data/lib/amritk-scalar-test/internal/cursor_page.rb +9 -0
- data/lib/amritk-scalar-test/models.rb +41 -0
- data/lib/amritk-scalar-test/request_options.rb +22 -0
- data/lib/amritk-scalar-test/resources/ping.rb +71 -0
- data/lib/amritk-scalar-test/runtime.rb +893 -0
- data/lib/amritk-scalar-test/version.rb +6 -0
- data/lib/amritk-scalar-test/webhooks.rb +111 -0
- data/lib/amritk-scalar-test.rb +8 -0
- data/tests/smoke-test.rb +69 -0
- metadata +59 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Code generated by Scalar SDK Generator. DO NOT EDIT.
|
|
4
|
+
require "base64"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
|
|
8
|
+
module ScalarRubyTest
|
|
9
|
+
module Webhooks
|
|
10
|
+
EVENT_NAMES = [].freeze
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WebhookEvent
|
|
14
|
+
attr_reader :type, :payload
|
|
15
|
+
|
|
16
|
+
def initialize(type:, payload:)
|
|
17
|
+
@type = type
|
|
18
|
+
@payload = payload
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class VerificationError < StandardError; end
|
|
23
|
+
|
|
24
|
+
def self.verify_signature(
|
|
25
|
+
payload, secret, signature_header,
|
|
26
|
+
algorithm: "hmac-sha256", public_key: nil,
|
|
27
|
+
timestamp: nil, tolerance: nil,
|
|
28
|
+
replay_store: nil, replay_id: nil, asymmetric_verifier: nil
|
|
29
|
+
)
|
|
30
|
+
return verify_asymmetric(payload, signature_header, public_key || secret.to_s, algorithm, asymmetric_verifier) unless algorithm == "hmac-sha256"
|
|
31
|
+
secrets = secret.is_a?(Array) ? secret : [secret]
|
|
32
|
+
raise VerificationError, "webhook secret is required" if secrets.empty? || secrets.first.to_s.empty?
|
|
33
|
+
if timestamp && tolerance && (Time.now.to_i - timestamp.to_i).abs > tolerance.to_i
|
|
34
|
+
raise VerificationError, "webhook timestamp is outside the allowed tolerance"
|
|
35
|
+
end
|
|
36
|
+
if replay_store
|
|
37
|
+
id = replay_id || signature_header
|
|
38
|
+
raise VerificationError, "webhook replay detected" if replay_store.include?(id)
|
|
39
|
+
replay_store << id if replay_store.respond_to?(:<<)
|
|
40
|
+
end
|
|
41
|
+
signature_candidates(signature_header).any? do |candidate|
|
|
42
|
+
decoded = decode_signature(candidate)
|
|
43
|
+
decoded && secrets.any? { |item| secure_compare(decoded, OpenSSL::HMAC.digest("SHA256", item.to_s, payload)) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.verify_rsa_sha256(payload, public_key, signature_header)
|
|
48
|
+
verify_asymmetric(payload, signature_header, public_key, "rsa-sha256", nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.verify_ecdsa_sha256(payload, public_key, signature_header)
|
|
52
|
+
verify_asymmetric(payload, signature_header, public_key, "ecdsa-sha256", nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.verify_ed25519(payload, public_key, signature_header, verifier)
|
|
56
|
+
verify_asymmetric(payload, signature_header, public_key, "ed25519", verifier)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.parse_event(payload, headers: {}, secret: nil, signature_header: "webhook-signature")
|
|
60
|
+
if secret
|
|
61
|
+
signature = header_value(headers, signature_header)
|
|
62
|
+
raise VerificationError, "missing webhook signature header" unless signature
|
|
63
|
+
raise VerificationError, "webhook signature verification failed" unless verify_signature(payload, secret, signature)
|
|
64
|
+
end
|
|
65
|
+
data = JSON.parse(payload)
|
|
66
|
+
type = data.is_a?(Hash) ? data["type"] : nil
|
|
67
|
+
raise VerificationError, "webhook payload is missing discriminator: type" unless type.is_a?(String)
|
|
68
|
+
raise VerificationError, "unknown webhook event type: #{type}" if EVENT_NAMES.any? && !EVENT_NAMES.include?(type)
|
|
69
|
+
WebhookEvent.new(type: type, payload: data)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.signature_candidates(header)
|
|
73
|
+
header.to_s.split(/[,\s]+/).map { |part| part.sub(/\A(sha256|v0|v1|sig|signature)=/i, "").strip }.reject { |part| part.empty? || part.match?(/\A(t|ts|timestamp)=/i) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.decode_signature(candidate)
|
|
77
|
+
return [candidate].pack("H*") if candidate.match?(/\A[0-9a-f]+\z/i) && candidate.length.even?
|
|
78
|
+
Base64.strict_decode64(candidate)
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.verify_asymmetric(payload, signature_header, public_key, algorithm, verifier)
|
|
84
|
+
raise VerificationError, "webhook public key is required" if public_key.to_s.empty?
|
|
85
|
+
signature_candidates(signature_header).any? do |candidate|
|
|
86
|
+
decoded = decode_signature(candidate)
|
|
87
|
+
next false unless decoded
|
|
88
|
+
return true if verifier&.call(payload, decoded, public_key, algorithm)
|
|
89
|
+
key = OpenSSL::PKey.read(public_key)
|
|
90
|
+
digest = OpenSSL::Digest.new("SHA256")
|
|
91
|
+
case algorithm
|
|
92
|
+
when "rsa-sha256", "ecdsa-sha256"
|
|
93
|
+
key.verify(digest, decoded, payload)
|
|
94
|
+
when "ed25519"
|
|
95
|
+
raise VerificationError, "Ed25519 verification requires a verifier callback"
|
|
96
|
+
else
|
|
97
|
+
raise VerificationError, "unsupported webhook signature algorithm: #{algorithm}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.secure_compare(left, right)
|
|
103
|
+
return false unless left.bytesize == right.bytesize
|
|
104
|
+
OpenSSL.fixed_length_secure_compare(left, right)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.header_value(headers, name)
|
|
108
|
+
headers.find { |key, _| key.to_s.downcase == name.downcase }&.last
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "amritk-scalar-test/version"
|
|
4
|
+
require_relative "amritk-scalar-test/request_options"
|
|
5
|
+
require_relative "amritk-scalar-test/file_part"
|
|
6
|
+
require_relative "amritk-scalar-test/internal/cursor_page"
|
|
7
|
+
require_relative "amritk-scalar-test/client"
|
|
8
|
+
require_relative "amritk-scalar-test/webhooks"
|
data/tests/smoke-test.rb
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Code generated by Scalar SDK Generator. DO NOT EDIT.
|
|
4
|
+
require "json"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
root = File.exist?(File.join(__dir__, "lib", "amritk-scalar-test.rb")) ? __dir__ : File.expand_path("..", __dir__)
|
|
9
|
+
$LOAD_PATH.unshift(File.join(root, "lib"))
|
|
10
|
+
require "amritk-scalar-test"
|
|
11
|
+
|
|
12
|
+
# Smoke test: calls every generated operation once to confirm the SDK can reach each endpoint.
|
|
13
|
+
# Run it from this repo with `ruby tests/smoke-test.rb`. The generator also runs this file
|
|
14
|
+
# against a mock server and reads the JSON report produced via SCALAR_SMOKE_REPORT.
|
|
15
|
+
client = ScalarRubyTest::Client.new(max_retries: 0, timeout: 30)
|
|
16
|
+
|
|
17
|
+
cases = [
|
|
18
|
+
{
|
|
19
|
+
operation: "ping",
|
|
20
|
+
method: "GET",
|
|
21
|
+
path: "/ping",
|
|
22
|
+
run: -> {
|
|
23
|
+
client.ping.ping
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
def run_case(test_case)
|
|
29
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
30
|
+
begin
|
|
31
|
+
test_case[:run].call
|
|
32
|
+
{ operation: test_case[:operation], method: test_case[:method], path: test_case[:path], status: "passed", durationMs: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).to_i }
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
{ operation: test_case[:operation], method: test_case[:method], path: test_case[:path], status: "failed", durationMs: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).to_i, error: ([e.class.name, e.message] + e.backtrace.to_a).join("\n") }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def probe_websocket(request)
|
|
39
|
+
socket = request.connect
|
|
40
|
+
ensure
|
|
41
|
+
socket&.close
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
filter = ENV["SCALAR_SMOKE_FILTER"]
|
|
45
|
+
needles = filter ? filter.split(",").map(&:strip).reject(&:empty?) : []
|
|
46
|
+
selected = needles.empty? ? cases : cases.select { |test_case| needles.any? { |needle| test_case[:operation].include?(needle) || test_case[:path].include?(needle) } }
|
|
47
|
+
|
|
48
|
+
results = selected.map do |test_case|
|
|
49
|
+
Thread.new(test_case) do |smoke_case|
|
|
50
|
+
run_case(smoke_case)
|
|
51
|
+
end
|
|
52
|
+
end.map(&:value)
|
|
53
|
+
|
|
54
|
+
failed = results.select { |result| result[:status] == "failed" }
|
|
55
|
+
if ENV["SCALAR_SMOKE_REPORT"]
|
|
56
|
+
File.write(ENV.fetch("SCALAR_SMOKE_REPORT"), JSON.generate({ total: results.length, failed: failed.length, results: results }))
|
|
57
|
+
else
|
|
58
|
+
results.each do |result|
|
|
59
|
+
if result[:status] == "passed"
|
|
60
|
+
puts "PASS #{result[:operation]} (#{result[:method]} #{result[:path]}) #{result[:durationMs]}ms"
|
|
61
|
+
else
|
|
62
|
+
warn "FAIL #{result[:operation]} (#{result[:method]} #{result[:path]})\n#{result[:error]}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
warn "No code samples ran (empty SDK or a SCALAR_SMOKE_FILTER that matched nothing)." if results.empty?
|
|
66
|
+
puts "\n#{results.length - failed.length}/#{results.length} samples passed" unless results.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
exit(1) if failed.any? || results.empty?
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: amritk-scalar-test
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Scalar SDK Generator
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- SECURITY.md
|
|
22
|
+
- SKILL.md
|
|
23
|
+
- lib/amritk-scalar-test.rb
|
|
24
|
+
- lib/amritk-scalar-test/client.rb
|
|
25
|
+
- lib/amritk-scalar-test/errors.rb
|
|
26
|
+
- lib/amritk-scalar-test/file_part.rb
|
|
27
|
+
- lib/amritk-scalar-test/internal/cursor_page.rb
|
|
28
|
+
- lib/amritk-scalar-test/models.rb
|
|
29
|
+
- lib/amritk-scalar-test/request_options.rb
|
|
30
|
+
- lib/amritk-scalar-test/resources/ping.rb
|
|
31
|
+
- lib/amritk-scalar-test/runtime.rb
|
|
32
|
+
- lib/amritk-scalar-test/version.rb
|
|
33
|
+
- lib/amritk-scalar-test/webhooks.rb
|
|
34
|
+
- tests/smoke-test.rb
|
|
35
|
+
homepage: https://github.com/amritk/scalar-ruby
|
|
36
|
+
licenses:
|
|
37
|
+
- Apache-2.0
|
|
38
|
+
metadata:
|
|
39
|
+
source_code_uri: https://github.com/amritk/scalar-ruby
|
|
40
|
+
post_install_message:
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.2'
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.4.19
|
|
56
|
+
signing_key:
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Minimal API used to test the generated Ruby SDK release pipeline.
|
|
59
|
+
test_files: []
|