diff --git a/README.md b/README.md index 6a92db8ca..835e4aa80 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ## Ruby -You have to use Ruby version 3.3.9 with installed bundler. +You have to use Ruby version 4.0.6 with installed bundler. ## Postgresql diff --git a/app/admin/equipment/gateways.rb b/app/admin/equipment/gateways.rb index 100d8d475..e1001ac18 100644 --- a/app/admin/equipment/gateways.rb +++ b/app/admin/equipment/gateways.rb @@ -402,8 +402,8 @@ def resource_params f.input :contact_user if f.object.external_id.nil? || authorized?(:allow_incoming_auth_credentials) - f.input :incoming_auth_username, hint: "#{link_to('Сlick to fill random username', 'javascript:void(0)', onclick: 'generateCredential(this)')}. #{t('formtastic.hints.gateway.incoming_auth_username')}".html_safe - f.input :incoming_auth_password, as: :string, input_html: { autocomplete: 'off' }, hint: link_to('Сlick to fill random password', 'javascript:void(0)', onclick: 'generateCredential(this)') + f.input :incoming_auth_username, hint: "#{link_to('Click to fill random username', '#', data: { generate_credential: '' })}. #{t('formtastic.hints.gateway.incoming_auth_username')}".html_safe + f.input :incoming_auth_password, as: :string, input_html: { autocomplete: 'off' }, hint: link_to('Click to fill random password', '#', data: { generate_credential: '' }) end f.input :incoming_auth_allow_jwt diff --git a/app/admin/system/api_accesses.rb b/app/admin/system/api_accesses.rb index dc8709973..32355e74b 100644 --- a/app/admin/system/api_accesses.rb +++ b/app/admin/system/api_accesses.rb @@ -80,8 +80,8 @@ f.semantic_errors *f.object.errors.attribute_names f.inputs do - f.input :login, hint: link_to('Сlick to fill random login', 'javascript:void(0)', onclick: 'generateCredential(this)') - f.input :password, as: :string, hint: link_to('Сlick to fill random password', 'javascript:void(0)', onclick: 'generateCredential(this)') + f.input :login, hint: link_to('Click to fill random login', '#', data: { generate_credential: '' }) + f.input :password, as: :string, hint: link_to('Click to fill random password', '#', data: { generate_credential: '' }) f.contractor_input :customer_id, label: 'Customer' f.account_input :account_ids, multiple: true, diff --git a/app/assets/javascripts/credential_generator.js b/app/assets/javascripts/credential_generator.js index 528cdf46a..698154d91 100644 --- a/app/assets/javascripts/credential_generator.js +++ b/app/assets/javascripts/credential_generator.js @@ -1,3 +1,8 @@ +// Fills an input with a random credential. The "click to fill random ..." hint +// links opt in with a `data-generate-credential` attribute (optionally carrying a +// length) and are bound by the delegated handler below rather than an inline +// onclick=, so the admin runs under a Content-Security-Policy without +// `script-src 'unsafe-inline'` — see also chart_init.js. function generateCredential(target, length) { if (!$(target).is('input')) { target = $(target).closest('li[id$=input]').find('input')[0]; } if (typeof(target) === 'undefined' || target.length === 0) return; @@ -13,3 +18,11 @@ function generateCredential(target, length) { $(target).val(credential); } + +// Delegated: the hint links live inside formtastic inputs that are re-rendered +// (and, on the gateway form, revealed) after page load. +$(document).on('click', '[data-generate-credential]', function (e) { + e.preventDefault(); + var length = parseInt($(this).data('generateCredential'), 10); + generateCredential(this, isNaN(length) ? undefined : length); +}); diff --git a/app/controllers/api/rest/admin/routing_plan_static_routes_controller.rb b/app/controllers/api/rest/admin/routing_plan_static_routes_controller.rb new file mode 100644 index 000000000..455e9a9f9 --- /dev/null +++ b/app/controllers/api/rest/admin/routing_plan_static_routes_controller.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class Api::Rest::Admin::RoutingPlanStaticRoutesController < Api::Rest::Admin::BaseController +end diff --git a/app/resources/api/rest/admin/routing_plan_resource.rb b/app/resources/api/rest/admin/routing_plan_resource.rb index c78c7f2d8..d521b2514 100644 --- a/app/resources/api/rest/admin/routing_plan_resource.rb +++ b/app/resources/api/rest/admin/routing_plan_resource.rb @@ -12,6 +12,13 @@ class Api::Rest::Admin::RoutingPlanResource < BaseResource relation_name: :routing_groups, foreign_key_on: :related + # read-only: routing_plan_id is NOT NULL, so static routes can only be + # (re)assigned from the routing-plan-static-routes endpoint + has_many :static_routes, class_name: 'RoutingPlanStaticRoute', + exclude_links: %i[default self], + relation_name: :static_routes, + foreign_key_on: :related + filter :name # DEPRECATED in favor of name_eq ransack_filter :name, type: :string diff --git a/app/resources/api/rest/admin/routing_plan_static_route_resource.rb b/app/resources/api/rest/admin/routing_plan_static_route_resource.rb new file mode 100644 index 000000000..9d2ff7fe3 --- /dev/null +++ b/app/resources/api/rest/admin/routing_plan_static_route_resource.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Api::Rest::Admin::RoutingPlanStaticRouteResource < BaseResource + model_name 'Routing::RoutingPlanStaticRoute' + + paginator :paged + + # network_prefix_id is detected from prefix by Yeti::NetworkDetector, so it is read-only + attributes :prefix, :priority, :weight, :network_prefix_id + + has_one :routing_plan, class_name: 'RoutingPlan', always_include_linkage_data: true + has_one :vendor, class_name: 'Contractor', always_include_linkage_data: true + + ransack_filter :prefix, type: :string + ransack_filter :priority, type: :number + ransack_filter :weight, type: :number + ransack_filter :routing_plan_id, type: :number + ransack_filter :vendor_id, type: :number + ransack_filter :network_prefix_id, type: :number + + def self.updatable_fields(_context) + %i[ + prefix + priority + weight + routing_plan + vendor + ] + end + + def self.creatable_fields(context) + updatable_fields(context) + end +end diff --git a/config/routes.rb b/config/routes.rb index ef1f41419..f273ba017 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,6 +113,7 @@ def dasherized_resource(name, options = {}, &block) jsonapi_resources :payments, except: %i[update destroy] jsonapi_resources :routing_plans + jsonapi_resources :routing_plan_static_routes jsonapi_resources :codec_groups jsonapi_resources :disconnect_policies diff --git a/spec/acceptance/rest/admin/api/routing_plan_static_routes_spec.rb b/spec/acceptance/rest/admin/api/routing_plan_static_routes_spec.rb new file mode 100644 index 000000000..63016bde8 --- /dev/null +++ b/spec/acceptance/rest/admin/api/routing_plan_static_routes_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'rspec_api_documentation/dsl' + +RSpec.resource 'Routing plan static routes' do + include_context :acceptance_admin_user + let(:type) { 'routing-plan-static-routes' } + + required_params = %i[prefix] + optional_params = %i[priority weight] + required_relationships = %i[routing-plan vendor] + + get '/api/rest/admin/routing-plan-static-routes' do + jsonapi_filters Api::Rest::Admin::RoutingPlanStaticRouteResource._allowed_filters + + before { create_list(:routing_plan_static_route, 2) } + + example_request 'get listing' do + expect(status).to eq(200) + end + end + + get '/api/rest/admin/routing-plan-static-routes/:id' do + let(:id) { create(:routing_plan_static_route).id } + + example_request 'get specific entry' do + expect(status).to eq(200) + end + end + + post '/api/rest/admin/routing-plan-static-routes' do + parameter :type, 'Resource type (routing-plan-static-routes)', scope: :data, required: true + + jsonapi_attributes(required_params, optional_params) + jsonapi_relationships(required_relationships, []) + + let(:prefix) { '1234' } + let(:priority) { 100 } + let(:weight) { 100 } + let(:'routing-plan') { wrap_relationship(:routing_plans, create(:routing_plan, :with_static_routes).id) } + let(:vendor) { wrap_relationship(:contractors, create(:vendor).id) } + + example_request 'create new entry' do + expect(status).to eq(201) + end + end + + put '/api/rest/admin/routing-plan-static-routes/:id' do + parameter :type, 'Resource type (routing-plan-static-routes)', scope: :data, required: true + parameter :id, 'Routing plan static route ID', scope: :data, required: true + + jsonapi_attributes(required_params, optional_params) + + let(:id) { create(:routing_plan_static_route).id } + let(:prefix) { '5678' } + + example_request 'update values' do + expect(status).to eq(200) + end + end + + delete '/api/rest/admin/routing-plan-static-routes/:id' do + let(:id) { create(:routing_plan_static_route).id } + + example_request 'delete entry' do + expect(status).to eq(204) + end + end +end diff --git a/spec/features/equipment/gateways/edit_gateway_spec.rb b/spec/features/equipment/gateways/edit_gateway_spec.rb index 63e445b39..77e8fba11 100644 --- a/spec/features/equipment/gateways/edit_gateway_spec.rb +++ b/spec/features/equipment/gateways/edit_gateway_spec.rb @@ -40,7 +40,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_username with 20 chars' do subject - click_link('Сlick to fill random username') + click_link('Click to fill random username') incoming_auth_username = find_field('gateway_incoming_auth_username') expect(incoming_auth_username).to be_present expect(incoming_auth_username.value).to be_present @@ -51,7 +51,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_password with 20 chars' do subject - click_link('Сlick to fill random password') + click_link('Click to fill random password') incoming_auth_password = find_field('gateway_incoming_auth_password') expect(incoming_auth_password).to be_present expect(incoming_auth_password.value).to be_present @@ -82,7 +82,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_username with 20 chars' do subject - click_link('Сlick to fill random username') + click_link('Click to fill random username') incoming_auth_username = find_field('gateway_incoming_auth_username') expect(incoming_auth_username).to be_present expect(incoming_auth_username.value).to be_present @@ -94,7 +94,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_password with 20 chars' do subject - click_link('Сlick to fill random password') + click_link('Click to fill random password') incoming_auth_password = find_field('gateway_incoming_auth_password') expect(incoming_auth_password).to be_present expect(incoming_auth_password.value).to be_present diff --git a/spec/features/equipment/gateways/new_gateway_spec.rb b/spec/features/equipment/gateways/new_gateway_spec.rb index 4db35893f..27adb4beb 100644 --- a/spec/features/equipment/gateways/new_gateway_spec.rb +++ b/spec/features/equipment/gateways/new_gateway_spec.rb @@ -94,7 +94,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_username with 20 chars' do subject - click_link('Сlick to fill random username') + click_link('Click to fill random username') incoming_auth_username = find_field('gateway_incoming_auth_username') expect(incoming_auth_username).to be_present expect(incoming_auth_username.value).to be_present @@ -105,7 +105,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_password with 20 chars' do subject - click_link('Сlick to fill random password') + click_link('Click to fill random password') incoming_auth_password = find_field('gateway_incoming_auth_password') expect(incoming_auth_password).to be_present expect(incoming_auth_password.value).to be_present @@ -142,7 +142,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_username with 20 chars' do subject - click_link('Сlick to fill random username') + click_link('Click to fill random username') incoming_auth_username = find_field('gateway_incoming_auth_username') expect(incoming_auth_username).to be_present expect(incoming_auth_username.value).to be_present @@ -154,7 +154,7 @@ it 'should generate new credential by click on the link in hint for :incoming_auth_password with 20 chars' do subject - click_link('Сlick to fill random password') + click_link('Click to fill random password') incoming_auth_password = find_field('gateway_incoming_auth_password') expect(incoming_auth_password).to be_present expect(incoming_auth_password.value).to be_present diff --git a/spec/features/system/api_accesses/edit_api_access_spec.rb b/spec/features/system/api_accesses/edit_api_access_spec.rb index 29ca87cba..f585934e4 100644 --- a/spec/features/system/api_accesses/edit_api_access_spec.rb +++ b/spec/features/system/api_accesses/edit_api_access_spec.rb @@ -21,7 +21,7 @@ let!(:api_access) { FactoryBot.create(:api_access, attributes) } it 'should generate new credential by click on the link in hint for :login with 20 chars' do - click_link('Сlick to fill random login') + click_link('Click to fill random login') login = find_field('system_api_access_login') expect(login).to be_present expect(login.value).to be_present @@ -31,7 +31,7 @@ end it 'should generate new credential by click on the link in hint for :password with 20 chars' do - click_link('Сlick to fill random password') + click_link('Click to fill random password') password = find_field('system_api_access_password') expect(password).to be_present expect(password.value).to be_present diff --git a/spec/features/system/api_accesses/new_api_access_spec.rb b/spec/features/system/api_accesses/new_api_access_spec.rb index 936f7414c..dfdf83057 100644 --- a/spec/features/system/api_accesses/new_api_access_spec.rb +++ b/spec/features/system/api_accesses/new_api_access_spec.rb @@ -44,7 +44,7 @@ context 'when credentials is empty' do it 'should generate new credential by click on the link in hint for :login with 20 chars' do - click_link('Сlick to fill random login') + click_link('Click to fill random login') login = find_field('system_api_access_login') expect(login).to be_present expect(login.value).to be_present @@ -53,7 +53,7 @@ end it 'should generate new credential by click on the link in hint for :password with 20 chars' do - click_link('Сlick to fill random password') + click_link('Click to fill random password') password = find_field('system_api_access_password') expect(password).to be_present expect(password.value).to be_present @@ -81,7 +81,7 @@ end it 'should generate new credential by click on the link in hint for :login with 20 chars' do - click_link('Сlick to fill random login') + click_link('Click to fill random login') login = find_field('system_api_access_login') expect(login).to be_present expect(login.value).to be_present @@ -91,7 +91,7 @@ end it 'should generate new credential by click on the link in hint for :password with 20 chars' do - click_link('Сlick to fill random password') + click_link('Click to fill random password') password = find_field('system_api_access_password') expect(password).to be_present expect(password.value).to be_present diff --git a/spec/requests/api/rest/admin/routing_plan_static_routes_spec.rb b/spec/requests/api/rest/admin/routing_plan_static_routes_spec.rb new file mode 100644 index 000000000..cb13c58fc --- /dev/null +++ b/spec/requests/api/rest/admin/routing_plan_static_routes_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +RSpec.describe Api::Rest::Admin::RoutingPlanStaticRoutesController, type: :request do + include_context :json_api_admin_helpers, type: :'routing-plan-static-routes' + + describe 'GET /api/rest/admin/routing-plan-static-routes' do + subject do + get json_api_request_path, params: json_api_request_query, headers: json_api_request_headers + end + + let(:json_api_request_query) { nil } + let!(:static_routes) { FactoryBot.create_list(:routing_plan_static_route, 2) } + + include_examples :jsonapi_responds_with_pagination_links + include_examples :returns_json_api_collection do + let(:json_api_collection_ids) { static_routes.map { |r| r.id.to_s } } + end + + it_behaves_like :json_api_admin_check_authorization + + context 'with ransack filters' do + let(:factory) { :routing_plan_static_route } + + it_behaves_like :jsonapi_filters_by_string_field, :prefix + it_behaves_like :jsonapi_filters_by_number_field, :priority + it_behaves_like :jsonapi_filters_by_number_field, :weight + + it_behaves_like :jsonapi_filters_by_foreign_key, :routing_plan_id do + let(:foreign_keys_to_ids) do + static_routes.group_by(&:routing_plan_id).transform_values { |records| records.map(&:id) } + end + let!(:static_routes) { FactoryBot.create_list(:routing_plan_static_route, 3) } + end + + it_behaves_like :jsonapi_filters_by_foreign_key, :vendor_id do + let(:foreign_keys_to_ids) do + static_routes.group_by(&:vendor_id).transform_values { |records| records.map(&:id) } + end + let!(:static_routes) { FactoryBot.create_list(:routing_plan_static_route, 3) } + end + end + end + + describe 'GET /api/rest/admin/routing-plan-static-routes/{id}' do + subject do + get json_api_request_path, params: nil, headers: json_api_request_headers + end + + let(:json_api_request_path) { "#{super()}/#{record_id}" } + let(:record_id) { static_route.id.to_s } + let!(:static_route) { FactoryBot.create(:routing_plan_static_route, prefix: '1234', priority: 10, weight: 20) } + + include_examples :returns_json_api_record, relationships: %i[routing-plan vendor] do + let(:json_api_record_id) { record_id } + let(:json_api_record_attributes) do + { + prefix: '1234', + priority: 10, + weight: 20, + 'network-prefix-id': static_route.network_prefix_id + } + end + end + + it_behaves_like :json_api_admin_check_authorization + end + + describe 'POST /api/rest/admin/routing-plan-static-routes' do + subject do + post json_api_request_path, params: json_api_request_body.to_json, headers: json_api_request_headers + end + + let(:routing_plan) { FactoryBot.create(:routing_plan, :with_static_routes) } + let(:vendor) { FactoryBot.create(:vendor) } + + let(:json_api_request_body) do + { + data: { + type: json_api_resource_type, + attributes: json_api_request_attributes, + relationships: json_api_request_relationships + } + } + end + let(:json_api_request_attributes) do + { prefix: '1234', priority: 10, weight: 20 } + end + let(:json_api_request_relationships) do + { + 'routing-plan': { data: { id: routing_plan.id.to_s, type: 'routing_plans' } }, + 'vendor': { data: { id: vendor.id.to_s, type: 'contractors' } } + } + end + let(:last_static_route) { Routing::RoutingPlanStaticRoute.last! } + + include_examples :returns_json_api_record, relationships: %i[routing-plan vendor], status: 201 do + let(:json_api_record_id) { last_static_route.id.to_s } + let(:json_api_record_attributes) do + { + prefix: '1234', + priority: 10, + weight: 20, + 'network-prefix-id': last_static_route.network_prefix_id + } + end + end + + include_examples :changes_records_qty_of, Routing::RoutingPlanStaticRoute, by: 1 + + it_behaves_like :json_api_admin_check_authorization, status: 201 + + it 'assigns relationships and detects network prefix' do + subject + expect(last_static_route).to have_attributes( + routing_plan_id: routing_plan.id, + vendor_id: vendor.id, + network_prefix_id: System::NetworkPrefix.longest_match('1234')&.id + ) + end + + context 'with defaults only' do + let(:json_api_request_attributes) { { prefix: '1234' } } + + include_examples :returns_json_api_record, relationships: %i[routing-plan vendor], status: 201 do + let(:json_api_record_id) { last_static_route.id.to_s } + let(:json_api_record_attributes) do + { + prefix: '1234', + priority: 100, + weight: 100, + 'network-prefix-id': last_static_route.network_prefix_id + } + end + end + end + + context 'when routing plan does not use static routes' do + let(:routing_plan) { FactoryBot.create(:routing_plan) } + + include_examples :returns_json_api_errors, status: 422, errors: [ + { detail: 'routing-plan - is invalid' } + ] + end + + context 'when contractor is not a vendor' do + let(:vendor) { FactoryBot.create(:customer) } + + include_examples :returns_json_api_errors, status: 422, errors: [ + { detail: 'vendor - must exist' }, + { detail: "vendor - can't be blank" } + ] + end + + context 'when prefix contains spaces' do + let(:json_api_request_attributes) { { prefix: '12 34' } } + + include_examples :returns_json_api_errors, status: 422, errors: [ + { detail: 'prefix - is invalid', source: { pointer: '/data/attributes/prefix' } } + ] + end + + context 'when priority is out of range' do + let(:json_api_request_attributes) { { prefix: '1234', priority: 0 } } + + include_examples :returns_json_api_errors, status: 422, errors: [ + { detail: 'priority - must be greater than 0', source: { pointer: '/data/attributes/priority' } } + ] + end + + context 'when weight is out of range' do + let(:json_api_request_attributes) { { prefix: '1234', weight: 40_000 } } + + include_examples :returns_json_api_errors, status: 422, errors: [ + { detail: 'weight - must be less than or equal to 32767', source: { pointer: '/data/attributes/weight' } } + ] + end + end + + describe 'PATCH /api/rest/admin/routing-plan-static-routes/{id}' do + subject do + patch json_api_request_path, params: json_api_request_body.to_json, headers: json_api_request_headers + end + + let(:json_api_request_path) { "#{super()}/#{record_id}" } + let(:record_id) { static_route.id.to_s } + let!(:static_route) { FactoryBot.create(:routing_plan_static_route, prefix: '1234', priority: 10, weight: 20) } + + let(:json_api_request_body) do + { data: { id: record_id, type: json_api_resource_type, attributes: json_api_request_attributes } } + end + let(:json_api_request_attributes) { { prefix: '5678', priority: 55 } } + + include_examples :returns_json_api_record, relationships: %i[routing-plan vendor] do + let(:json_api_record_id) { record_id } + let(:json_api_record_attributes) { hash_including(prefix: '5678', priority: 55, weight: 20) } + end + + it_behaves_like :json_api_admin_check_authorization + + it 're-detects network prefix on prefix change' do + subject + expect(static_route.reload.network_prefix_id).to eq(System::NetworkPrefix.longest_match('5678')&.id) + end + + context 'with new vendor' do + let(:new_vendor) { FactoryBot.create(:vendor) } + let(:json_api_request_body) do + { + data: { + id: record_id, + type: json_api_resource_type, + relationships: { 'vendor': { data: { id: new_vendor.id.to_s, type: 'contractors' } } } + } + } + end + + it 'changes vendor' do + expect { subject }.to change { static_route.reload.vendor_id }.to(new_vendor.id) + end + end + + context 'with invalid prefix' do + let(:json_api_request_attributes) { { prefix: '12 34' } } + + include_examples :returns_json_api_errors, status: 422, errors: [ + { detail: 'prefix - is invalid', source: { pointer: '/data/attributes/prefix' } } + ] + end + end + + describe 'DELETE /api/rest/admin/routing-plan-static-routes/{id}' do + subject do + delete json_api_request_path, headers: json_api_request_headers + end + + let(:json_api_request_path) { "#{super()}/#{record_id}" } + let(:record_id) { static_route.id.to_s } + let!(:static_route) { FactoryBot.create(:routing_plan_static_route) } + + include_examples :responds_with_status, 204 + include_examples :changes_records_qty_of, Routing::RoutingPlanStaticRoute, by: -1 + + it_behaves_like :json_api_admin_check_authorization, status: 204 + end +end diff --git a/spec/requests/api/rest/admin/routing_plans_spec.rb b/spec/requests/api/rest/admin/routing_plans_spec.rb index af19d8a8a..d1d90eb3b 100644 --- a/spec/requests/api/rest/admin/routing_plans_spec.rb +++ b/spec/requests/api/rest/admin/routing_plans_spec.rb @@ -21,4 +21,36 @@ it_behaves_like :json_api_admin_check_authorization end + + describe 'GET /api/rest/admin/routing-plans/{id}' do + subject do + get json_api_request_path, params: json_api_request_query, headers: json_api_request_headers + end + + let(:json_api_request_path) { "#{super()}/#{record_id}" } + let(:json_api_request_query) { nil } + let(:record_id) { routing_plan.id.to_s } + let!(:routing_plan) { FactoryBot.create(:routing_plan, :with_static_routes) } + let!(:static_routes) { FactoryBot.create_list(:routing_plan_static_route, 2, routing_plan: routing_plan) } + + include_examples :returns_json_api_record, relationships: %i[routing-groups static-routes] do + let(:json_api_record_id) { record_id } + let(:json_api_record_attributes) { be_present } + end + + context 'with include=static-routes' do + let(:json_api_request_query) { { include: 'static-routes' } } + + it 'includes static routes' do + subject + expect(response.status).to eq(200) + expect(response_json[:data][:relationships][:'static-routes'][:data]).to match_array( + static_routes.map { |r| { id: r.id.to_s, type: 'routing-plan-static-routes' } } + ) + expect(response_json[:included]).to match_array( + static_routes.map { |r| hash_including(id: r.id.to_s, type: 'routing-plan-static-routes') } + ) + end + end + end end