diff --git a/.editorconfig b/.editorconfig
index 16ff5720c..111c6ec90 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -10,5 +10,5 @@ indent_size = 2
indent_style = space
trim_trailing_whitespace = true
-[*.{ico,keep,pdf,svg}]
+[*.{ico,keep,pdf,svg,der}]
insert_final_newline = false
diff --git a/README.md b/README.md
index e8267b183..f113289d4 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,7 @@ En local, un utilisateur de test est créé automatiquement, avec les identifian
WarnExpiringDossiersJob.set(cron: "0 0 1 * *").perform_later
GestionnaireEmailNotificationJob.set(cron: "0 10 * * 1,2,3,4,5,6").perform_later
PurgeUnattachedBlobsJob.set(cron: "0 0 * * *").perform_later
+ OperationsSignatureJob.set(cron: "0 6 * * *").perform_later
### Voir les emails envoyés en local
diff --git a/app/controllers/manager/bill_signatures_controller.rb b/app/controllers/manager/bill_signatures_controller.rb
new file mode 100644
index 000000000..8e454c083
--- /dev/null
+++ b/app/controllers/manager/bill_signatures_controller.rb
@@ -0,0 +1,4 @@
+module Manager
+ class BillSignaturesController < Manager::ApplicationController
+ end
+end
diff --git a/app/dashboards/bill_signature_dashboard.rb b/app/dashboards/bill_signature_dashboard.rb
new file mode 100644
index 000000000..91e5d7d1f
--- /dev/null
+++ b/app/dashboards/bill_signature_dashboard.rb
@@ -0,0 +1,22 @@
+require "administrate/base_dashboard"
+
+class BillSignatureDashboard < Administrate::BaseDashboard
+ ATTRIBUTE_TYPES = {
+ dossier_operation_logs: Field::HasMany,
+ id: Field::Number,
+ digest: Field::String,
+ created_at: Field::DateTime,
+ updated_at: Field::DateTime,
+ serialized: AttachmentField,
+ signature: AttachmentField
+ }.freeze
+
+ COLLECTION_ATTRIBUTES = [
+ :id,
+ :created_at,
+ :dossier_operation_logs,
+ :digest,
+ :serialized,
+ :signature
+ ].freeze
+end
diff --git a/app/fields/attachment_field.rb b/app/fields/attachment_field.rb
new file mode 100644
index 000000000..2022fb355
--- /dev/null
+++ b/app/fields/attachment_field.rb
@@ -0,0 +1,11 @@
+require "administrate/field/base"
+
+class AttachmentField < Administrate::Field::Base
+ def to_s
+ data.filename.to_s
+ end
+
+ def blob_path
+ Rails.application.routes.url_helpers.rails_blob_path(data)
+ end
+end
diff --git a/app/jobs/operations_signature_job.rb b/app/jobs/operations_signature_job.rb
new file mode 100644
index 000000000..092aec740
--- /dev/null
+++ b/app/jobs/operations_signature_job.rb
@@ -0,0 +1,15 @@
+class OperationsSignatureJob < ApplicationJob
+ queue_as :cron
+
+ def perform(*args)
+ last_midnight = Time.zone.today.beginning_of_day
+ operations_by_day = BillSignatureService.grouped_unsigned_operation_until(last_midnight)
+ operations_by_day.each do |day, operations|
+ begin
+ BillSignatureService.sign_operations(operations, day)
+ rescue
+ raise # let errors show up on Sentry and delayed_jobs
+ end
+ end
+ end
+end
diff --git a/app/lib/asn1/timestamp.rb b/app/lib/asn1/timestamp.rb
new file mode 100644
index 000000000..6dd65e4db
--- /dev/null
+++ b/app/lib/asn1/timestamp.rb
@@ -0,0 +1,25 @@
+class ASN1::Timestamp
+ ## Poor man’s rfc3161 timestamp decoding
+ # This works, as of 2019-05, for timestamps delivered by the universign POST api.
+ # We should properly access the ASN1 contents using the sequence and tags structure.
+ # However:
+ # * It’s hard to do right.
+ # * We currently don’t require it for proper operation; timestamps are never exposed to users.
+ # * There’s an ongoing PR https://github.com/ruby/openssl/pull/204 for proper timestamp decoding in the ruby openssl library; let’s use OpenSSL::TS once it exists.
+
+ def self.timestampInfo(asn1timestamp)
+ asn1 = OpenSSL::ASN1.decode(asn1timestamp)
+ tstInfo = OpenSSL::ASN1.decode(asn1.value[1].value[0].value[2].value[1].value[0].value)
+ tstInfo
+ end
+
+ def self.signature_time(asn1timestamp)
+ tstInfo = timestampInfo(asn1timestamp)
+ tstInfo.value[4].value
+ end
+
+ def self.signed_digest(asn1timestamp)
+ tstInfo = timestampInfo(asn1timestamp)
+ tstInfo.value[2].value[1].value.unpack1('H*')
+ end
+end
diff --git a/app/lib/universign/api.rb b/app/lib/universign/api.rb
new file mode 100644
index 000000000..eeb987ca1
--- /dev/null
+++ b/app/lib/universign/api.rb
@@ -0,0 +1,40 @@
+class Universign::API
+ ## Universign Timestamp POST API
+ # Official documentation is at https://help.universign.com/hc/fr/articles/360000898965-Guide-d-intégration-horodatage
+
+ def self.ensure_properly_configured!
+ if userpwd.blank?
+ raise StandardError, 'Universign API is not properly configured'
+ end
+ end
+
+ def self.timestamp(data)
+ ensure_properly_configured!
+
+ response = Typhoeus.post(
+ UNIVERSIGN_API_URL,
+ userpwd: userpwd,
+ body: body(data)
+ )
+
+ if response.success?
+ response.body
+ else
+ raise StandardError, "Universign timestamp query failed: #{response.status_message}"
+ end
+ end
+
+ private
+
+ def self.body(data)
+ {
+ 'hashAlgo': 'SHA256',
+ 'withCert': 'true',
+ 'hashValue': data
+ }
+ end
+
+ def self.userpwd
+ Rails.application.secrets.universign[:userpwd]
+ end
+end
diff --git a/app/models/bill_signature.rb b/app/models/bill_signature.rb
new file mode 100644
index 000000000..cef20c232
--- /dev/null
+++ b/app/models/bill_signature.rb
@@ -0,0 +1,84 @@
+class BillSignature < ApplicationRecord
+ has_many :dossier_operation_logs
+
+ has_one_attached :serialized
+ has_one_attached :signature
+
+ validate :check_bill_digest
+ validate :check_serialized_bill_contents
+ validate :check_signature_contents
+
+ def self.build_with_operations(operations, day)
+ bill = new(dossier_operation_logs: operations)
+
+ bill.serialize_operations(day)
+
+ bill
+ end
+
+ def serialize_operations(day)
+ self.serialized.attach(
+ io: StringIO.new(operations_bill_json),
+ filename: "demarches-simplifiees-operations-#{day.to_date.iso8601}.json",
+ content_type: 'application/json',
+ metadata: { virus_scan_result: ActiveStorage::VirusScanner::SAFE }
+ )
+
+ self.digest = operations_bill_digest
+ end
+
+ def operations_bill
+ dossier_operation_logs.map { |op| [op.id.to_s, op.digest] }.to_h
+ end
+
+ def operations_bill_json
+ operations_bill.to_json
+ end
+
+ def operations_bill_digest
+ Digest::SHA256.hexdigest(operations_bill_json)
+ end
+
+ def set_signature(signature, day)
+ self.signature.attach(
+ io: StringIO.new(signature),
+ filename: "demarches-simplifiees-signature-#{day.to_date.iso8601}.der",
+ content_type: 'application/x-x509-ca-cert'
+ )
+ end
+
+ # Validations
+ def check_bill_digest
+ if self.digest != self.operations_bill_digest
+ errors.add(:digest)
+ end
+ end
+
+ def check_serialized_bill_contents
+ if !self.serialized.attached?
+ errors.add(:serialized, :blank)
+ return
+ end
+
+ if JSON.parse(self.serialized.download) != self.operations_bill
+ errors.add(:serialized)
+ end
+ end
+
+ def check_signature_contents
+ if !self.signature.attached?
+ errors.add(:signature, :blank)
+ return
+ end
+
+ timestamp_signature_date = ASN1::Timestamp.signature_time(self.signature.download)
+ if timestamp_signature_date > Time.zone.now
+ errors.add(:signature, :invalid_date)
+ end
+
+ timestamp_signed_digest = ASN1::Timestamp.signed_digest(self.signature.download)
+ if timestamp_signed_digest != self.digest
+ errors.add(:signature)
+ end
+ end
+end
diff --git a/app/models/dossier_operation_log.rb b/app/models/dossier_operation_log.rb
index e517dcb36..a66492ace 100644
--- a/app/models/dossier_operation_log.rb
+++ b/app/models/dossier_operation_log.rb
@@ -12,6 +12,7 @@ class DossierOperationLog < ApplicationRecord
belongs_to :dossier
has_one_attached :serialized
+ belongs_to :bill_signature, optional: true
def self.create_and_serialize(params)
dossier = params.fetch(:dossier)
diff --git a/app/services/bill_signature_service.rb b/app/services/bill_signature_service.rb
new file mode 100644
index 000000000..f8640a4b1
--- /dev/null
+++ b/app/services/bill_signature_service.rb
@@ -0,0 +1,16 @@
+class BillSignatureService
+ def self.grouped_unsigned_operation_until(date)
+ unsigned_operations = DossierOperationLog
+ .where(bill_signature: nil)
+ .where('executed_at < ?', date)
+
+ unsigned_operations.group_by { |e| e.executed_at.to_date }
+ end
+
+ def self.sign_operations(operations, day)
+ bill = BillSignature.build_with_operations(operations, day)
+ signature = Universign::API.timestamp(bill.digest)
+ bill.set_signature(signature, day)
+ bill.save!
+ end
+end
diff --git a/app/views/fields/attachment_field/_index.html.haml b/app/views/fields/attachment_field/_index.html.haml
new file mode 100644
index 000000000..d29687a35
--- /dev/null
+++ b/app/views/fields/attachment_field/_index.html.haml
@@ -0,0 +1 @@
+= link_to(field.to_s, field.blob_path)
diff --git a/app/views/manager/bill_signatures/_collection.html.erb b/app/views/manager/bill_signatures/_collection.html.erb
new file mode 100644
index 000000000..aea9f28c7
--- /dev/null
+++ b/app/views/manager/bill_signatures/_collection.html.erb
@@ -0,0 +1,68 @@
+<%#
+# Collection
+
+This partial is used on the `index` and `show` pages
+to display a collection of resources in an HTML table.
+
+## Local variables:
+
+- `collection_presenter`:
+ An instance of [Administrate::Page::Collection][1].
+ The table presenter uses `ResourceDashboard::COLLECTION_ATTRIBUTES` to determine
+ the columns displayed in the table
+- `resources`:
+ An ActiveModel::Relation collection of resources to be displayed in the table.
+ By default, the number of resources is limited by pagination
+ or by a hard limit to prevent excessive page load times
+
+[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection
+%>
+
+
+
+
+ <% collection_presenter.attribute_types.each do |attr_name, attr_type| %>
+
+ <%= link_to(sanitized_order_params(page, collection_field_name).merge(
+ collection_presenter.order_params_for(attr_name, key: collection_field_name)
+ )) do %>
+ <%= t(
+ "helpers.label.#{collection_presenter.resource_name}.#{attr_name}",
+ default: attr_name.to_s,
+ ).titleize %>
+ <% if collection_presenter.ordered_by?(attr_name) %>
+
+
+
+ <% end %>
+ <% end %>
+ |
+ <% end %>
+ <% [valid_action?(:edit, collection_presenter.resource_name),
+ valid_action?(:destroy, collection_presenter.resource_name)].count(true).times do %>
+ |
+ <% end %>
+
+
+
+
+ <% resources.each do |resource| %>
+
+ <% collection_presenter.attributes_for(resource).each do |attribute| %>
+
+ <%= render_field attribute %>
+ |
+ <% end %>
+
+ <% end %>
+
+
diff --git a/config/env.example b/config/env.example
index b1aa41f94..84fd7aeb7 100644
--- a/config/env.example
+++ b/config/env.example
@@ -66,3 +66,6 @@ TRUSTED_NETWORKS=""
SKYLIGHT_AUTHENTICATION_KEY=""
LOGRAGE_ENABLED="disabled"
+
+UNIVERSIGN_API_URL=""
+UNIVERSIGN_USERPWD=""
diff --git a/config/initializers/urls.rb b/config/initializers/urls.rb
index 92a27a505..585f11740 100644
--- a/config/initializers/urls.rb
+++ b/config/initializers/urls.rb
@@ -7,6 +7,7 @@ API_GEO_SANDBOX_URL = ENV.fetch("API_GEO_SANDBOX_URL", "https://sandbox.geo.api.
HELPSCOUT_API_URL = ENV.fetch("HELPSCOUT_API_URL", "https://api.helpscout.net/v2")
PIPEDRIVE_API_URL = ENV.fetch("PIPEDRIVE_API_URL", "https://api.pipedrive.com/v1")
SENDINBLUE_API_URL = ENV.fetch("SENDINBLUE_API_URL", "https://in-automate.sendinblue.com/api/v2")
+UNIVERSIGN_API_URL = ENV.fetch("UNIVERSIGN_API_URL", "https://ws.universign.eu/tsa/post/")
# Internal URLs
FOG_BASE_URL = "https://static.demarches-simplifiees.fr"
diff --git a/config/locales/models/bill_signature/fr.yml b/config/locales/models/bill_signature/fr.yml
new file mode 100644
index 000000000..b614025fe
--- /dev/null
+++ b/config/locales/models/bill_signature/fr.yml
@@ -0,0 +1,34 @@
+fr:
+ activerecord:
+ attributes:
+ bill_signature:
+ dossier_operation_logs:
+ one: opération
+ other: opérations
+ digest: empreinte
+ serialized: liasse
+ signature: signature
+ errors:
+ models:
+ bill_signature:
+ attributes:
+ digest:
+ invalid: 'ne correspond pas à la liasse'
+ serialized:
+ blank: 'doit être rempli'
+ invalid: 'ne correspond pas aux opérations'
+ signature:
+ blank: 'doit être rempli'
+ invalid: 'ne correspond pas à l’empreinte'
+ invalid_date: 'ne doit pas être dans le futur'
+ models:
+ bill_signature:
+ one: Horodatage
+ other: Horodatages
+ helpers:
+ label:
+ bill_signature:
+ dossier_operation_logs: opérations
+ digest: empreinte
+ serialized: liasse
+ signature: signature
diff --git a/config/routes.rb b/config/routes.rb
index da1c66ecc..e6006507c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -37,6 +37,8 @@ Rails.application.routes.draw do
resources :demandes, only: [:index]
+ resources :bill_signatures, only: [:index]
+
resources :services, only: [:index, :show]
post 'demandes/create_administrateur'
diff --git a/config/secrets.yml b/config/secrets.yml
index 9a74a71cc..7f3166b96 100644
--- a/config/secrets.yml
+++ b/config/secrets.yml
@@ -64,6 +64,8 @@ defaults: &defaults
crisp:
enabled: <%= ENV['CRISP_ENABLED'] == 'enabled' %>
client_key: <%= ENV['CRISP_CLIENT_KEY'] %>
+ universign:
+ userpwd: <%= ENV['UNIVERSIGN_USERPWD'] %>
@@ -90,6 +92,8 @@ test:
token_endpoint: https://bidon.com/endpoint
userinfo_endpoint: https://bidon.com/endpoint
logout_endpoint: https://bidon.com/endpoint
+ universign:
+ userpwd: 'fake:fake'
# Do not keep production secrets in the repository,
# instead read values from the environment.
diff --git a/db/migrate/20190616141702_create_bill_signature.rb b/db/migrate/20190616141702_create_bill_signature.rb
new file mode 100644
index 000000000..95b0b9a2e
--- /dev/null
+++ b/db/migrate/20190616141702_create_bill_signature.rb
@@ -0,0 +1,10 @@
+class CreateBillSignature < ActiveRecord::Migration[5.2]
+ def change
+ create_table :bill_signatures do |t|
+ t.string :digest
+ t.timestamps
+ end
+
+ add_reference :dossier_operation_logs, :bill_signature, foreign_key: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 0a29eae67..f44e8df90 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2019_06_07_124156) do
+ActiveRecord::Schema.define(version: 2019_06_16_141702) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -145,6 +145,12 @@ ActiveRecord::Schema.define(version: 2019_06_07_124156) do
t.index ["gestionnaire_id"], name: "index_avis_on_gestionnaire_id"
end
+ create_table "bill_signatures", force: :cascade do |t|
+ t.string "digest"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
create_table "champs", id: :serial, force: :cascade do |t|
t.string "value"
t.integer "type_de_champ_id"
@@ -224,7 +230,9 @@ ActiveRecord::Schema.define(version: 2019_06_07_124156) do
t.datetime "keep_until"
t.datetime "executed_at"
t.text "digest"
+ t.bigint "bill_signature_id"
t.index ["administration_id"], name: "index_dossier_operation_logs_on_administration_id"
+ t.index ["bill_signature_id"], name: "index_dossier_operation_logs_on_bill_signature_id"
t.index ["dossier_id"], name: "index_dossier_operation_logs_on_dossier_id"
t.index ["gestionnaire_id"], name: "index_dossier_operation_logs_on_gestionnaire_id"
t.index ["keep_until"], name: "index_dossier_operation_logs_on_keep_until"
@@ -617,6 +625,7 @@ ActiveRecord::Schema.define(version: 2019_06_07_124156) do
add_foreign_key "closed_mails", "procedures"
add_foreign_key "commentaires", "dossiers"
add_foreign_key "dossier_operation_logs", "administrations"
+ add_foreign_key "dossier_operation_logs", "bill_signatures"
add_foreign_key "dossier_operation_logs", "dossiers"
add_foreign_key "dossier_operation_logs", "gestionnaires"
add_foreign_key "dossiers", "users"
diff --git a/spec/factories/bill_signature.rb b/spec/factories/bill_signature.rb
new file mode 100644
index 000000000..363aafb8f
--- /dev/null
+++ b/spec/factories/bill_signature.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :bill_signature do
+ serialized { Rack::Test::UploadedFile.new("./spec/fixtures/files/bill_signature/serialized.json", 'application/json') }
+ signature { Rack::Test::UploadedFile.new("./spec/fixtures/files/bill_signature/signature.der", 'application/x-x509-ca-cert') }
+ end
+end
diff --git a/spec/factories/dossier_operation_log.rb b/spec/factories/dossier_operation_log.rb
new file mode 100644
index 000000000..ff6a80a53
--- /dev/null
+++ b/spec/factories/dossier_operation_log.rb
@@ -0,0 +1,5 @@
+FactoryBot.define do
+ factory :dossier_operation_log do
+ operation { :passer_en_instruction }
+ end
+end
diff --git a/spec/fixtures/cassettes/universign.yml b/spec/fixtures/cassettes/universign.yml
new file mode 100644
index 000000000..3f84c27d9
--- /dev/null
+++ b/spec/fixtures/cassettes/universign.yml
@@ -0,0 +1,43 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: https://ws.universign.eu/tsa/post/
+ body:
+ encoding: UTF-8
+ string: hashAlgo=SHA256&hashValue=d28d6c7742c6c77025a104b81750ce8dc5f7dba2d01d9b5f4cad828f02b2324b&withCert=true
+ headers:
+ User-Agent:
+ - demarches-simplifiees.fr
+ Expect:
+ - ''
+ Authorization:
+ - Basic cmllbiBpY2kK=
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Date:
+ - Fri, 17 May 2019 10:05:12 GMT
+ Server:
+ - Apache
+ Cache-Control:
+ - no-cache
+ Pragma:
+ - no-cache
+ Content-Type:
+ - application/octet-stream
+ Set-Cookie:
+ - CGSESSIONID=A3D72C1B0D07C073CFE68597B3FBF1B6E639C351;Path=/;Version=0
+ X-Ua-Compatible:
+ - IE=Edge
+ Transfer-Encoding:
+ - chunked
+ body:
+ encoding: ASCII-8BIT
+ string: !binary |-
+ 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
+ http_version:
+ recorded_at: Fri, 17 May 2019 10:05:12 GMT
+recorded_with: VCR 4.0.0
diff --git a/spec/fixtures/files/bill_signature/serialized.json b/spec/fixtures/files/bill_signature/serialized.json
new file mode 100644
index 000000000..87852f06f
--- /dev/null
+++ b/spec/fixtures/files/bill_signature/serialized.json
@@ -0,0 +1 @@
+{"dossier1": "hash1", "dossier2": "hash2"}
diff --git a/spec/fixtures/files/bill_signature/signature.der b/spec/fixtures/files/bill_signature/signature.der
new file mode 100644
index 000000000..9adcc9a7e
Binary files /dev/null and b/spec/fixtures/files/bill_signature/signature.der differ
diff --git a/spec/lib/asn1/timestamp_spec.rb b/spec/lib/asn1/timestamp_spec.rb
new file mode 100644
index 000000000..3ae0d6272
--- /dev/null
+++ b/spec/lib/asn1/timestamp_spec.rb
@@ -0,0 +1,19 @@
+require 'spec_helper'
+
+describe ASN1::Timestamp do
+ let(:asn1timestamp) { File.read('spec/fixtures/files/bill_signature/signature.der') }
+
+ describe '.timestamp_time' do
+ subject { described_class.signature_time(asn1timestamp) }
+
+ it { is_expected.to eq Time.zone.parse('2019-04-30 15:30:20 UTC') }
+ end
+
+ describe '.timestamp_signed_data' do
+ subject { described_class.signed_digest(asn1timestamp) }
+
+ let(:data) { Digest::SHA256.hexdigest('CECI EST UN BLOB') }
+
+ it { is_expected.to eq data }
+ end
+end
diff --git a/spec/lib/universign/api_spec.rb b/spec/lib/universign/api_spec.rb
new file mode 100644
index 000000000..124076ba5
--- /dev/null
+++ b/spec/lib/universign/api_spec.rb
@@ -0,0 +1,11 @@
+require 'spec_helper'
+
+describe Universign::API do
+ describe '.request_timestamp', vcr: { cassette_name: 'universign' } do
+ subject { described_class.timestamp(digest) }
+
+ let(:digest) { Digest::SHA256.hexdigest("CECI EST UN HASH") }
+
+ it { is_expected.not_to be_nil }
+ end
+end
diff --git a/spec/models/bill_signature_spec.rb b/spec/models/bill_signature_spec.rb
new file mode 100644
index 000000000..30157cbfe
--- /dev/null
+++ b/spec/models/bill_signature_spec.rb
@@ -0,0 +1,160 @@
+require 'rails_helper'
+
+RSpec.describe BillSignature, type: :model do
+ describe 'validations' do
+ subject(:bill_signature) { BillSignature.new }
+
+ describe 'check_bill_digest' do
+ before do
+ bill_signature.dossier_operation_logs = dossier_operation_logs
+ bill_signature.digest = digest
+ bill_signature.valid?
+ end
+
+ context 'when there are no operations' do
+ let(:dossier_operation_logs) { [] }
+
+ context 'when the digest is correct' do
+ let(:digest) { Digest::SHA256.hexdigest('{}') }
+
+ it { expect(bill_signature.errors.details[:digest]).to be_empty }
+ end
+
+ context 'when the digest is incorrect' do
+ let(:digest) { 'baadf00d' }
+
+ it { expect(bill_signature.errors.details[:digest]).to eq [error: :invalid] }
+ end
+ end
+
+ context 'when the signature has operations' do
+ let(:dossier_operation_logs) { [build(:dossier_operation_log, id: '1234', digest: 'abcd')] }
+
+ context 'when the digest is correct' do
+ let(:digest) { Digest::SHA256.hexdigest('{"1234":"abcd"}') }
+
+ it { expect(bill_signature.errors.details[:digest]).to be_empty }
+ end
+
+ context 'when the digest is incorrect' do
+ let(:digest) { 'baadf00d' }
+
+ it { expect(bill_signature.errors.details[:digest]).to eq [error: :invalid] }
+ end
+ end
+ end
+
+ describe 'check_serialized_bill_contents' do
+ before do
+ bill_signature.dossier_operation_logs = dossier_operation_logs
+ bill_signature.serialized.attach(io: StringIO.new(serialized), filename: 'file') if serialized.present?
+ bill_signature.valid?
+ end
+
+ context 'when there are no operations' do
+ let(:dossier_operation_logs) { [] }
+ let(:serialized) { '{}' }
+
+ it { expect(bill_signature.errors.details[:serialized]).to be_empty }
+ end
+
+ context 'when the signature has operations' do
+ let(:dossier_operation_logs) { [build(:dossier_operation_log, id: '1234', digest: 'abcd')] }
+ let(:serialized) { '{"1234":"abcd"}' }
+
+ it { expect(bill_signature.errors.details[:serialized]).to be_empty }
+ end
+
+ context 'when serialized isn’t set' do
+ let(:dossier_operation_logs) { [] }
+ let(:serialized) { nil }
+
+ it { expect(bill_signature.errors.details[:serialized]).to eq [error: :blank] }
+ end
+ end
+
+ describe 'check_signature_contents' do
+ before do
+ bill_signature.signature.attach(io: StringIO.new(signature), filename: 'file') if signature.present?
+ allow(ASN1::Timestamp).to receive(:signature_time).and_return(signature_time)
+ allow(ASN1::Timestamp).to receive(:signed_digest).and_return(signed_digest)
+ bill_signature.digest = digest
+ bill_signature.valid?
+ end
+
+ context 'when the signature is correct' do
+ let(:signature) { 'signature' }
+ let(:signature_time) { 1.day.ago }
+ let(:digest) { 'abcd' }
+ let(:signed_digest) { 'abcd' }
+
+ it { expect(bill_signature.errors.details[:signature]).to be_empty }
+ end
+
+ context 'when the signature isn’t set' do
+ let(:signature) { nil }
+ let(:signature_time) { 1.day.ago }
+ let(:digest) { 'abcd' }
+ let(:signed_digest) { 'abcd' }
+
+ it { expect(bill_signature.errors.details[:signature]).to eq [error: :blank] }
+ end
+
+ context 'when the signature time is in the future' do
+ let(:signature) { 'signature' }
+ let(:signature_time) { 1.day.from_now }
+ let(:digest) { 'abcd' }
+ let(:signed_digest) { 'abcd' }
+
+ it { expect(bill_signature.errors.details[:signature]).to eq [error: :invalid_date] }
+ end
+
+ context 'when the signature doesn’t match the digest' do
+ let(:signature) { 'signature' }
+ let(:signature_time) { 1.day.ago }
+ let(:digest) { 'abcd' }
+ let(:signed_digest) { 'dcba' }
+
+ it { expect(bill_signature.errors.details[:signature]).to eq [error: :invalid] }
+ end
+ end
+ end
+
+ describe '.build_with_operations' do
+ subject(:bill_signature) { described_class.build_with_operations(dossier_operation_logs, Date.new(1871, 03, 18)) }
+
+ context 'when there are no operations' do
+ let(:dossier_operation_logs) { [] }
+
+ it { expect(bill_signature.operations_bill).to eq({}) }
+ it { expect(bill_signature.digest).to eq(Digest::SHA256.hexdigest('{}')) }
+ it { expect(bill_signature.serialized.download).to eq('{}') }
+ it { expect(bill_signature.serialized.filename).to eq('demarches-simplifiees-operations-1871-03-18.json') }
+ end
+
+ context 'when there is one operation' do
+ let(:dossier_operation_logs) do
+ [build(:dossier_operation_log, id: '1234', digest: 'abcd')]
+ end
+
+ it { expect(bill_signature.operations_bill).to eq({ '1234' => 'abcd' }) }
+ it { expect(bill_signature.digest).to eq(Digest::SHA256.hexdigest('{"1234":"abcd"}')) }
+ it { expect(bill_signature.serialized.download).to eq('{"1234":"abcd"}') }
+ it { expect(bill_signature.serialized.filename).to eq('demarches-simplifiees-operations-1871-03-18.json') }
+ end
+
+ context 'when there are several operations' do
+ let(:dossier_operation_logs) do
+ [
+ build(:dossier_operation_log, id: '1234', digest: 'abcd'),
+ build(:dossier_operation_log, id: '5678', digest: 'dcba')
+ ]
+ end
+
+ it { expect(bill_signature.operations_bill).to eq({ '1234' => 'abcd', '5678' => 'dcba' }) }
+ it { expect(bill_signature.digest).to eq(Digest::SHA256.hexdigest('{"1234":"abcd","5678":"dcba"}')) }
+ it { expect(bill_signature.serialized.download).to eq('{"1234":"abcd","5678":"dcba"}') }
+ it { expect(bill_signature.serialized.filename).to eq('demarches-simplifiees-operations-1871-03-18.json') }
+ end
+ end
+end
diff --git a/spec/services/bill_signature_service_spec.rb b/spec/services/bill_signature_service_spec.rb
new file mode 100644
index 000000000..2a88478df
--- /dev/null
+++ b/spec/services/bill_signature_service_spec.rb
@@ -0,0 +1,37 @@
+require 'spec_helper'
+
+describe BillSignatureService do
+ describe ".grouped_unsigned_operation_until" do
+ subject { BillSignatureService.grouped_unsigned_operation_until(date).length }
+
+ let(:date) { Date.today }
+
+ context "when operations of several days need to be signed" do
+ before do
+ create :dossier_operation_log, executed_at: 3.days.ago
+ create :dossier_operation_log, executed_at: 2.days.ago
+ create :dossier_operation_log, executed_at: 1.day.ago
+ end
+
+ it { is_expected.to eq 3 }
+ end
+
+ context "when operations on a single day need to be signed" do
+ before do
+ create :dossier_operation_log, executed_at: 1.day.ago
+ create :dossier_operation_log, executed_at: 1.day.ago
+ end
+
+ it { is_expected.to eq 1 }
+ end
+
+ context "when there are no operations to be signed" do
+ before do
+ create :dossier_operation_log, created_at: 1.day.ago, bill_signature: build(:bill_signature)
+ create :dossier_operation_log, created_at: 1.day.from_now
+ end
+
+ it { is_expected.to eq 0 }
+ end
+ end
+end