diff --git a/Gemfile.lock b/Gemfile.lock index a8d6434e6..3285380f7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -186,7 +186,7 @@ GEM thor (~> 0.14) globalid (0.4.1) activesupport (>= 4.2.0) - grape (1.0.3) + grape (1.1.0) activesupport builder mustermann-grape (~> 1.0.0) diff --git a/app/controllers/registrar/deposits_controller.rb b/app/controllers/registrar/deposits_controller.rb index ec6d13977..818e38c6d 100644 --- a/app/controllers/registrar/deposits_controller.rb +++ b/app/controllers/registrar/deposits_controller.rb @@ -10,12 +10,12 @@ class Registrar @deposit = Deposit.new(deposit_params.merge(registrar: current_user.registrar)) @invoice = @deposit.issue_prepayment_invoice - if @invoice&.persisted? + if @invoice flash[:notice] = t(:please_pay_the_following_invoice) redirect_to [:registrar, @invoice] else - flash.now[:alert] = t(:failed_to_create_record) - render 'new' + flash[:alert] = @deposit.errors.full_messages.join(', ') + redirect_to new_registrar_deposit_path end end diff --git a/app/models/deposit.rb b/app/models/deposit.rb index 23045196a..2eff26bcc 100644 --- a/app/models/deposit.rb +++ b/app/models/deposit.rb @@ -4,13 +4,17 @@ class Deposit extend ActiveModel::Naming include DisableHtml5Validation - attr_accessor :amount, :description, :registrar, :registrar_id + attr_accessor :description, :registrar, :registrar_id + attr_writer :amount validates :amount, :registrar, presence: true validate :validate_amount + def validate_amount - return if BigDecimal.new(amount) >= Setting.minimum_deposit - errors.add(:amount, I18n.t(:is_too_small_minimum_deposit_is, amount: Setting.minimum_deposit, currency: 'EUR')) + minimum_allowed_amount = [0.01, Setting.minimum_deposit].max + return if amount >= minimum_allowed_amount + errors.add(:amount, I18n.t(:is_too_small_minimum_deposit_is, amount: minimum_allowed_amount, + currency: 'EUR')) end def initialize(attributes = {}) @@ -24,10 +28,12 @@ class Deposit end def amount - BigDecimal.new(@amount.to_s.sub(/,/, '.')) + return BigDecimal('0.0') if @amount.blank? + BigDecimal(@amount.to_s.tr(',', '.'), 10) end def issue_prepayment_invoice - valid? && registrar.issue_prepayment_invoice(amount, description) + return unless valid? + registrar.issue_prepayment_invoice(amount, description) end end diff --git a/doc/registrant-api.md b/doc/registrant-api.md new file mode 100644 index 000000000..c6f063a91 --- /dev/null +++ b/doc/registrant-api.md @@ -0,0 +1,11 @@ +# Registrant API integration specification + +Test API endpoint: TBA +Production API endpoint: TBA + +Main communication specification through Registrant API: + +[Authentication](registrant-api/v1/authentication.md) +[Domains](registrant-api/v1/domain.md) +[Domain Lock](registrant-api/v1/domain_lock.md) +[Contacts](registrant-api/v1/contact.md) diff --git a/doc/registrant-api/v1/authentication.md b/doc/registrant-api/v1/authentication.md new file mode 100644 index 000000000..20ad6edd6 --- /dev/null +++ b/doc/registrant-api/v1/authentication.md @@ -0,0 +1,109 @@ +# Authentication + +## Authenticating with mobileID or ID-card + +For specified partners the API allows for use of data from mobile ID for +authentication. API client should perform authentication with eID according to +the approriate documentation, and then pass on values from the webserver's +certificate to the API server. + +## POST /api/v1/registrant/auth/eid + +Returns a bearer token to be used for further API requests. Tokens are valid for 2 hours since their creation. + +#### Paramaters + +Values in brackets represent values that come from the id card certificate. + +| Field name | Required | Type | Allowed values | Description | +| ----------------- | -------- | ---- | -------------- | ----------- | +| ident | true | String | | Identity code of the user (`serialNumber`) | +| first_name | true | String | | Name of the customer (`GN`) | +| last_name | true | String | | Name of the customer (`SN`) | + + +#### Request +``` +POST /api/v1/auth/token HTTP/1.1 +Accept: application/json +Content-type: application/json + +{ + "ident": "30110100103", + "first_name": "Jan", + "last_name": "Tamm", +} +``` + +#### Response +``` +HTTP/1.1 201 +Content-Type: application.json + + +{ + "access_token": "", + "expires_at": "2018-07-13 11:30:51 UTC", + "type": "Bearer" +} +``` + +## POST /api/v1/auth/username -- NOT IMPLEMENTED + +#### Paramaters + +Values in brackets represent values that come from the id card certificate + +| Field name | Required | Type | Allowed values | Description | +| ----------------- | -------- | ---- | -------------- | ----------- | +| username | true | String | Username as provided by the user | | +| password | true | String | Password as provided by the user | | + + +#### Request +``` +POST /api/v1/auth/token HTTP/1.1 +Accept: application/json +Content-type: application/json +``` + +#### Response +``` +HTTP/1.1 201 +Content-Type: application.json + + +{ + "access_token": "", + "expires_at": "2018-07-13 11:30:51 UTC", + "type": "Bearer" +} +``` + +## Implementation notes: + +We do not need to store the session data at all, instead we can levarage AES encryption and use +Rails secret as the key. General approximation: + +```ruby +class AuthenticationToken + def initialize(secret = Rails.application.config.secret_key_base, values = {}) + end + + def create_token_hash + data = values.to_s + + cipher = OpenSSL::Cipher::AES.new(256, :CBC) + cipher.encrypt + + encrypted = cipher.update(data) + cipher.final + base64_encoded = Base64.encode64(encrypted) + + { + token: base64_encoded, + expires_in = values[:expires_in] + type: "Bearer" + } + end +end +``` diff --git a/doc/registrant-api/v1/contact.md b/doc/registrant-api/v1/contact.md new file mode 100644 index 000000000..4e1c12bec --- /dev/null +++ b/doc/registrant-api/v1/contact.md @@ -0,0 +1,194 @@ +## GET /api/v1/registrant/contacts +Returns contacts of the current registrar. + + +#### Parameters + +| Field name | Required | Type | Allowed values | Description | +| ---------- | -------- | ---- | -------------- | ----------- | +| limit | false | Integer | [1..200] | How many contacts to show | +| offset | false | Integer | | Contact number to start at | + +#### Request +``` +GET /api/v1/registrant/contacts?limit=1 HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "contacts": [ + { + "uuid": "84c62f3d-e56f-40fa-9ca4-dc0137778949", + "domain_names": ["example.com"], + "code": "REGISTRAR2:SH022086480", + "phone": "+372.12345678", + "email": "hoyt@deckowbechtelar.net", + "fax": null, + "created_at": "2015-09-09T09:11:14.130Z", + "updated_at": "2015-09-09T09:11:14.130Z", + "ident": "37605030299", + "ident_type": "priv", + "auth_info": "password", + "name": "Karson Kessler0", + "org_name": null, + "registrar_id": 2, + "creator_str": null, + "updator_str": null, + "ident_country_code": "EE", + "city": "Tallinn", + "street": "Short street 11", + "zip": "11111", + "country_code": "EE", + "state": null, + "legacy_id": null, + "statuses": [ + "ok" + ], + "status_notes": { + } + } + ], + "total_number_of_records": 2 +} +``` + +## GET /api/v1/registrant/contacts/$UUID +Returns contacts of the current registrar. + + +#### Request +``` +GET /api/v1/registrant/contacts/84c62f3d-e56f-40fa-9ca4-dc0137778949 HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "uuid": "84c62f3d-e56f-40fa-9ca4-dc0137778949", + "domain_names": ["example.com"], + "code": "REGISTRAR2:SH022086480", + "phone": "+372.12345678", + "email": "hoyt@deckowbechtelar.net", + "fax": null, + "created_at": "2015-09-09T09:11:14.130Z", + "updated_at": "2015-09-09T09:11:14.130Z", + "ident": "37605030299", + "ident_type": "priv", + "auth_info": "password", + "name": "Karson Kessler0", + "org_name": null, + "registrar_id": 2, + "creator_str": null, + "updator_str": null, + "ident_country_code": "EE", + "city": "Tallinn", + "street": "Short street 11", + "zip": "11111", + "country_code": "EE", + "state": null, + "legacy_id": null, + "statuses": [ + "ok" + ], + "status_notes": {} +} +``` + +## PATCH /api/v1/registrant/contacts/$UUID + +Update contact details for a contact. + +#### Parameters + +| Field name | Required | Type | Allowed values | Description | +| ---- | --- | --- | --- | --- | +| email | false | String | | New email address | +| phone | false | String | | New phone number | +| fax | false | String | | New fax number | +| city | false | String | | New city name | +| street | false | String | | New street name | +| zip | false | String | | New zip code | +| country_code | false | String | | New country code in 2 letter format ('EE', 'LV') | +| state | false | String | | New state name | + + +#### Request +``` +PATCH /api/v1/registrant/contacts/84c62f3d-e56f-40fa-9ca4-dc0137778949 HTTP/1.1 +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Accept: application/json +Content-type: application/json + +{ + "email": "foo@bar.baz", + "phone": "+372.12345671", + "fax": "+372.12345672", + "city": "New City", + "street": "Main Street 123", + "zip": "22222", + "country_code": "LV", + "state": "New state" +} + +``` +#### Response on success + +``` +HTTP/1.1 200 +Content-Type: application.json + +{ + "uuid": "84c62f3d-e56f-40fa-9ca4-dc0137778949", + "domain_names": ["example.com"], + "code": "REGISTRAR2:SH022086480", + "phone": "+372.12345671", + "email": "foo@bar.baz", + "fax": "+372.12345672", + "created_at": "2015-09-09T09:11:14.130Z", + "updated_at": "2018-09-09T09:11:14.130Z", + "ident": "37605030299", + "ident_type": "priv", + "auth_info": "password", + "name": "Karson Kessler0", + "org_name": null, + "registrar_id": 2, + "creator_str": null, + "updator_str": null, + "ident_country_code": "EE", + "city": "New City", + "street": "Main Street 123", + "zip": "22222", + "country_code": "LV", + "state": "New state" + "legacy_id": null, + "statuses": [ + "ok" + ], + "status_notes": {} +} +``` + +### Response on failure +``` +HTTP/1.1 400 +Content-Type: application.json + +{ + "errors": [ + { "phone": "Phone nr is invalid" } + ] +} +``` diff --git a/doc/registrant-api/v1/domain.md b/doc/registrant-api/v1/domain.md new file mode 100644 index 000000000..09495220d --- /dev/null +++ b/doc/registrant-api/v1/domain.md @@ -0,0 +1,161 @@ +# Domain related actions + +## GET /api/v1/registrant/domains + +Returns domains of the current registrant. + + +#### Parameters + +| Field name | Required | Type | Allowed values | Description | +| ---------- | -------- | ---- | -------------- | ----------- | +| limit | false | Integer | [1..200] | How many domains to show | +| offset | false | Integer | | Domain number to start at | +| details | false | String | ["true", "false"] | Whether to include details | + +#### Request +``` +GET api/v1/registrant/domains?limit=1&details=true HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "domains": [ + { + "uuid": "98d1083a-8863-4153-93e4-caee4a013535", + "name": "domain0.ee", + "registrar_id": 2, + "registered_at": "2015-09-09T09:11:14.861Z", + "status": null, + "valid_from": "2015-09-09T09:11:14.861Z", + "valid_to": "2016-09-09T09:11:14.861Z", + "registrant_id": 1, + "transfer_code": "98oiewslkfkd", + "created_at": "2015-09-09T09:11:14.861Z", + "updated_at": "2015-09-09T09:11:14.860Z", + "name_dirty": "domain0.ee", + "name_puny": "domain0.ee", + "period": 1, + "period_unit": "y", + "creator_str": null, + "updator_str": null, + "legacy_id": null, + "legacy_registrar_id": null, + "legacy_registrant_id": null, + "outzone_at": "2016-09-24T09:11:14.861Z", + "delete_at": "2016-10-24T09:11:14.861Z", + "registrant_verification_asked_at": null, + "registrant_verification_token": null, + "pending_json": { + }, + "force_delete_at": null, + "statuses": [ + "ok" + ], + "reserved": false, + "status_notes": { + }, + "statuses_backup": [ + ] + } + ], + "total_number_of_records": 2 +} +``` + +## GET api/v1/registrant/domains + +Returns domain names with offset. + + +#### Request +``` +GET api/v1/registrant/domains?offset=1 HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "domains": [ + "domain1.ee" + ], + "total_number_of_records": 2 +} +``` + +## GET api/v1/registrant/domains/$UUID + +Returns a single domain object. + + +#### Request +``` +GET api/v1/registrant/domains/98d1083a-8863-4153-93e4-caee4a013535 HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response for success + +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "uuid": "98d1083a-8863-4153-93e4-caee4a013535", + "name": "domain0.ee", + "registrar_id": 2, + "registered_at": "2015-09-09T09:11:14.861Z", + "status": null, + "valid_from": "2015-09-09T09:11:14.861Z", + "valid_to": "2016-09-09T09:11:14.861Z", + "registrant_id": 1, + "transfer_code": "98oiewslkfkd", + "created_at": "2015-09-09T09:11:14.861Z", + "updated_at": "2015-09-09T09:11:14.860Z", + "name_dirty": "domain0.ee", + "name_puny": "domain0.ee", + "period": 1, + "period_unit": "y", + "creator_str": null, + "updator_str": null, + "legacy_id": null, + "legacy_registrar_id": null, + "legacy_registrant_id": null, + "outzone_at": "2016-09-24T09:11:14.861Z", + "delete_at": "2016-10-24T09:11:14.861Z", + "registrant_verification_asked_at": null, + "registrant_verification_token": null, + "pending_json": {}, + "force_delete_at": null, + "statuses": [ + "ok" + ], + "reserved": false, + "status_notes": {}, + "statuses_backup": [] +} +``` + +#### Response for failure + +``` +HTTP/1.1 404 +Content-Type: application/json + +{ "errors": ["Domain not found"] } +``` diff --git a/doc/registrant-api/v1/domain_lock.md b/doc/registrant-api/v1/domain_lock.md new file mode 100644 index 000000000..5f275edb4 --- /dev/null +++ b/doc/registrant-api/v1/domain_lock.md @@ -0,0 +1,163 @@ +# Domain locks + +## POST api/v1/registrant/domains/$UUID/registry_lock + +Set a registry lock on a domain. + +#### Request +``` +POST api/v1/registrant/domains/98d1083a-8863-4153-93e4-caee4a013535/registry_lock HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response for success + +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "uuid": "98d1083a-8863-4153-93e4-caee4a013535", + "name": "domain0.ee", + "registrar_id": 2, + "registered_at": "2015-09-09T09:11:14.861Z", + "status": null, + "valid_from": "2015-09-09T09:11:14.861Z", + "valid_to": "2016-09-09T09:11:14.861Z", + "registrant_id": 1, + "transfer_code": "98oiewslkfkd", + "created_at": "2015-09-09T09:11:14.861Z", + "updated_at": "2015-09-09T09:11:14.860Z", + "name_dirty": "domain0.ee", + "name_puny": "domain0.ee", + "period": 1, + "period_unit": "y", + "creator_str": null, + "updator_str": null, + "legacy_id": null, + "legacy_registrar_id": null, + "legacy_registrant_id": null, + "outzone_at": "2016-09-24T09:11:14.861Z", + "delete_at": "2016-10-24T09:11:14.861Z", + "registrant_verification_asked_at": null, + "registrant_verification_token": null, + "pending_json": {}, + "force_delete_at": null, + "statuses": [ + "serverUpdateProhibited", + "serverDeleteProhibited", + "serverTransferProhibited" + ], + "reserved": false, + "status_notes": {}, + "statuses_backup": [] +} +``` + +#### Response for failure + +``` +HTTP/1.1 400 +Content-Type: application/json + +{ + "errors": [ + { "base": "domain cannot be locked" } + ] +} + +``` + +``` +HTTP/1.1 404 +Content-Type: application/json + +{ + "errors": [ + { "base": "domain does not exist" } + ] +} + +``` + +## DELETE api/v1/registrant/domains/$UUID/registry_lock + +Remove a registry lock. + +#### Request +``` +DELETE api/v1/registrant/domains/98d1083a-8863-4153-93e4-caee4a013535/registry_lock HTTP/1.1 +Accept: application/json +Authorization: Bearer Z2l0bGFiOmdoeXQ5ZTRmdQ== +Content-Type: application/json +``` + +#### Response for success + +``` +HTTP/1.1 200 +Content-Type: application/json + +{ + "uuid": "98d1083a-8863-4153-93e4-caee4a013535", + "name": "domain0.ee", + "registrar_id": 2, + "registered_at": "2015-09-09T09:11:14.861Z", + "status": null, + "valid_from": "2015-09-09T09:11:14.861Z", + "valid_to": "2016-09-09T09:11:14.861Z", + "registrant_id": 1, + "transfer_code": "98oiewslkfkd", + "created_at": "2015-09-09T09:11:14.861Z", + "updated_at": "2015-09-09T09:11:14.860Z", + "name_dirty": "domain0.ee", + "name_puny": "domain0.ee", + "period": 1, + "period_unit": "y", + "creator_str": null, + "updator_str": null, + "legacy_id": null, + "legacy_registrar_id": null, + "legacy_registrant_id": null, + "outzone_at": "2016-09-24T09:11:14.861Z", + "delete_at": "2016-10-24T09:11:14.861Z", + "registrant_verification_asked_at": null, + "registrant_verification_token": null, + "pending_json": {}, + "force_delete_at": null, + "statuses": [ + "ok" + ], + "reserved": false, + "status_notes": {}, + "statuses_backup": [] +} +``` + +#### Response for failure + +``` +HTTP/1.1 400 +Content-Type: application/json + +{ + "errors": [ + { "base": "domain cannot be unlocked" } + ] +} + +``` + +``` +HTTP/1.1 404 +Content-Type: application/json + +{ + "errors": [ + { "base": "domain does not exist" } + ] +} + +``` diff --git a/spec/models/deposit_spec.rb b/spec/models/deposit_spec.rb deleted file mode 100644 index ff77dbd98..000000000 --- a/spec/models/deposit_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'rails_helper' - -describe Deposit do - context 'with invalid attribute' do - before :all do - @deposit = Deposit.new - end - - it 'should not be valid' do - @deposit.valid? - @deposit.errors.full_messages.should match_array([ - "Registrar is missing" - ]) - end - - it 'should have 0 amount' do - @deposit.amount.should == 0 - end - - it 'should not be presisted' do - @deposit.persisted?.should == false - end - - it 'should replace comma with point for 0' do - @deposit.amount = '0,0' - @deposit.amount.should == 0.0 - end - - it 'should replace comma with points' do - @deposit.amount = '10,11' - @deposit.amount.should == 10.11 - end - - it 'should work with float as well' do - @deposit.amount = 0.123 - @deposit.amount.should == 0.123 - end - end -end diff --git a/test/models/deposit_test.rb b/test/models/deposit_test.rb new file mode 100644 index 000000000..b7510b960 --- /dev/null +++ b/test/models/deposit_test.rb @@ -0,0 +1,59 @@ +require 'test_helper' + +class DepositTest < ActiveSupport::TestCase + def setup + super + + @deposit = Deposit.new(registrar: registrars(:bestnames)) + @minimum_deposit = Setting.minimum_deposit + Setting.minimum_deposit = 1.00 + end + + def teardown + super + + Setting.minimum_deposit = @minimum_deposit + end + + def test_validate_amount_cannot_be_lower_than_0_01 + Setting.minimum_deposit = 0.0 + @deposit.amount = -10 + refute(@deposit.valid?) + assert(@deposit.errors.full_messages.include?("Amount is too small. Minimum deposit is 0.01 EUR")) + end + + def test_validate_amount_cannot_be_lower_than_minimum_deposit + @deposit.amount = 0.10 + refute(@deposit.valid?) + + assert(@deposit.errors.full_messages.include?("Amount is too small. Minimum deposit is 1.0 EUR")) + end + + def test_registrar_must_be_set + deposit = Deposit.new(amount: 120) + refute(deposit.valid?) + + assert(deposit.errors.full_messages.include?("Registrar is missing")) + end + + def test_amount_is_converted_from_string + @deposit.amount = "12.00" + assert_equal(BigDecimal.new("12.00"), @deposit.amount) + + @deposit.amount = "12,11" + assert_equal(BigDecimal.new("12.11"), @deposit.amount) + end + + def test_amount_is_converted_from_float + @deposit.amount = 12.0044 + assert_equal(BigDecimal.new("12.0044"), @deposit.amount) + + @deposit.amount = 12.0144 + assert_equal(BigDecimal.new("12.0144"), @deposit.amount) + end + + def test_amount_is_converted_from_nil + @deposit.amount = nil + assert_equal(BigDecimal.new("0.00"), @deposit.amount) + end +end diff --git a/test/system/registrar_area/invoices/new_test.rb b/test/system/registrar_area/invoices/new_test.rb index b9b6b6db4..a5a72fbe8 100644 --- a/test/system/registrar_area/invoices/new_test.rb +++ b/test/system/registrar_area/invoices/new_test.rb @@ -29,11 +29,10 @@ class NewInvoiceTest < ApplicationSystemTestCase assert_text 'Pay invoice' end - # This test case should fail once issue #651 gets fixed - def test_create_new_invoice_with_amount_0_goes_through + def test_create_new_invoice_with_comma_in_number visit registrar_invoices_path click_link_or_button 'Add deposit' - fill_in 'Amount', with: '0.00' + fill_in 'Amount', with: '200,00' fill_in 'Description', with: 'My first invoice' assert_difference 'Invoice.count', 1 do @@ -42,7 +41,33 @@ class NewInvoiceTest < ApplicationSystemTestCase assert_text 'Please pay the following invoice' assert_text 'Invoice no. 131050' - assert_text 'Subtotal 0,00 €' + assert_text 'Subtotal 200,00 €' assert_text 'Pay invoice' end + + def test_create_new_invoice_fails_when_amount_is_0 + visit registrar_invoices_path + click_link_or_button 'Add deposit' + fill_in 'Amount', with: '0.00' + fill_in 'Description', with: 'My first invoice' + + assert_no_difference 'Invoice.count' do + click_link_or_button 'Add' + end + + assert_text 'Amount is too small. Minimum deposit is 0.01 EUR' + end + + def test_create_new_invoice_fails_when_amount_is_negative + visit registrar_invoices_path + click_link_or_button 'Add deposit' + fill_in 'Amount', with: '-120.00' + fill_in 'Description', with: 'My first invoice' + + assert_no_difference 'Invoice.count' do + click_link_or_button 'Add' + end + + assert_text 'Amount is too small. Minimum deposit is 0.01 EUR' + end end