epics 3.0.0.rc1 → 3.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfb2519dc2e22c76ab739665eae1376bc45f525b8025c158e25606892839bed9
4
- data.tar.gz: 29a8b4673f7b494c1a8b07e27865bfee5abe5056e176e29612bc4e08058fb340
3
+ metadata.gz: 5f9d505e87c6753d7e10cf2092fa02e9ec4fff0c93476fb47c32e8aabd834012
4
+ data.tar.gz: 0da7d015ff42a77a23e3ee3f91d6a26a353dba9e107cd6275e73a9b5132fef13
5
5
  SHA512:
6
- metadata.gz: 8fbe472fa138faa685cc8c35891d697d114b3cee9086c47ea3bea7a1c36d817176239647e04fc42dca327fcb07bcb0b0ce410d33e3060ccb446e4d4a092f4b50
7
- data.tar.gz: bc95a07d0c74576667827a341c3aed4ad1cf2777b000426e351baab9d542c8b0cb0679076ba0660350e96d50c26b7d1ab603217a0ed9d64f4a300f6b96e419e4
6
+ metadata.gz: cfd418610ca95b6ff2d29b407a5699721391e7b1197dffd271f52b7cb71e7c219629110c74ff77732a03a83ebf7f1d8696de9496eda636b6e4b2ddfd86d0c239
7
+ data.tar.gz: 21f19dee4287bca68b4b055148735445fb6893002a51dc73ebcc44ea3de597debae3b1397cbcdc8e8e129a7fa84908f0b70852527d40f5c2c6b70e2507962270
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ### Unreleased
2
2
 
3
+ ### 3.0.0
4
+
5
+ - [FIX] H005 uploads were sent without `SignatureFlag` in `BTUOrderParams`, although they
6
+ carry the user's ES (electronic signature), so banks treated them as orders
7
+ to be authorised outside EBICS and some rejected them with `EBICS_INVALID_ORDER_TYPE`.
8
+ `SignatureFlag` is now set by default, matching `OZHNN` on H004. Pass `request_eds: true`
9
+ to spool the order into the EDS (distributed electronic signature, German VEU)
10
+ when signatures are still missing, or `signature_flag: false` to omit the flag
11
+
12
+ ### 3.0.0.rc2
13
+
14
+ - [FIX] A download spanning several segments returned only its first segment, so zipped
15
+ downloads such as `C52` and `C53` raised `Zip::Error` and plain ones such as `STA` came
16
+ back cut off without an error. The remaining segments are now requested in the transfer
17
+ phase and the order data is decrypted once all segments are joined. A segmented response
18
+ without a readable `NumSegments` raises `Epics::InvalidResponseError`
19
+ - [FIX] Truncated order data was inflated as far as it went and returned. It now raises
20
+ `Epics::InvalidOrderDataError`, with the `Zlib` or `OpenSSL` error as its `cause`, and a
21
+ negative receipt is sent instead of a positive one, so the bank does not consider a
22
+ broken download as delivered
23
+
3
24
  ### 3.0.0.rc1
4
25
 
5
26
  Major release adding EBICS 3.0 (H005) and EBICS 2.4 (H003) support, alongside a
data/README.md CHANGED
@@ -307,6 +307,28 @@ practice requires it to be supplied (guidelines §5.2).
307
307
  When it is absent the bank assumes a global definition.
308
308
  On EBICS 2.4/2.5 these parameters have no equivalent and are ignored.
309
309
 
310
+ ### Signatures on uploads
311
+
312
+ EBICS 3.0 replaces the order attribute (`OZHNN`, `DZHNN`) with `SignatureFlag`, which tells
313
+ the bank whether an upload carries the user's ES (electronic signature). Uploads always
314
+ include it and send `SignatureFlag` by default, the equivalent of `OZHNN`: the bank expects
315
+ every required signature in the request and rejects the order otherwise.
316
+
317
+ ```ruby
318
+ # spool into the EDS (distributed electronic signature, German VEU) when signatures are
319
+ # still missing, e.g. for a user who needs a second signer
320
+ e.CCT(document, request_eds: true)
321
+
322
+ # no SignatureFlag: the order is authorised outside EBICS, e.g. by an accompanying slip
323
+ # or in online banking
324
+ e.CCT(document, signature_flag: false)
325
+ ```
326
+
327
+ `signature_flag: false` only drops the flag; the user's signature is still sent.
328
+
329
+ On EBICS 2.4/2.5 both options have no effect and are silently ignored: uploads keep
330
+ their fixed order attribute (`OZHNN` for the payment uploads).
331
+
310
332
  ## Features
311
333
 
312
334
  ### Initialization
@@ -354,6 +376,10 @@ puts e.STA('2014-09-01', '2014-09-11')
354
376
  ^62RATAJA 13B/1083-032 PSZCZOL^63KI
355
377
  ```
356
378
 
379
+ If the order data cannot be decrypted or inflated, e.g. because it arrived truncated,
380
+ the download is rejected to the bank with a negative receipt and
381
+ `Epics::InvalidOrderDataError` is raised, with the underlying error as its `cause`.
382
+
357
383
  ### Uploads
358
384
 
359
385
  - CD1 (Uploads a SEPA Direct Debit document of type COR1)
@@ -32,8 +32,22 @@ class Epics::Builders::OrderDetailsBuilder::V3 < Epics::Builders::OrderDetailsBu
32
32
  end
33
33
 
34
34
  def add_btu_order_params(
35
- filename:, service_name:, msg_name:, scope: nil, service_option: nil, container_type: nil, msg_name_version: nil, msg_name_variant: nil, msg_name_format: nil
35
+ filename:,
36
+ service_name:,
37
+ msg_name:,
38
+ scope: nil,
39
+ service_option: nil,
40
+ container_type: nil,
41
+ msg_name_version: nil,
42
+ msg_name_variant: nil,
43
+ msg_name_format: nil,
44
+ signature_flag: true,
45
+ request_eds: false
36
46
  )
47
+ if request_eds && !signature_flag
48
+ raise ArgumentError, 'request_eds requires signature_flag'
49
+ end
50
+
37
51
  @xml.BTUOrderParams fileName: filename do |xml|
38
52
  xml.Service do
39
53
  xml.ServiceName service_name
@@ -46,6 +60,16 @@ class Epics::Builders::OrderDetailsBuilder::V3 < Epics::Builders::OrderDetailsBu
46
60
  msg_name_attributes[:format] = msg_name_format if msg_name_format
47
61
  xml.MsgName msg_name, msg_name_attributes
48
62
  end
63
+ # Without SignatureFlag the bank treats the order as carrying no ES (electronic
64
+ # signature) and expects authorisation outside EBICS. requestEDS asks the bank to
65
+ # spool the order into the EDS (distributed electronic signature, German VEU,
66
+ # "Verteilte Elektronische Unterschrift") instead of rejecting it when signatures
67
+ # are still missing.
68
+ if signature_flag
69
+ signature_flag_attributes = {}
70
+ signature_flag_attributes[:requestEDS] = 'true' if request_eds
71
+ xml.SignatureFlag '', signature_flag_attributes
72
+ end
49
73
  end
50
74
  end
51
75
  end
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)
@@ -702,7 +724,7 @@ class Epics::Client
702
724
  data["#{host_id.upcase}.#{sig.version}"] = dump_signature(sig)
703
725
  end
704
726
 
705
- JSON.pretty_generate(data, JSON.dump_default_options)
727
+ JSON.pretty_generate(data)
706
728
  end
707
729
 
708
730
  # Keys without a certificate keep the bare-string form so key files stay readable by
@@ -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'
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
 
@@ -317,6 +317,60 @@ RSpec.describe 'Builders' do
317
317
  expect(container).not_to be_nil
318
318
  expect(container['containerType']).to eq('ZIP')
319
319
  end
320
+
321
+ it 'adds SignatureFlag after Service by default' do
322
+ instance = described_class::V3.new do |b|
323
+ b.add_btu_order_params(
324
+ filename: 'test.xml', service_name: 'MCT', msg_name: 'pain.001'
325
+ )
326
+ end
327
+ xml = parse_doc(instance)
328
+ params = xml.at('OrderDetails/BTUOrderParams')
329
+ expect(params.element_children.map(&:name)).to eq(%w[Service SignatureFlag])
330
+ expect(params.at('SignatureFlag')['requestEDS']).to be_nil
331
+ end
332
+
333
+ it 'sets requestEDS on SignatureFlag when request_eds is given' do
334
+ instance = described_class::V3.new do |b|
335
+ b.add_btu_order_params(
336
+ filename: 'test.xml',
337
+ service_name: 'MCT',
338
+ msg_name: 'pain.001',
339
+ request_eds: true
340
+ )
341
+ end
342
+ xml = parse_doc(instance)
343
+ params = xml.at('OrderDetails/BTUOrderParams')
344
+ expect(params.at('SignatureFlag')['requestEDS']).to eq('true')
345
+ end
346
+
347
+ it 'omits SignatureFlag when signature_flag is false' do
348
+ instance = described_class::V3.new do |b|
349
+ b.add_btu_order_params(
350
+ filename: 'test.xml',
351
+ service_name: 'MCT',
352
+ msg_name: 'pain.001',
353
+ signature_flag: false
354
+ )
355
+ end
356
+ xml = parse_doc(instance)
357
+ params = xml.at('OrderDetails/BTUOrderParams')
358
+ expect(params.at('SignatureFlag')).to be_nil
359
+ end
360
+
361
+ it 'rejects request_eds without signature_flag' do
362
+ expect do
363
+ described_class::V3.new do |b|
364
+ b.add_btu_order_params(
365
+ filename: 'test.xml',
366
+ service_name: 'MCT',
367
+ msg_name: 'pain.001',
368
+ signature_flag: false,
369
+ request_eds: true
370
+ )
371
+ end
372
+ end.to raise_error(ArgumentError, /request_eds/)
373
+ end
320
374
  end
321
375
  end
322
376
  end
@@ -439,6 +439,25 @@ RSpec.describe 'RequestFactory' do
439
439
  params = xml.at('//BTUOrderParams')
440
440
  expect(params['fileName']).to eq('custom.xml')
441
441
  end
442
+
443
+ it 'create_xe2 without override has SignatureFlag without requestEDS' do
444
+ xml = parse(factory.create_xe2(digest, transaction_key))
445
+ params = xml.at('//BTUOrderParams')
446
+ expect(params.at('SignatureFlag')).not_to be_nil
447
+ expect(params.at('SignatureFlag')['requestEDS']).to be_nil
448
+ end
449
+
450
+ it 'create_xe2 accepts request_eds override' do
451
+ xml = parse(factory.create_xe2(digest, transaction_key, request_eds: true))
452
+ params = xml.at('//BTUOrderParams')
453
+ expect(params.at('SignatureFlag')['requestEDS']).to eq('true')
454
+ end
455
+
456
+ it 'create_xe2 accepts signature_flag override' do
457
+ xml = parse(factory.create_xe2(digest, transaction_key, signature_flag: false))
458
+ params = xml.at('//BTUOrderParams')
459
+ expect(params.at('SignatureFlag')).to be_nil
460
+ end
442
461
  end
443
462
  end
444
463
  end
@@ -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
@@ -469,6 +469,10 @@ RSpec.shared_examples 'a valid ebicsRequest H005 upload' do |service_name:, msg_
469
469
  expect(service.at_xpath('e:MsgName', ns).text).to eq(msg_name)
470
470
  end
471
471
 
472
+ it 'has SignatureFlag' do
473
+ expect(xml.at_xpath('//e:header/e:static/e:OrderDetails/e:BTUOrderParams/e:SignatureFlag', ns)).not_to be_nil
474
+ end
475
+
472
476
  if scope
473
477
  it "has Scope #{scope}" do
474
478
  service = xml.at_xpath('//e:header/e:static/e:OrderDetails/e:BTUOrderParams/e:Service', ns)
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
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