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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +26 -0
- data/lib/epics/builders/order_details_builder/v3.rb +25 -1
- data/lib/epics/client.rb +25 -3
- data/lib/epics/generic_request.rb +7 -2
- data/lib/epics/response.rb +18 -3
- data/lib/epics/version.rb +3 -1
- data/lib/epics.rb +26 -0
- data/spec/builders_spec.rb +54 -0
- data/spec/request_factory_spec.rb +19 -0
- data/spec/response_spec.rb +31 -0
- data/spec/segmented_download_spec.rb +248 -0
- data/spec/support/ebics_shared_examples.rb +4 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f9d505e87c6753d7e10cf2092fa02e9ec4fff0c93476fb47c32e8aabd834012
|
|
4
|
+
data.tar.gz: 0da7d015ff42a77a23e3ee3f91d6a26a353dba9e107cd6275e73a9b5132fef13
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
54
|
-
builder = request_factory.
|
|
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
|
|
data/lib/epics/response.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
115
|
+
Zlib::Inflate.inflate(data)
|
|
101
116
|
end
|
|
102
117
|
|
|
103
118
|
def cipher
|
data/lib/epics/version.rb
CHANGED
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
|
|
data/spec/builders_spec.rb
CHANGED
|
@@ -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
|
data/spec/response_spec.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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
|