doorflow 1.0.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f030947a7aa52576b9c64f55b185c888d43ff51c7257099a74db36608d2c14d3
4
- data.tar.gz: 94aba11ea4616046c3e92d8a0bc6a71f61e2dc22ef3d229d5de07a3ff5c1aff3
3
+ metadata.gz: 958e972d5b704e9dc612689508b92b2f889cd658db9987e12e073017a70a6942
4
+ data.tar.gz: c6aaa00b0644899890276825dbaca7022ee22ab92a1a70f35fb901ea9f07291f
5
5
  SHA512:
6
- metadata.gz: f1793b621c409beca25bf87f90ff5246c3e1930219f75e57a503d873bf720f00c73e5b46f84c39ae498702a23de3d931c4d652e325a8d5134b410df55e9fcecc
7
- data.tar.gz: a6488799e13203486b693ab13087489fedd35e998671133e5cb2956a761ede1f616bfe60a152493aa9f400135728e8929e517ac2d726c6cd9076d627f26dd7a6
6
+ metadata.gz: 6ab31e49a003ed6217abad77bb8084ec214f3f3bb75ab2fbbd2f508f67ebaeddfc37933049fc4baa62680ad3f8fd54f90b2343bc82952aa12ac434b343ad8331
7
+ data.tar.gz: 22a30992a4455c7743d63fda56169e9867b9c339be4abd190d8ebda48d6624d542d2191c63c6c41196474e5768629d93bf1669b96a80a6f5bb2986e61a06f0c1
data/README.md CHANGED
@@ -285,8 +285,8 @@ class WebhooksController < ApplicationController
285
285
  def create
286
286
  handler.handle(
287
287
  payload: request.raw_post,
288
- signature: request.headers['X-DoorFlow-Signature'],
289
- timestamp: request.headers['X-DoorFlow-Timestamp']
288
+ signature: request.headers['Signature'],
289
+ timestamp: request.headers['Timestamp']
290
290
  )
291
291
  head :ok
292
292
  rescue DoorFlow::Webhooks::SignatureError
@@ -35,10 +35,11 @@ module DoorFlow
35
35
  end
36
36
 
37
37
  # Retrieve a credential by ID
38
+ # @param person_id [Integer] Person ID (required by the API)
38
39
  # @param id [String, Integer] Credential ID
39
40
  # @return [DoorFlow::Resources::Credential]
40
- def retrieve(id)
41
- response = client.credentials_api.get_credential(id)
41
+ def retrieve(person_id, id)
42
+ response = client.credentials_api.get_credential(person_id, id)
42
43
  to_resource(response, Resources::Credential)
43
44
  end
44
45
 
@@ -56,14 +57,10 @@ module DoorFlow
56
57
 
57
58
  # Delete a credential
58
59
  # @param id [String, Integer] Credential ID
59
- # @param person_id [String, Integer, nil] Person ID (may be required by API)
60
+ # @param person_id [Integer] Person ID (required by the API)
60
61
  # @return [Boolean] true if successful
61
- def delete(id, person_id: nil)
62
- if person_id
63
- client.credentials_api.delete_credential(person_id, id)
64
- else
65
- client.credentials_api.delete_credential(id)
66
- end
62
+ def delete(id, person_id:)
63
+ client.credentials_api.delete_credential(person_id, id)
67
64
  true
68
65
  rescue => e
69
66
  false
@@ -0,0 +1,91 @@
1
+ module DoorFlow
2
+ class Client
3
+ # Base proxy for Element Sessions — handles the HTTP call
4
+ #
5
+ # @api private
6
+ class ElementSessionsProxy < ResourceProxy
7
+ def create_session(widget_type:, member:, credentials: nil)
8
+ params = {
9
+ widget_type: widget_type,
10
+ member: {
11
+ external_id: member[:external_id],
12
+ name: member[:name],
13
+ email: member[:email]
14
+ }
15
+ }
16
+ params[:credentials] = credentials if credentials
17
+ response = post("/api/3/element_sessions", params)
18
+ to_resource(response, Resources::ElementSession)
19
+ end
20
+
21
+ private
22
+
23
+ def post(path, params)
24
+ api_client = client.send(:api_client)
25
+ config = api_client.config
26
+
27
+ url = "#{config.scheme}://#{config.host}#{path}"
28
+ token = client.access_token
29
+
30
+ conn = Faraday.new do |f|
31
+ f.request :json
32
+ f.response :json, parser_options: { symbolize_names: true }
33
+ f.adapter Faraday.default_adapter
34
+ end
35
+
36
+ response = conn.post(url) do |req|
37
+ req.headers["Authorization"] = "Bearer #{token}"
38
+ req.headers["Content-Type"] = "application/json"
39
+ req.body = params.to_json
40
+ end
41
+
42
+ unless response.success?
43
+ raise DoorFlow::ApiError.new(
44
+ code: response.status,
45
+ response_body: response.body
46
+ ), "Element session creation failed (#{response.status})"
47
+ end
48
+
49
+ response.body
50
+ end
51
+ end
52
+
53
+ # Proxy for creating admin widget sessions
54
+ #
55
+ # @example
56
+ # session = client.admin_sessions.create(
57
+ # member: {
58
+ # external_id: "usr_123",
59
+ # name: "Alice Anderson",
60
+ # email: "alice@example.com"
61
+ # },
62
+ # credentials: ["mobile_access", "pin"]
63
+ # )
64
+ # session.token #=> "eyJhbGciOiJIUzI1NiJ9..."
65
+ # session.expires_in #=> 900
66
+ class AdminSessionsProxy < ElementSessionsProxy
67
+ def create(member:, credentials: nil)
68
+ create_session(widget_type: "admin", member: member, credentials: credentials)
69
+ end
70
+ end
71
+
72
+ # Proxy for creating member widget sessions
73
+ #
74
+ # @example
75
+ # session = client.member_sessions.create(
76
+ # member: {
77
+ # external_id: "usr_123",
78
+ # name: "Alice Anderson",
79
+ # email: "alice@example.com"
80
+ # },
81
+ # credentials: ["mobile_access", "pin"]
82
+ # )
83
+ # session.token #=> "eyJhbGciOiJIUzI1NiJ9..."
84
+ # session.expires_in #=> 900
85
+ class MemberSessionsProxy < ElementSessionsProxy
86
+ def create(member:, credentials: nil)
87
+ create_session(widget_type: "member", member: member, credentials: credentials)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -129,6 +129,18 @@ module DoorFlow
129
129
  @account ||= AccountProxy.new(self)
130
130
  end
131
131
 
132
+ # Access admin widget sessions
133
+ # @return [DoorFlow::Client::AdminSessionsProxy] admin sessions resource proxy
134
+ def admin_sessions
135
+ @admin_sessions ||= AdminSessionsProxy.new(self)
136
+ end
137
+
138
+ # Access member widget sessions
139
+ # @return [DoorFlow::Client::MemberSessionsProxy] member sessions resource proxy
140
+ def member_sessions
141
+ @member_sessions ||= MemberSessionsProxy.new(self)
142
+ end
143
+
132
144
  # ============================================
133
145
  # Low-level API accessors (for advanced use)
134
146
  # ============================================
@@ -261,3 +273,4 @@ require_relative 'client/reservations_proxy'
261
273
  require_relative 'client/group_reservations_proxy'
262
274
  require_relative 'client/notification_rules_proxy'
263
275
  require_relative 'client/account_proxy'
276
+ require_relative 'client/element_sessions_proxy'
@@ -52,7 +52,7 @@ module DoorFlow
52
52
  # Format error for display
53
53
  # @return [String]
54
54
  def to_s
55
- parts = [message]
55
+ parts = [super]
56
56
  parts << "HTTP #{status}" if status
57
57
  parts << "Code: #{code}" if code
58
58
  parts << "Request ID: #{request_id}" if request_id
@@ -27,10 +27,11 @@ module DoorFlow
27
27
  end
28
28
 
29
29
  # Retrieve a credential by ID
30
+ # @param person_id [Integer] person ID (required by the API)
30
31
  # @param id [String] credential ID
31
32
  # @return [DoorFlow::Credential] the credential
32
- def self.retrieve(id)
33
- response = client.credentials_api.get_credential(id)
33
+ def self.retrieve(person_id, id)
34
+ response = client.credentials_api.get_credential(person_id, id)
34
35
  from_api_model(response)
35
36
  end
36
37
 
@@ -63,7 +64,7 @@ module DoorFlow
63
64
  # Delete this credential
64
65
  # @return [Boolean] true if successful
65
66
  def delete
66
- _client.credentials_api.delete_credential(@id)
67
+ _client.credentials_api.delete_credential(@person_id, @id)
67
68
  true
68
69
  rescue
69
70
  false
@@ -0,0 +1,28 @@
1
+ module DoorFlow
2
+ module Resources
3
+ # Represents a DoorFlow Elements session token
4
+ #
5
+ # Element sessions provide short-lived JWT tokens for embedding
6
+ # DoorFlow widgets (admin or member) in your application via iframes.
7
+ #
8
+ # @example Create an admin session
9
+ # client = DoorFlow::Client.new(access_token: 'your_token')
10
+ # session = client.element_sessions.create(
11
+ # person_id: 123,
12
+ # widget_type: 'admin'
13
+ # )
14
+ # session.token #=> "eyJhbGciOiJIUzI1NiJ9..."
15
+ # session.expires_in #=> 900
16
+ class ElementSession < Resource
17
+ # @return [String] the JWT session token
18
+ def token
19
+ @attributes[:token]
20
+ end
21
+
22
+ # @return [Integer] seconds until the token expires
23
+ def expires_in
24
+ @attributes[:expires_in]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -27,8 +27,8 @@ module DoorFlow
27
27
  # # In your controller:
28
28
  # handler.handle(
29
29
  # payload: request.raw_post,
30
- # signature: request.headers['X-DoorFlow-Signature'],
31
- # timestamp: request.headers['X-DoorFlow-Timestamp']
30
+ # signature: request.headers['Signature'],
31
+ # timestamp: request.headers['Timestamp']
32
32
  # )
33
33
  #
34
34
  # @example With auto-fetch
@@ -82,8 +82,8 @@ module DoorFlow
82
82
  # Process an incoming webhook request
83
83
  #
84
84
  # @param payload [String] Raw request body
85
- # @param signature [String] X-DoorFlow-Signature header
86
- # @param timestamp [String] X-DoorFlow-Timestamp header
85
+ # @param signature [String] Signature header
86
+ # @param timestamp [String] Timestamp header
87
87
  # @return [Array<Event>] Processed events
88
88
  # @raise [SignatureError] If signature verification fails
89
89
  def handle(payload:, signature:, timestamp:)
@@ -108,8 +108,8 @@ module DoorFlow
108
108
  def handle_request(request)
109
109
  handle(
110
110
  payload: extract_body(request),
111
- signature: extract_header(request, 'X-DoorFlow-Signature'),
112
- timestamp: extract_header(request, 'X-DoorFlow-Timestamp')
111
+ signature: extract_header(request, 'Signature'),
112
+ timestamp: extract_header(request, 'Timestamp')
113
113
  )
114
114
  end
115
115
 
@@ -20,8 +20,8 @@ module DoorFlow
20
20
  # @example Verifying a webhook
21
21
  # events = DoorFlow::Webhooks::SignatureVerifier.verify(
22
22
  # payload: request.raw_post,
23
- # signature: request.headers['X-DoorFlow-Signature'],
24
- # timestamp: request.headers['X-DoorFlow-Timestamp'],
23
+ # signature: request.headers['Signature'],
24
+ # timestamp: request.headers['Timestamp'],
25
25
  # secret: ENV['DOORFLOW_WEBHOOK_SECRET']
26
26
  # )
27
27
  #
@@ -36,8 +36,8 @@ module DoorFlow
36
36
  # Verify webhook signature and parse events
37
37
  #
38
38
  # @param payload [String] Raw request body
39
- # @param signature [String] X-DoorFlow-Signature header value
40
- # @param timestamp [String] X-DoorFlow-Timestamp header value
39
+ # @param signature [String] Signature header value
40
+ # @param timestamp [String] Timestamp header value
41
41
  # @param secret [String] Webhook secret from DoorFlow dashboard
42
42
  # @param tolerance [Integer] Maximum age in seconds (0 to disable)
43
43
  # @return [Array<Event>] Parsed webhook events
@@ -109,7 +109,10 @@ module DoorFlow
109
109
 
110
110
  def parse_events(payload)
111
111
  data = JSON.parse(payload, symbolize_names: true)
112
- events = data[:events] || []
112
+ events = case data
113
+ when Array then data
114
+ else data[:events] || [data]
115
+ end
113
116
  events.map { |e| Event.new(e) }
114
117
  rescue JSON::ParserError => e
115
118
  raise SignatureError.new("Invalid JSON payload: #{e.message}")
@@ -11,5 +11,5 @@ Generator version: 7.18.0-SNAPSHOT
11
11
  =end
12
12
 
13
13
  module DoorFlow
14
- VERSION = '1.0.0'
14
+ VERSION = '1.0.1'
15
15
  end
data/lib/doorflow.rb CHANGED
@@ -28,6 +28,7 @@ require 'doorflow/resources/person'
28
28
  require 'doorflow/resources/reservation'
29
29
  require 'doorflow/resources/role'
30
30
  require 'doorflow/resources/site'
31
+ require 'doorflow/resources/element_session'
31
32
 
32
33
  # DoorFlow Ruby SDK for access control and door management
33
34
  #
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe DoorFlow::Webhooks::Event do
6
+ let(:event_data) do
7
+ {
8
+ action: 'CREATE',
9
+ resource: 'https://api.doorflow.com/api/v1/accounts/1/events/42',
10
+ resource_type: 'Event',
11
+ resource_id: 42,
12
+ account_id: 1,
13
+ ack_token: 'abc123'
14
+ }
15
+ end
16
+
17
+ subject(:event) { described_class.new(event_data) }
18
+
19
+ describe '#initialize' do
20
+ it 'sets all attributes from the data hash' do
21
+ expect(event.action).to eq('CREATE')
22
+ expect(event.resource_url).to eq('https://api.doorflow.com/api/v1/accounts/1/events/42')
23
+ expect(event.resource_type).to eq('Event')
24
+ expect(event.resource_id).to eq('42')
25
+ expect(event.account_id).to eq(1)
26
+ expect(event.ack_token).to eq('abc123')
27
+ end
28
+
29
+ it 'converts resource_id to string' do
30
+ expect(event.resource_id).to be_a(String)
31
+ end
32
+
33
+ it 'handles nil resource_id' do
34
+ data = event_data.merge(resource_id: nil)
35
+ event = described_class.new(data)
36
+ expect(event.resource_id).to be_nil
37
+ end
38
+ end
39
+
40
+ describe '#pattern' do
41
+ it 'returns ResourceType.ACTION format' do
42
+ expect(event.pattern).to eq('Event.CREATE')
43
+ end
44
+
45
+ it 'handles different resource types' do
46
+ data = event_data.merge(resource_type: 'PersonCredential', action: 'UPDATE')
47
+ event = described_class.new(data)
48
+ expect(event.pattern).to eq('PersonCredential.UPDATE')
49
+ end
50
+ end
51
+
52
+ describe '#create?' do
53
+ it 'returns true for CREATE action' do
54
+ expect(event.create?).to be true
55
+ end
56
+
57
+ it 'returns false for other actions' do
58
+ data = event_data.merge(action: 'DELETE')
59
+ expect(described_class.new(data).create?).to be false
60
+ end
61
+ end
62
+
63
+ describe '#update?' do
64
+ it 'returns true for UPDATE action' do
65
+ data = event_data.merge(action: 'UPDATE')
66
+ expect(described_class.new(data).update?).to be true
67
+ end
68
+
69
+ it 'returns false for other actions' do
70
+ expect(event.update?).to be false
71
+ end
72
+ end
73
+
74
+ describe '#delete?' do
75
+ it 'returns true for DELETE action' do
76
+ data = event_data.merge(action: 'DELETE')
77
+ expect(described_class.new(data).delete?).to be true
78
+ end
79
+
80
+ it 'returns false for other actions' do
81
+ expect(event.delete?).to be false
82
+ end
83
+ end
84
+
85
+ describe '#to_h' do
86
+ it 'returns a hash with all attributes' do
87
+ expected = {
88
+ action: 'CREATE',
89
+ resource: 'https://api.doorflow.com/api/v1/accounts/1/events/42',
90
+ resource_type: 'Event',
91
+ resource_id: '42',
92
+ account_id: 1,
93
+ ack_token: 'abc123'
94
+ }
95
+ expect(event.to_h).to eq(expected)
96
+ end
97
+
98
+ it 'round-trips through Event.new' do
99
+ reconstructed = described_class.new(event.to_h)
100
+ expect(reconstructed.to_h).to eq(event.to_h)
101
+ end
102
+ end
103
+
104
+ describe '#to_s' do
105
+ it 'includes pattern and resource_id' do
106
+ expect(event.to_s).to eq('Event.CREATE (42)')
107
+ end
108
+ end
109
+
110
+ describe '#inspect' do
111
+ it 'includes class name, pattern, and resource_id' do
112
+ expect(event.inspect).to include('DoorFlow::Webhooks::Event')
113
+ expect(event.inspect).to include('Event.CREATE')
114
+ expect(event.inspect).to include('42')
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative 'support'
5
+
6
+ RSpec.describe DoorFlow::Webhooks::Handler do
7
+ include WebhookSpecSupport
8
+
9
+ let(:secret) { WebhookSpecSupport::WEBHOOK_SECRET }
10
+ let(:timestamp) { Time.now.to_i.to_s }
11
+ let(:event_hash) { WebhookSpecSupport::SAMPLE_EVENT_HASH }
12
+ let(:payload) { JSON.generate([event_hash]) }
13
+
14
+ let(:signature) { compute_signature(timestamp, payload, secret) }
15
+
16
+ subject(:handler) { described_class.new(secret: secret) }
17
+
18
+ describe '#on' do
19
+ it 'returns self for method chaining' do
20
+ result = handler.on('Event.CREATE') { |e| }
21
+ expect(result).to be(handler)
22
+ end
23
+
24
+ it 'supports chained registrations' do
25
+ result = handler
26
+ .on('Event.CREATE') { |e| }
27
+ .on('Person.UPDATE') { |e| }
28
+ .on('*') { |e| }
29
+
30
+ expect(result).to be(handler)
31
+ end
32
+
33
+ it 'raises ArgumentError without a block' do
34
+ expect { handler.on('Event.CREATE') }.to raise_error(ArgumentError, /Block required/)
35
+ end
36
+ end
37
+
38
+ describe '#handle' do
39
+ it 'dispatches events to matching handlers' do
40
+ received = []
41
+ handler.on('Event.CREATE') { |event| received << event }
42
+
43
+ handler.handle(payload: payload, signature: signature, timestamp: timestamp)
44
+
45
+ expect(received.length).to eq(1)
46
+ expect(received.first.pattern).to eq('Event.CREATE')
47
+ end
48
+
49
+ it 'does not dispatch to non-matching handlers' do
50
+ received = []
51
+ handler.on('Person.UPDATE') { |event| received << event }
52
+
53
+ handler.handle(payload: payload, signature: signature, timestamp: timestamp)
54
+
55
+ expect(received).to be_empty
56
+ end
57
+
58
+ it 'dispatches to wildcard handler' do
59
+ received = []
60
+ handler.on('*') { |event| received << event }
61
+
62
+ handler.handle(payload: payload, signature: signature, timestamp: timestamp)
63
+
64
+ expect(received.length).to eq(1)
65
+ end
66
+
67
+ it 'dispatches to resource wildcard handler' do
68
+ received = []
69
+ handler.on('Event.*') { |event| received << event }
70
+
71
+ handler.handle(payload: payload, signature: signature, timestamp: timestamp)
72
+
73
+ expect(received.length).to eq(1)
74
+ end
75
+
76
+ it 'does not match wrong resource wildcard' do
77
+ received = []
78
+ handler.on('Person.*') { |event| received << event }
79
+
80
+ handler.handle(payload: payload, signature: signature, timestamp: timestamp)
81
+
82
+ expect(received).to be_empty
83
+ end
84
+
85
+ it 'dispatches to multiple matching handlers' do
86
+ exact = []
87
+ wildcard = []
88
+ handler.on('Event.CREATE') { |event| exact << event }
89
+ handler.on('*') { |event| wildcard << event }
90
+
91
+ handler.handle(payload: payload, signature: signature, timestamp: timestamp)
92
+
93
+ expect(exact.length).to eq(1)
94
+ expect(wildcard.length).to eq(1)
95
+ end
96
+
97
+ it 'returns the parsed events' do
98
+ events = handler.handle(payload: payload, signature: signature, timestamp: timestamp)
99
+
100
+ expect(events).to be_an(Array)
101
+ expect(events.length).to eq(1)
102
+ expect(events.first).to be_a(DoorFlow::Webhooks::Event)
103
+ end
104
+ end
105
+
106
+ describe '#handle_request' do
107
+ context 'with a Rails-style request' do
108
+ it 'extracts Signature and Timestamp headers' do
109
+ headers = {
110
+ 'Signature' => signature,
111
+ 'Timestamp' => timestamp
112
+ }
113
+ request = double('rails_request',
114
+ raw_post: payload,
115
+ headers: headers
116
+ )
117
+
118
+ received = []
119
+ handler.on('Event.CREATE') { |event| received << event }
120
+ handler.handle_request(request)
121
+
122
+ expect(received.length).to eq(1)
123
+ expect(received.first.pattern).to eq('Event.CREATE')
124
+ end
125
+ end
126
+
127
+ context 'with a Rack-style request' do
128
+ it 'extracts headers from the env hash' do
129
+ body = double('body')
130
+ allow(body).to receive(:read).and_return(payload)
131
+
132
+ request = double('rack_request',
133
+ body: body,
134
+ env: {
135
+ 'HTTP_SIGNATURE' => signature,
136
+ 'HTTP_TIMESTAMP' => timestamp
137
+ }
138
+ )
139
+ # Rack request doesn't respond to raw_post or headers
140
+ allow(request).to receive(:respond_to?).with(:raw_post).and_return(false)
141
+ allow(request).to receive(:respond_to?).with(:body).and_return(true)
142
+ allow(request).to receive(:respond_to?).with(:headers).and_return(false)
143
+ allow(request).to receive(:respond_to?).with(:env).and_return(true)
144
+
145
+ received = []
146
+ handler.on('Event.CREATE') { |event| received << event }
147
+ handler.handle_request(request)
148
+
149
+ expect(received.length).to eq(1)
150
+ expect(received.first.pattern).to eq('Event.CREATE')
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative 'support'
5
+
6
+ RSpec.describe DoorFlow::Webhooks::SignatureVerifier do
7
+ include WebhookSpecSupport
8
+
9
+ let(:secret) { WebhookSpecSupport::WEBHOOK_SECRET }
10
+ let(:timestamp) { Time.now.to_i.to_s }
11
+ let(:event_hash) { WebhookSpecSupport::SAMPLE_EVENT_HASH }
12
+
13
+ describe '.verify' do
14
+ context 'with a root-level array payload (documented format)' do
15
+ let(:payload) { JSON.generate([event_hash]) }
16
+ let(:signature) { compute_signature(timestamp, payload, secret) }
17
+
18
+ it 'returns an array of Event objects' do
19
+ events = described_class.verify(
20
+ payload: payload,
21
+ signature: signature,
22
+ timestamp: timestamp,
23
+ secret: secret
24
+ )
25
+
26
+ expect(events).to be_an(Array)
27
+ expect(events.length).to eq(1)
28
+ expect(events.first).to be_a(DoorFlow::Webhooks::Event)
29
+ expect(events.first.resource_type).to eq('Event')
30
+ expect(events.first.action).to eq('CREATE')
31
+ end
32
+ end
33
+
34
+ context 'with multiple events in an array' do
35
+ let(:second_event) do
36
+ {
37
+ action: 'UPDATE',
38
+ resource: 'https://api.doorflow.com/api/v1/accounts/1/people/99',
39
+ resource_type: 'Person',
40
+ resource_id: 99,
41
+ account_id: 1,
42
+ ack_token: 'def456'
43
+ }
44
+ end
45
+ let(:payload) { JSON.generate([event_hash, second_event]) }
46
+ let(:signature) { compute_signature(timestamp, payload, secret) }
47
+
48
+ it 'returns all events' do
49
+ events = described_class.verify(
50
+ payload: payload,
51
+ signature: signature,
52
+ timestamp: timestamp,
53
+ secret: secret
54
+ )
55
+
56
+ expect(events.length).to eq(2)
57
+ expect(events[0].pattern).to eq('Event.CREATE')
58
+ expect(events[1].pattern).to eq('Person.UPDATE')
59
+ end
60
+ end
61
+
62
+ context 'with a single event object payload' do
63
+ let(:payload) { JSON.generate(event_hash) }
64
+ let(:signature) { compute_signature(timestamp, payload, secret) }
65
+
66
+ it 'wraps the single event in an array' do
67
+ events = described_class.verify(
68
+ payload: payload,
69
+ signature: signature,
70
+ timestamp: timestamp,
71
+ secret: secret
72
+ )
73
+
74
+ expect(events.length).to eq(1)
75
+ expect(events.first.pattern).to eq('Event.CREATE')
76
+ end
77
+ end
78
+
79
+ context 'with an events-key payload (backwards compat)' do
80
+ let(:payload) { JSON.generate({ events: [event_hash] }) }
81
+ let(:signature) { compute_signature(timestamp, payload, secret) }
82
+
83
+ it 'extracts events from the events key' do
84
+ events = described_class.verify(
85
+ payload: payload,
86
+ signature: signature,
87
+ timestamp: timestamp,
88
+ secret: secret
89
+ )
90
+
91
+ expect(events.length).to eq(1)
92
+ expect(events.first.pattern).to eq('Event.CREATE')
93
+ end
94
+ end
95
+
96
+ context 'with an invalid signature' do
97
+ let(:payload) { JSON.generate([event_hash]) }
98
+
99
+ it 'raises SignatureError' do
100
+ expect {
101
+ described_class.verify(
102
+ payload: payload,
103
+ signature: 'invalid_signature_value',
104
+ timestamp: timestamp,
105
+ secret: secret
106
+ )
107
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Signature mismatch/)
108
+ end
109
+ end
110
+
111
+ context 'with an expired timestamp' do
112
+ let(:old_timestamp) { (Time.now.to_i - 600).to_s }
113
+ let(:payload) { JSON.generate([event_hash]) }
114
+ let(:signature) { compute_signature(old_timestamp, payload, secret) }
115
+
116
+ it 'raises SignatureError' do
117
+ expect {
118
+ described_class.verify(
119
+ payload: payload,
120
+ signature: signature,
121
+ timestamp: old_timestamp,
122
+ secret: secret
123
+ )
124
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /too old/)
125
+ end
126
+ end
127
+
128
+ context 'with tolerance disabled' do
129
+ let(:old_timestamp) { (Time.now.to_i - 600).to_s }
130
+ let(:payload) { JSON.generate([event_hash]) }
131
+ let(:signature) { compute_signature(old_timestamp, payload, secret) }
132
+
133
+ it 'does not check timestamp' do
134
+ events = described_class.verify(
135
+ payload: payload,
136
+ signature: signature,
137
+ timestamp: old_timestamp,
138
+ secret: secret,
139
+ tolerance: 0
140
+ )
141
+
142
+ expect(events.length).to eq(1)
143
+ end
144
+ end
145
+
146
+ context 'with missing inputs' do
147
+ let(:payload) { JSON.generate([event_hash]) }
148
+ let(:signature) { compute_signature(timestamp, payload, secret) }
149
+
150
+ it 'raises SignatureError for missing payload' do
151
+ expect {
152
+ described_class.verify(payload: nil, signature: signature, timestamp: timestamp, secret: secret)
153
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Missing payload/)
154
+ end
155
+
156
+ it 'raises SignatureError for empty payload' do
157
+ expect {
158
+ described_class.verify(payload: '', signature: signature, timestamp: timestamp, secret: secret)
159
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Missing payload/)
160
+ end
161
+
162
+ it 'raises SignatureError for missing signature' do
163
+ expect {
164
+ described_class.verify(payload: payload, signature: nil, timestamp: timestamp, secret: secret)
165
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Missing signature/)
166
+ end
167
+
168
+ it 'raises SignatureError for missing timestamp' do
169
+ expect {
170
+ described_class.verify(payload: payload, signature: signature, timestamp: nil, secret: secret)
171
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Missing timestamp/)
172
+ end
173
+
174
+ it 'raises SignatureError for missing secret' do
175
+ expect {
176
+ described_class.verify(payload: payload, signature: signature, timestamp: timestamp, secret: nil)
177
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Missing secret/)
178
+ end
179
+ end
180
+
181
+ context 'with malformed JSON' do
182
+ let(:payload) { 'not valid json{{{' }
183
+ let(:signature) { compute_signature(timestamp, payload, secret) }
184
+
185
+ it 'raises SignatureError' do
186
+ expect {
187
+ described_class.verify(
188
+ payload: payload,
189
+ signature: signature,
190
+ timestamp: timestamp,
191
+ secret: secret
192
+ )
193
+ }.to raise_error(DoorFlow::Webhooks::SignatureError, /Invalid JSON/)
194
+ end
195
+ end
196
+ end
197
+
198
+ describe '.compute_signature' do
199
+ it 'returns a hex-encoded HMAC-SHA256 digest' do
200
+ sig = described_class.compute_signature('12345', '{"test":true}', 'secret')
201
+ expect(sig).to match(/\A[a-f0-9]{64}\z/)
202
+ end
203
+
204
+ it 'uses timestamp.payload format' do
205
+ expected = OpenSSL::HMAC.hexdigest('SHA256', 'secret', '12345.{"test":true}')
206
+ sig = described_class.compute_signature('12345', '{"test":true}', 'secret')
207
+ expect(sig).to eq(expected)
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebhookSpecSupport
4
+ WEBHOOK_SECRET = 'whsec_test_secret_key'
5
+
6
+ SAMPLE_EVENT_HASH = {
7
+ action: 'CREATE',
8
+ resource: 'https://api.doorflow.com/api/v1/accounts/1/events/42',
9
+ resource_type: 'Event',
10
+ resource_id: 42,
11
+ account_id: 1,
12
+ ack_token: 'abc123'
13
+ }.freeze
14
+
15
+ def compute_signature(timestamp, payload, secret)
16
+ OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{payload}")
17
+ end
18
+ end
@@ -0,0 +1,115 @@
1
+ require 'spec_helper'
2
+
3
+ # Regression specs for bugs reported by William:
4
+ # - retrieve and delete were calling get_credential / delete_credential
5
+ # with only (id), omitting the required person_id argument.
6
+ # - CredentialsProxy#delete had a dead else-branch that always failed silently.
7
+ RSpec.describe 'Credential person_id argument fixes' do
8
+ let(:person_id) { 456 }
9
+ let(:credential_id) { 'abc123' }
10
+
11
+ let(:credential_response) do
12
+ double('CredentialResponse',
13
+ to_hash: { 'id' => credential_id, 'person_id' => person_id,
14
+ 'credential_type_id' => 1, 'label' => 'Card' }
15
+ )
16
+ end
17
+
18
+ let(:mock_credentials_api) { double('CredentialsApi') }
19
+ let(:mock_client) { double('Client', credentials_api: mock_credentials_api) }
20
+
21
+ # ──────────────────────────────────────────────────────────────────────────────
22
+ # DoorFlow::Resources::Credential.retrieve
23
+ # ──────────────────────────────────────────────────────────────────────────────
24
+ describe 'DoorFlow::Resources::Credential.retrieve' do
25
+ before { allow(DoorFlow::Resources::Credential).to receive(:client).and_return(mock_client) }
26
+
27
+ it 'passes person_id and id to get_credential' do
28
+ expect(mock_credentials_api).to receive(:get_credential)
29
+ .with(person_id, credential_id)
30
+ .and_return(credential_response)
31
+
32
+ DoorFlow::Resources::Credential.retrieve(person_id, credential_id)
33
+ end
34
+
35
+ it 'returns a Credential resource' do
36
+ allow(mock_credentials_api).to receive(:get_credential)
37
+ .with(person_id, credential_id)
38
+ .and_return(credential_response)
39
+
40
+ result = DoorFlow::Resources::Credential.retrieve(person_id, credential_id)
41
+ expect(result).to be_a(DoorFlow::Resources::Credential)
42
+ end
43
+ end
44
+
45
+ # ──────────────────────────────────────────────────────────────────────────────
46
+ # DoorFlow::Resources::Credential#delete
47
+ # ──────────────────────────────────────────────────────────────────────────────
48
+ describe 'DoorFlow::Resources::Credential#delete' do
49
+ let(:credential) do
50
+ DoorFlow::Resources::Credential.new(
51
+ { 'id' => credential_id, 'person_id' => person_id },
52
+ client: mock_client
53
+ )
54
+ end
55
+
56
+ it 'passes person_id and id to delete_credential' do
57
+ expect(mock_credentials_api).to receive(:delete_credential)
58
+ .with(person_id, credential_id)
59
+ .and_return(nil)
60
+
61
+ credential.delete
62
+ end
63
+
64
+ it 'returns true on success' do
65
+ allow(mock_credentials_api).to receive(:delete_credential)
66
+ .with(person_id, credential_id)
67
+ .and_return(nil)
68
+
69
+ expect(credential.delete).to eq(true)
70
+ end
71
+ end
72
+
73
+ # ──────────────────────────────────────────────────────────────────────────────
74
+ # DoorFlow::Client::CredentialsProxy#retrieve
75
+ # ──────────────────────────────────────────────────────────────────────────────
76
+ describe 'DoorFlow::Client::CredentialsProxy#retrieve' do
77
+ let(:proxy) { DoorFlow::Client::CredentialsProxy.new(mock_client) }
78
+
79
+ it 'passes person_id and id to get_credential' do
80
+ expect(mock_credentials_api).to receive(:get_credential)
81
+ .with(person_id, credential_id)
82
+ .and_return(credential_response)
83
+
84
+ proxy.retrieve(person_id, credential_id)
85
+ end
86
+
87
+ it 'returns a Credential resource' do
88
+ allow(mock_credentials_api).to receive(:get_credential)
89
+ .with(person_id, credential_id)
90
+ .and_return(credential_response)
91
+
92
+ result = proxy.retrieve(person_id, credential_id)
93
+ expect(result).to be_a(DoorFlow::Resources::Credential)
94
+ end
95
+ end
96
+
97
+ # ──────────────────────────────────────────────────────────────────────────────
98
+ # DoorFlow::Client::CredentialsProxy#delete
99
+ # ──────────────────────────────────────────────────────────────────────────────
100
+ describe 'DoorFlow::Client::CredentialsProxy#delete' do
101
+ let(:proxy) { DoorFlow::Client::CredentialsProxy.new(mock_client) }
102
+
103
+ it 'passes person_id and id to delete_credential and returns true' do
104
+ expect(mock_credentials_api).to receive(:delete_credential)
105
+ .with(person_id, credential_id)
106
+ .and_return(nil)
107
+
108
+ expect(proxy.delete(credential_id, person_id: person_id)).to eq(true)
109
+ end
110
+
111
+ it 'raises ArgumentError when person_id is not supplied' do
112
+ expect { proxy.delete(credential_id) }.to raise_error(ArgumentError)
113
+ end
114
+ end
115
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: doorflow
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NetNodes Ltd
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-05 00:00:00.000000000 Z
10
+ date: 2026-02-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: faraday
@@ -282,6 +282,7 @@ files:
282
282
  - lib/doorflow/client/channels_proxy.rb
283
283
  - lib/doorflow/client/credential_types_proxy.rb
284
284
  - lib/doorflow/client/credentials_proxy.rb
285
+ - lib/doorflow/client/element_sessions_proxy.rb
285
286
  - lib/doorflow/client/events_proxy.rb
286
287
  - lib/doorflow/client/group_reservations_proxy.rb
287
288
  - lib/doorflow/client/groups_proxy.rb
@@ -305,6 +306,7 @@ files:
305
306
  - lib/doorflow/resources/channel.rb
306
307
  - lib/doorflow/resources/credential.rb
307
308
  - lib/doorflow/resources/credential_type.rb
309
+ - lib/doorflow/resources/element_session.rb
308
310
  - lib/doorflow/resources/event.rb
309
311
  - lib/doorflow/resources/group.rb
310
312
  - lib/doorflow/resources/group_reservation.rb
@@ -323,6 +325,10 @@ files:
323
325
  - spec/api/group_reservations_api_spec.rb
324
326
  - spec/api/oauth_api_spec.rb
325
327
  - spec/doorflow/client_spec.rb
328
+ - spec/doorflow/webhooks/event_spec.rb
329
+ - spec/doorflow/webhooks/handler_spec.rb
330
+ - spec/doorflow/webhooks/signature_verifier_spec.rb
331
+ - spec/doorflow/webhooks/support.rb
326
332
  - spec/doorflow_spec.rb
327
333
  - spec/fixtures/vcr_cassettes/channel/list.yml
328
334
  - spec/fixtures/vcr_cassettes/channel/retrieve.yml
@@ -390,6 +396,7 @@ files:
390
396
  - spec/models/revoke_token403_response_spec.rb
391
397
  - spec/models/site_site_ips_inner_spec.rb
392
398
  - spec/models/unlock_channel_request_spec.rb
399
+ - spec/resources/credential_bugs_spec.rb
393
400
  - spec/spec_helper.rb
394
401
  homepage: https://www.doorflow.com
395
402
  licenses:
@@ -425,6 +432,10 @@ test_files:
425
432
  - spec/api/group_reservations_api_spec.rb
426
433
  - spec/api/oauth_api_spec.rb
427
434
  - spec/doorflow/client_spec.rb
435
+ - spec/doorflow/webhooks/event_spec.rb
436
+ - spec/doorflow/webhooks/handler_spec.rb
437
+ - spec/doorflow/webhooks/signature_verifier_spec.rb
438
+ - spec/doorflow/webhooks/support.rb
428
439
  - spec/doorflow_spec.rb
429
440
  - spec/fixtures/vcr_cassettes/channel/list.yml
430
441
  - spec/fixtures/vcr_cassettes/channel/retrieve.yml
@@ -492,4 +503,5 @@ test_files:
492
503
  - spec/models/revoke_token403_response_spec.rb
493
504
  - spec/models/site_site_ips_inner_spec.rb
494
505
  - spec/models/unlock_channel_request_spec.rb
506
+ - spec/resources/credential_bugs_spec.rb
495
507
  - spec/spec_helper.rb