epics 3.0.0.rc1 → 3.0.0.rc2

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: bfb2519dc2e22c76ab739665eae1376bc45f525b8025c158e25606892839bed9
4
- data.tar.gz: 29a8b4673f7b494c1a8b07e27865bfee5abe5056e176e29612bc4e08058fb340
3
+ metadata.gz: 2d81d3bda7eef2d4468cba9327d5e72ad7222eec73e76bfe82c609765a212075
4
+ data.tar.gz: b49bcb37a43532a0f9d5074478e22cfa126c4b3e7bf594894f8be27e60aeca37
5
5
  SHA512:
6
- metadata.gz: 8fbe472fa138faa685cc8c35891d697d114b3cee9086c47ea3bea7a1c36d817176239647e04fc42dca327fcb07bcb0b0ce410d33e3060ccb446e4d4a092f4b50
7
- data.tar.gz: bc95a07d0c74576667827a341c3aed4ad1cf2777b000426e351baab9d542c8b0cb0679076ba0660350e96d50c26b7d1ab603217a0ed9d64f4a300f6b96e419e4
6
+ metadata.gz: '09ce2c4c8afa08bf91a00112d8a1078b388a76b40276006706f0c821a1aab7924655a9de56032956d1e74975dda1be8438e42d11211fae95b2d4aa4634b7f7f5'
7
+ data.tar.gz: fe3a33f8d19057cbe437189ae98b18e32eafbedde991bb12b1cf0c10a63384a5910f254272f4d9d43f972951d50fa18601bd2b521253603b2f3893e6a81c9e13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ### Unreleased
2
2
 
3
+ - [FIX] A download spanning several segments returned only its first segment, so zipped
4
+ downloads such as `C52` and `C53` raised `Zip::Error` and plain ones such as `STA` came
5
+ back cut off without an error. The remaining segments are now requested in the transfer
6
+ phase and the order data is decrypted once all segments are joined. A segmented response
7
+ without a readable `NumSegments` raises `Epics::InvalidResponseError`
8
+ - [FIX] Truncated order data was inflated as far as it went and returned. It now raises
9
+ `Epics::InvalidOrderDataError`, with the `Zlib` or `OpenSSL` error as its `cause`, and a
10
+ negative receipt is sent instead of a positive one, so the bank does not consider a
11
+ broken download as delivered
12
+
3
13
  ### 3.0.0.rc1
4
14
 
5
15
  Major release adding EBICS 3.0 (H005) and EBICS 2.4 (H003) support, alongside a
data/README.md CHANGED
@@ -354,6 +354,10 @@ puts e.STA('2014-09-01', '2014-09-11')
354
354
  ^62RATAJA 13B/1083-032 PSZCZOL^63KI
355
355
  ```
356
356
 
357
+ If the order data cannot be decrypted or inflated, e.g. because it arrived truncated,
358
+ the download is rejected to the bank with a negative receipt and
359
+ `Epics::InvalidOrderDataError` is raised, with the underlying error as its `cause`.
360
+
357
361
  ### Uploads
358
362
 
359
363
  - CD1 (Uploads a SEPA Direct Debit document of type COR1)
data/lib/epics/client.rb CHANGED
@@ -604,10 +604,32 @@ class Epics::Client
604
604
  document = order_type.new(self, *args, **options)
605
605
  res = post(url, document.to_xml).body
606
606
  document.transaction_id = res.transaction_id
607
+ segments = [res, *remaining_segments(document, res)]
608
+ order_data = begin
609
+ res.decrypt_order_data(segments.map(&:order_data_encrypted).join)
610
+ rescue StandardError => e
611
+ reject_download(document) if res.segmented?
612
+ raise Epics::InvalidOrderDataError.new(order_type.name.split('::').last, e)
613
+ end
614
+ post(url, document.to_receipt_xml) if res.segmented?
615
+
616
+ order_data
617
+ end
607
618
 
608
- post(url, document.to_receipt_xml).body if res.segmented? && res.last_segment?
619
+ def remaining_segments(document, res)
620
+ return [] if !res.segmented? || res.last_segment?
621
+
622
+ (2..res.num_segments).map do |segment_number|
623
+ post(url, document.to_transfer_download_xml(segment_number, segment_number == res.num_segments)).body
624
+ end
625
+ end
609
626
 
610
- res.order_data
627
+ # The bank answers a negative receipt with an error, and the reason the order data
628
+ # could not be read is the one worth raising.
629
+ def reject_download(document)
630
+ post(url, document.to_receipt_xml(acknowledged: false))
631
+ rescue StandardError
632
+ nil
611
633
  end
612
634
 
613
635
  def download_and_unzip(order_type, *args, **options)
@@ -50,8 +50,13 @@ class Epics::GenericRequest
50
50
  raise NotImplementedError
51
51
  end
52
52
 
53
- def to_receipt_xml
54
- builder = request_factory.create_transfer_receipt(transaction_id, 0)
53
+ def to_transfer_download_xml(segment_number, is_last_segment)
54
+ builder = request_factory.create_transfer_download(transaction_id, segment_number, is_last_segment)
55
+ builder.to_xml
56
+ end
57
+
58
+ def to_receipt_xml(acknowledged: true)
59
+ builder = request_factory.create_transfer_receipt(transaction_id, acknowledged ? 0 : 1)
55
60
  builder.to_xml
56
61
  end
57
62
 
@@ -46,6 +46,12 @@ class Epics::Response
46
46
  !!doc.at_xpath('//xmlns:header/xmlns:mutable/xmlns:SegmentNumber', xmlns: client.urn_schema)
47
47
  end
48
48
 
49
+ def num_segments
50
+ Integer(doc.xpath('//xmlns:header/xmlns:static/xmlns:NumSegments', xmlns: client.urn_schema).text, 10)
51
+ rescue ArgumentError => e
52
+ raise Epics::InvalidResponseError.new('NumSegments', e)
53
+ end
54
+
49
55
  def return_code
50
56
  doc.xpath('//xmlns:ReturnCode', xmlns: client.urn_schema).last.content
51
57
  rescue NoMethodError
@@ -93,11 +99,20 @@ class Epics::Response
93
99
  end
94
100
 
95
101
  def order_data
96
- order_data_encrypted = Base64.decode64(doc.xpath('//xmlns:OrderData', xmlns: client.urn_schema).first.content)
102
+ decrypt_order_data(order_data_encrypted)
103
+ end
104
+
105
+ def order_data_encrypted
106
+ Base64.decode64(doc.xpath('//xmlns:OrderData', xmlns: client.urn_schema).first.content)
107
+ end
97
108
 
98
- data = (cipher.update(order_data_encrypted) + cipher.final)
109
+ # Segments cannot be decrypted on their own, so pass all of them joined. Only the first
110
+ # response carries the transaction key.
111
+ def decrypt_order_data(order_data_encrypted)
112
+ decipher = cipher
113
+ data = decipher.update(order_data_encrypted) + decipher.final
99
114
 
100
- Zlib::Inflate.new.inflate(data)
115
+ Zlib::Inflate.inflate(data)
101
116
  end
102
117
 
103
118
  def cipher
data/lib/epics/version.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Epics
4
- VERSION = '3.0.0.rc1'
4
+
5
+ VERSION = '3.0.0.rc2'
6
+
5
7
  end
data/lib/epics.rb CHANGED
@@ -115,6 +115,32 @@ module Epics
115
115
  end
116
116
  end
117
117
 
118
+ # Raised when the bank's response lacks a value the protocol requires, or carries one
119
+ # that cannot be read.
120
+ class InvalidResponseError < StandardError
121
+ attr_reader :field
122
+
123
+ def initialize(field, original = nil)
124
+ @field = field
125
+ message = "Could not read #{field} from the bank's response"
126
+ message += " (#{original.message})" if original
127
+ super(message)
128
+ end
129
+ end
130
+
131
+ # Raised when the order data of a download cannot be decrypted or inflated, e.g.
132
+ # because it arrived truncated. The bank has been sent a negative receipt by then.
133
+ class InvalidOrderDataError < StandardError
134
+ attr_reader :order_type
135
+
136
+ def initialize(order_type, original = nil)
137
+ @order_type = order_type
138
+ message = "Could not read the order data of the #{order_type} download"
139
+ message += " (#{original.message})" if original
140
+ super(message)
141
+ end
142
+ end
143
+
118
144
  class VersionSupportError < StandardError
119
145
  attr_reader :version
120
146
 
@@ -89,6 +89,37 @@ RSpec.describe Epics::Response do
89
89
  end
90
90
  end
91
91
 
92
+ describe '#num_segments' do
93
+ let(:ebics_response) { File.read('spec/fixtures/xml/sta_response_continued.xml') }
94
+
95
+ it 'pulls the number of segments from the response' do
96
+ expect(subject.num_segments).to eq(2)
97
+ end
98
+
99
+ describe 'when the number has leading zeros' do
100
+ let(:ebics_response) do
101
+ File.read('spec/fixtures/xml/sta_response_continued.xml').sub('<NumSegments>2<', '<NumSegments>09<')
102
+ end
103
+
104
+ it 'reads it as a decimal' do
105
+ expect(subject.num_segments).to eq(9)
106
+ end
107
+ end
108
+
109
+ describe 'when the number is missing' do
110
+ let(:ebics_response) do
111
+ File.read('spec/fixtures/xml/sta_response_continued.xml').sub(%r{<NumSegments>2</NumSegments>}, '')
112
+ end
113
+
114
+ it 'raises an error naming the field' do
115
+ expect { subject.num_segments }.to raise_error(Epics::InvalidResponseError, /NumSegments/) do |error|
116
+ expect(error.field).to eq('NumSegments')
117
+ expect(error.cause).to be_a(ArgumentError)
118
+ end
119
+ end
120
+ end
121
+ end
122
+
92
123
  describe '#segmented?' do
93
124
  describe 'when the response is segemnted' do
94
125
  let(:ebics_response) { File.read('spec/fixtures/xml/sta_response.xml') }
@@ -0,0 +1,248 @@
1
+ RSpec.describe 'segmented downloads' do
2
+ versions = [Epics::Keyring::VERSION_24, Epics::Keyring::VERSION_25, Epics::Keyring::VERSION_30]
3
+
4
+ let(:url) { 'https://194.180.18.30/ebicsweb/ebicsweb' }
5
+ let(:version) { Epics::Keyring::VERSION_25 }
6
+ let(:client) do
7
+ Epics::Client.new(File.open(File.join(File.dirname(__FILE__), 'fixtures', 'SIZBN001.key')),
8
+ 'secret', url, 'SIZBN001', 'EBIX', 'EBICS', version:)
9
+ end
10
+ let(:ns) { { 'e' => client.urn_schema } }
11
+ let(:transaction_id) { 'ECD6F062AAEDFA77250526A68CBEC549' }
12
+
13
+ describe 'Epics::GenericRequest#to_transfer_download_xml' do
14
+ let(:order) { Epics::C53.new(client, from: Date.new(2026, 9, 24), to: Date.new(2026, 9, 24)) }
15
+ let(:xml) { Nokogiri::XML(order.to_transfer_download_xml(2, false)) }
16
+
17
+ before { order.transaction_id = transaction_id }
18
+
19
+ versions.each do |version|
20
+ context version do
21
+ let(:version) { version }
22
+
23
+ it 'is a valid EBICS document' do
24
+ expect(xml.to_xml).to be_a_valid_ebics_doc(version)
25
+ end
26
+
27
+ it 'requests the segment of the transaction without sending order data' do
28
+ expect(xml.at_xpath('//e:header/e:static/e:TransactionID', ns).text).to eq(transaction_id)
29
+ expect(xml.at_xpath('//e:header/e:mutable/e:TransactionPhase', ns).text).to eq('Transfer')
30
+ expect(xml.at_xpath('//e:header/e:mutable/e:SegmentNumber', ns).text).to eq('2')
31
+ expect(xml.at_xpath('//e:header/e:mutable/e:SegmentNumber', ns)['lastSegment']).to eq('false')
32
+ expect(xml.at_xpath('//e:body', ns).elements).to be_empty
33
+ end
34
+ end
35
+ end
36
+
37
+ it 'marks the last segment' do
38
+ segment_number = Nokogiri::XML(order.to_transfer_download_xml(3, true))
39
+ .at_xpath('//e:header/e:mutable/e:SegmentNumber', ns)
40
+
41
+ expect(segment_number.text).to eq('3')
42
+ expect(segment_number['lastSegment']).to eq('true')
43
+ end
44
+ end
45
+
46
+ describe 'Epics::GenericRequest#to_receipt_xml' do
47
+ let(:order) { Epics::C53.new(client, from: Date.new(2026, 9, 24), to: Date.new(2026, 9, 24)) }
48
+
49
+ before { order.transaction_id = transaction_id }
50
+
51
+ def receipt_code(xml)
52
+ Nokogiri::XML(xml).at_xpath('//e:body/e:TransferReceipt/e:ReceiptCode', ns).text
53
+ end
54
+
55
+ it 'acknowledges the download by default' do
56
+ expect(receipt_code(order.to_receipt_xml)).to eq('0')
57
+ end
58
+
59
+ versions.each do |version|
60
+ context version do
61
+ let(:version) { version }
62
+
63
+ it 'rejects the download when not acknowledged' do
64
+ xml = order.to_receipt_xml(acknowledged: false)
65
+
66
+ expect(xml).to be_a_valid_ebics_doc(version)
67
+ expect(receipt_code(xml)).to eq('1')
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ describe 'Epics::Client#download' do
74
+ def response(phase, order_data: nil, segment_number: nil, num_segments: nil, return_code: '000000')
75
+ <<~XML
76
+ <?xml version="1.0" encoding="UTF-8"?>
77
+ <ebicsResponse xmlns="#{client.urn_schema}" Version="#{version}" Revision="1">
78
+ <header authenticate="true">
79
+ <static>
80
+ <TransactionID>#{transaction_id}</TransactionID>
81
+ #{"<NumSegments>#{num_segments}</NumSegments>" if num_segments}
82
+ </static>
83
+ <mutable>
84
+ <TransactionPhase>#{phase}</TransactionPhase>
85
+ #{segment_number_xml(segment_number) if segment_number}
86
+ <ReturnCode>#{return_code}</ReturnCode>
87
+ </mutable>
88
+ </header>
89
+ <body>
90
+ #{data_transfer_xml(phase, order_data) if order_data}
91
+ <ReturnCode authenticate="true">000000</ReturnCode>
92
+ </body>
93
+ </ebicsResponse>
94
+ XML
95
+ end
96
+
97
+ def segment_number_xml(segment_number)
98
+ %(<SegmentNumber lastSegment="#{segment_number == segments.size}">#{segment_number}</SegmentNumber>)
99
+ end
100
+
101
+ def data_transfer_xml(phase, order_data)
102
+ <<~XML
103
+ <DataTransfer>
104
+ #{data_encryption_info_xml if phase == 'Initialisation'}
105
+ <OrderData>#{Base64.strict_encode64(order_data)}</OrderData>
106
+ </DataTransfer>
107
+ XML
108
+ end
109
+
110
+ def data_encryption_info_xml
111
+ <<~XML
112
+ <DataEncryptionInfo authenticate="true">
113
+ <TransactionKey>#{Base64.strict_encode64(client.encryption_key.key.public_encrypt(transaction_key))}</TransactionKey>
114
+ </DataEncryptionInfo>
115
+ XML
116
+ end
117
+
118
+ def summarize(request)
119
+ segment_number = request.at_xpath('//e:header/e:mutable/e:SegmentNumber', ns)
120
+ receipt_code = request.at_xpath('//e:body/e:TransferReceipt/e:ReceiptCode', ns)
121
+
122
+ [request.at_xpath('//e:header/e:mutable/e:TransactionPhase', ns).text,
123
+ segment_number&.text,
124
+ segment_number&.[]('lastSegment'),
125
+ receipt_code&.text].compact
126
+ end
127
+
128
+ let(:zipped_order_data) { File.binread(File.join(File.dirname(__FILE__), 'fixtures', 'test.zip')) }
129
+ let(:transaction_key) { SecureRandom.random_bytes(16) }
130
+ let(:order_data_encrypted) do
131
+ cipher = OpenSSL::Cipher.new('aes-128-cbc').encrypt
132
+ cipher.key = transaction_key
133
+
134
+ cipher.update(Zlib::Deflate.deflate(zipped_order_data)) + cipher.final
135
+ end
136
+ let(:missing_bytes) { 0 }
137
+ let(:segment_count) { 3 }
138
+ let(:segments) do
139
+ received = order_data_encrypted.byteslice(0, order_data_encrypted.bytesize - missing_bytes)
140
+
141
+ received.bytes.each_slice((received.bytesize / segment_count.to_f).ceil).map { |bytes| bytes.pack('C*') }
142
+ end
143
+ let(:initialisation_response) do
144
+ response('Initialisation', order_data: segments.first, segment_number: 1, num_segments: segments.size)
145
+ end
146
+ let(:aborted_segment) { nil }
147
+ let(:requests) { [] }
148
+
149
+ subject { client.C53(Date.new(2026, 9, 24), Date.new(2026, 9, 24)) }
150
+
151
+ before do
152
+ stub_request(:post, url).to_return do |http_request|
153
+ requests << summarize(Nokogiri::XML(http_request.body))
154
+ phase, *details = requests.last
155
+ body = case phase
156
+ when 'Initialisation' then initialisation_response
157
+ when 'Transfer'
158
+ segment_number = Integer(details.first)
159
+ if segment_number == aborted_segment
160
+ response('Transfer', return_code: '091102')
161
+ else
162
+ response('Transfer', order_data: segments[segment_number - 1], segment_number:)
163
+ end
164
+ when 'Receipt' then response('Receipt', return_code: details == ['0'] ? '011000' : '011001')
165
+ end
166
+
167
+ { status: 200, body: }
168
+ end
169
+ end
170
+
171
+ context 'when the order data arrives short by whole cipher blocks' do
172
+ let(:missing_bytes) { 32 }
173
+
174
+ it 'raises after sending a negative receipt' do
175
+ expect { subject }.to raise_error(Epics::InvalidOrderDataError, /C53/) do |error|
176
+ expect(error.cause).to be_a(Zlib::BufError)
177
+ end
178
+ expect(requests).to eq([%w[Initialisation], %w[Transfer 2 false], %w[Transfer 3 true], %w[Receipt 1]])
179
+ end
180
+ end
181
+
182
+ context 'when the order data arrives short by part of a cipher block' do
183
+ let(:missing_bytes) { 5 }
184
+
185
+ it 'raises after sending a negative receipt' do
186
+ expect { subject }.to raise_error(Epics::InvalidOrderDataError, /C53/) do |error|
187
+ expect(error.cause).to be_a(OpenSSL::Cipher::CipherError)
188
+ end
189
+ expect(requests).to eq([%w[Initialisation], %w[Transfer 2 false], %w[Transfer 3 true], %w[Receipt 1]])
190
+ end
191
+ end
192
+
193
+ context 'when the bank aborts the transaction during the transfer' do
194
+ let(:aborted_segment) { 2 }
195
+
196
+ it "raises the bank's error without sending a receipt" do
197
+ expect { subject }.to raise_error(Epics::Error::TechnicalError) do |error|
198
+ expect(error.symbol).to eq('EBICS_TX_ABORT')
199
+ end
200
+ expect(requests).to eq([%w[Initialisation], %w[Transfer 2 false]])
201
+ end
202
+ end
203
+
204
+ context 'when the negative receipt cannot be sent' do
205
+ let(:missing_bytes) { 32 }
206
+
207
+ before do
208
+ stub_request(:post, url).with { |request| summarize(Nokogiri::XML(request.body)) == %w[Receipt 1] }
209
+ .to_raise(Faraday::ConnectionFailed)
210
+ end
211
+
212
+ it 'raises the reason the order data could not be read' do
213
+ expect { subject }.to raise_error(Epics::InvalidOrderDataError, /C53/) do |error|
214
+ expect(error.cause).to be_a(Zlib::BufError)
215
+ end
216
+ end
217
+ end
218
+
219
+ context 'when the response is not segmented' do
220
+ let(:initialisation_response) { response('Initialisation', order_data: order_data_encrypted) }
221
+
222
+ it 'returns the order data without sending a receipt' do
223
+ expect(subject).to eq(["ebics is great\n"])
224
+ expect(requests).to eq([%w[Initialisation]])
225
+ end
226
+ end
227
+
228
+ context 'when the order data fits into a single segment' do
229
+ let(:segment_count) { 1 }
230
+
231
+ it 'returns the order data and sends the receipt' do
232
+ expect(subject).to eq(["ebics is great\n"])
233
+ expect(requests).to eq([%w[Initialisation], %w[Receipt 0]])
234
+ end
235
+ end
236
+
237
+ versions.each do |version|
238
+ context "when the order data spans several segments (#{version})" do
239
+ let(:version) { version }
240
+
241
+ it 'returns the order data of all segments and sends the receipt after the last one' do
242
+ expect(subject).to eq(["ebics is great\n"])
243
+ expect(requests).to eq([%w[Initialisation], %w[Transfer 2 false], %w[Transfer 3 true], %w[Receipt 0]])
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: epics
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.rc1
4
+ version: 3.0.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lars Brillert
@@ -455,6 +455,7 @@ files:
455
455
  - spec/response_business_error_spec.rb
456
456
  - spec/response_spec.rb
457
457
  - spec/response_validation_spec.rb
458
+ - spec/segmented_download_spec.rb
458
459
  - spec/signature_algorithm/rsa_pss_spec.rb
459
460
  - spec/spec_helper.rb
460
461
  - spec/support/ebics_matcher.rb
@@ -520,7 +521,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
520
521
  - !ruby/object:Gem::Version
521
522
  version: '0'
522
523
  requirements: []
523
- rubygems_version: 4.0.16
524
+ rubygems_version: 4.0.20
524
525
  specification_version: 4
525
526
  summary: a ruby implementation of the EBICS protocol
526
527
  test_files:
@@ -633,6 +634,7 @@ test_files:
633
634
  - spec/response_business_error_spec.rb
634
635
  - spec/response_spec.rb
635
636
  - spec/response_validation_spec.rb
637
+ - spec/segmented_download_spec.rb
636
638
  - spec/signature_algorithm/rsa_pss_spec.rb
637
639
  - spec/spec_helper.rb
638
640
  - spec/support/ebics_matcher.rb