From 842ed1fbd79074012d792888edf8ec4e766dfcb3 Mon Sep 17 00:00:00 2001 From: luccastera Date: Wed, 7 Feb 2024 19:50:23 +0100 Subject: [PATCH] Implemented dashboard and rpc controller --- Gemfile | 4 +- Gemfile.lock | 2 + app/controllers/application_controller.rb | 57 ++++++++++- app/controllers/dashboard_controller.rb | 12 ++- app/controllers/provisioning_controller.rb | 18 ++-- app/controllers/rpc_controller.rb | 22 ++++- app/views/dashboard/index.html.erb | 5 + app/views/dashboard/show.html.erb | 2 - config/credentials.yml.enc | 2 +- config/routes.rb | 19 ++-- public/400.html | 66 +++++++++++++ public/401.html | 65 +++++++++++++ spec/helpers/dashboard_helper_spec.rb | 1 - spec/helpers/pages_helper_spec.rb | 1 - spec/helpers/provisioning_helper_spec.rb | 1 - spec/helpers/rpc_helper_spec.rb | 1 - spec/requests/dashboard_spec.rb | 51 +++++++--- spec/requests/rpc_spec.rb | 108 +++++++++++++++++++-- 18 files changed, 386 insertions(+), 51 deletions(-) create mode 100644 app/views/dashboard/index.html.erb delete mode 100644 app/views/dashboard/show.html.erb create mode 100644 public/400.html create mode 100644 public/401.html diff --git a/Gemfile b/Gemfile index 2772a73..a1fb41f 100644 --- a/Gemfile +++ b/Gemfile @@ -84,4 +84,6 @@ group :development do gem 'annotate' end -gem "discard" \ No newline at end of file +gem "discard" + +gem "jwt" \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index d8ab6a7..bc01bdc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -123,6 +123,7 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + jwt (2.7.1) launchy (2.5.2) addressable (~> 2.8) letter_opener (1.8.1) @@ -312,6 +313,7 @@ DEPENDENCIES faker importmap-rails jbuilder + jwt letter_opener pg (~> 1.1) puma (>= 5.0) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1214c45..c5067bf 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,7 +4,62 @@ class ApplicationController < ActionController::Base def render_404 respond_to do |format| format.html { render file: "#{Rails.root}/public/404.html", status: :not_found } - format.json { render json: { error: "Not found" }, status: :not_found } + format.json { render json: json_rpc_error(404, "Not Found"), status: :not_found } end end + + def render_400 + respond_to do |format| + format.html { render file: "#{Rails.root}/public/400.html", status: :bad_request } + format.json { render json: json_rpc_error(400, "Bad Request"), status: :bad_request } + end + end + + def render_401 + respond_to do |format| + format.html { render file: "#{Rails.root}/public/401.html", status: :unauthorized } + format.json { render json: json_rpc_error(401, "Unauthorized"), status: :unauthorized } + end + end + + def validate_presence_of_jwt_token! + render_400 and return unless params['jwt'].present? + end + + def authenticate_via_jwt! # rubocop:disable Metrics/AbcSize + if session[:account_quicknode_id].present? + @current_acccount = Account.kept.find_by(quicknode_id: session[:account_quicknode_id]) + else + token = params['jwt'] + begin + decoded_tokens = JWT.decode token, Rails.application.credentials.jwt.secret, true + @decoded_token = decoded_tokens.first + logger.info "[DASH] decoded_token: #{@decoded_token}" + session[:account_quicknode_id] = @decoded_token["quicknode_id"] + session[:email] = @decoded_token["email"] + session[:name] = @decoded_token["name"] + session[:organization_name] = @decoded_token["organization_name"] + rescue JWT::VerificationError, JWT::DecodeError => e + logger.error "[BAD JWT] #{e.message}" + @error = 'forged or missing JWT' + end + return if @error.present? + + @current_acccount = Account.kept.find_by(quicknode_id: session[:account_quicknode_id]) + @error = "account not provisioned" unless @current_acccount + end + end + + private + + def json_rpc_error(code, message) + { + id: 1, + error: { + code:, + message:, + }, + jsonrpc: "2.0", + } + end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 7234ddd..8a6141d 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,9 +1,15 @@ # frozen_string_literal: true class DashboardController < ApplicationController - def show - respond_to do |format| - format.html + before_action :validate_presence_of_jwt_token! + before_action :authenticate_via_jwt! + + def index + unless @current_acccount + logger.info "[401 Unauthorized] #{@error}" + render_401 and return end + + @endpoint = @current_acccount.endpoints.kept end end diff --git a/app/controllers/provisioning_controller.rb b/app/controllers/provisioning_controller.rb index e906cc6..c10bb15 100644 --- a/app/controllers/provisioning_controller.rb +++ b/app/controllers/provisioning_controller.rb @@ -3,6 +3,8 @@ class ProvisioningController < ApplicationController skip_before_action :verify_authenticity_token + before_action :set_account_and_endpoint, only: %i[update deactivate_endpoint deprovision] + # Authenticate with HTTP Basic Auth. # To find the credentials: run bin/rails credentials:edit http_basic_authenticate_with( @@ -34,10 +36,7 @@ def provision end def update - @account = Account.find_by(quicknode_id: params["quicknode-id"]) render_404 and return unless @account - - @endpoint = Endpoint.find_by(quicknode_id: params["endpoint-id"]) render_404 and return unless @endpoint @account.update( @@ -59,10 +58,7 @@ def update end def deactivate_endpoint - @account = Account.find_by(quicknode_id: params["quicknode-id"]) render_404 and return unless @account - - @endpoint = Endpoint.find_by(quicknode_id: params["endpoint-id"]) render_404 and return unless @endpoint @endpoint.discard @@ -73,10 +69,7 @@ def deactivate_endpoint end def deprovision - @account = Account.find_by(quicknode_id: params["quicknode-id"]) render_404 and return unless @account - - @endpoint = Endpoint.find_by(quicknode_id: params["endpoint-id"]) render_404 and return unless @endpoint @account.endpoints.each(&:discard) @@ -86,4 +79,11 @@ def deprovision status: "success", } end + + private + + def set_account_and_endpoint + @account = Account.kept.find_by(quicknode_id: params["quicknode-id"]) + @endpoint = Endpoint.kept.find_by(quicknode_id: params["endpoint-id"]) + end end diff --git a/app/controllers/rpc_controller.rb b/app/controllers/rpc_controller.rb index e64e853..9aba5b0 100644 --- a/app/controllers/rpc_controller.rb +++ b/app/controllers/rpc_controller.rb @@ -1,5 +1,25 @@ # frozen_string_literal: true class RPCController < ApplicationController - def rpc; end + before_action :validate_headers + + RPC_METHODS = %w[qn_hello_world].freeze + + def rpc + @account = Account.kept.find_by(quicknode_id: request.headers["X-QUICKNODE-ID"]) + render_404 and return unless @account.present? + + render_404 and return unless RPC_METHODS.include?(params[:method]) + + render json: { jsonrpc: "2.0", result: "hello world" } + end + + private + + def validate_headers # rubocop:disable Metrics/CyclomaticComplexity + render_400 and return unless request.headers["X-QUICKNODE-ID"].present? + render_400 and return unless request.headers["X-INSTANCE-ID"].present? + render_400 and return unless request.headers["X-QN-CHAIN"].present? + render_400 and return unless request.headers["X-QN-NETWORK"].present? + end end diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb new file mode 100644 index 0000000..83fcdcb --- /dev/null +++ b/app/views/dashboard/index.html.erb @@ -0,0 +1,5 @@ +

Dashboard

+ +<%= session[:name] %> +<%= session[:email] %> +<%= session[:organization_name] %> \ No newline at end of file diff --git a/app/views/dashboard/show.html.erb b/app/views/dashboard/show.html.erb deleted file mode 100644 index c0e51d8..0000000 --- a/app/views/dashboard/show.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -

Dashboard#show

-

Find me in app/views/dashboard/show.html.erb

diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 17afd9f..0d10729 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -aTPehLiCrSJ0I7BwkJ6q/zX+M+lg34DcaNO4qvWUtg7hKDnGxIwo3xwCnHWWobKmoISPBVAm1v8amIQxUBi7+Sb2uIXcb8OoO9HwULDW6LBDvXrjncbDS4J2tmHUlkCFr+ENQpMPjOth9msJYmUm7MgWM0TLowNSFD7oVS0QdDXMkrvmjKVDpVjAQziLUCEZmbWTiDkhSgAgjxvxDbHxk+7XIkKiQVTepindc6e3cXOviVB7O49f/p/cHaIwsEkjxD7XpHGNa8bDdbSPqQf7JFbP+Yi5AwE+wu7Sw3oWTd4bWOtEYm/8AEBBmjYieeh+PPPsnXwrRVtTGAHp/CPi6VfvJhU1jNSODQXEmdtFt7avgQfjn1yjZ9c7nj8XmJXo66tycV56VjJMZ1SndiA=--Fxhv/oUs/THaAbR8--tbwfBVPso0I6FZHZb5K6IQ== \ No newline at end of file +yiQVMZFkaATpdGSSl2kUoXV1hl8UQItLV2LawNnnlpvooJz9n2FasjKQSvO6xvmEgfy8DQ9Tt9WODHB+fCIMCqKY1kWS4cMVb6s3orYA3t08O7lNzGN0p3iPlI564m1ITu7jJ/k31SLmJAiRdfelLsgjhP6BrTaFUuqnyxuKkd4QVsZc0cVQfu5QvMyoKrIfTyY/lMvjtj6pcI+5laerCsI1U3AQuadTuXx1/Csz2fqLnWITEPz0b6X8SoPWvEoHAvVyT+u6Qf/Wm6VmSKqu5oRqota0MqBoyx5vMQsIQ/y5UChn3pCz5XnwZOdSYxj36PpzyZ6PGvgf9AGhsCDA7BtcADd8Ia6bWkwFSTaNWnCk1XzE5eCvC5O705N9EyOJtxJdmd656H5QV+MNkmv1jQ8iLLIgqKddj/KWwhzmawP3IDq7fJalpzk=--tVJ/MS2Xvc39LbvV--LAAV2BfvBCnFIsPfb4Ztbg== \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 7465b53..d5b0638 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,15 +9,18 @@ root "pages#index" # Dashboard - get 'dashboard/:quicknode_id' => 'dashboard#show', as: 'dashboard' + get 'dashboard' => 'dashboard#index', as: 'dashboard' - # Provisioning - post 'provision' => "provisioning#provision", as: 'provision' - put 'update' => "provisioning#update", as: 'update' - delete 'deactivate_endpoint' => "provisioning#deactivate_endpoint", as: 'deactivate_endpoint' - delete 'deprovision' => "provisioning#deprovision", as: 'deprovision' + # The methods below are APIs that should only use JSON format + defaults format: :json do + # Provisioning + post 'provision' => "provisioning#provision", as: 'provision' + put 'update' => "provisioning#update", as: 'update' + delete 'deactivate_endpoint' => "provisioning#deactivate_endpoint", as: 'deactivate_endpoint' + delete 'deprovision' => "provisioning#deprovision", as: 'deprovision' - # RPC - post 'rpc' => "rpc#rpc", as: 'rpc' + # RPC + post 'rpc' => "rpc#rpc", as: 'rpc' + end end diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..67e1c8f --- /dev/null +++ b/public/400.html @@ -0,0 +1,66 @@ + + + + Bad Request (400) + + + + + + +
+
+

Bad Request

+

The request is not valid.

+
+
+ + diff --git a/public/401.html b/public/401.html new file mode 100644 index 0000000..c4f6d43 --- /dev/null +++ b/public/401.html @@ -0,0 +1,65 @@ + + + + Unauthorized (401) + + + + + + +
+
+

Unauthorized.

+
+
+ + diff --git a/spec/helpers/dashboard_helper_spec.rb b/spec/helpers/dashboard_helper_spec.rb index 12cff9a..4222146 100644 --- a/spec/helpers/dashboard_helper_spec.rb +++ b/spec/helpers/dashboard_helper_spec.rb @@ -11,5 +11,4 @@ # end # end RSpec.describe DashboardHelper, type: :helper do - pending "add some examples to (or delete) #{__FILE__}" end diff --git a/spec/helpers/pages_helper_spec.rb b/spec/helpers/pages_helper_spec.rb index 2960941..15c26b7 100644 --- a/spec/helpers/pages_helper_spec.rb +++ b/spec/helpers/pages_helper_spec.rb @@ -11,5 +11,4 @@ # end # end RSpec.describe PagesHelper, type: :helper do - pending "add some examples to (or delete) #{__FILE__}" end diff --git a/spec/helpers/provisioning_helper_spec.rb b/spec/helpers/provisioning_helper_spec.rb index 53a7bf0..d027cda 100644 --- a/spec/helpers/provisioning_helper_spec.rb +++ b/spec/helpers/provisioning_helper_spec.rb @@ -13,5 +13,4 @@ # end # end RSpec.describe ProvisioningHelper, type: :helper do - pending "add some examples to (or delete) #{__FILE__}" end diff --git a/spec/helpers/rpc_helper_spec.rb b/spec/helpers/rpc_helper_spec.rb index 65bad00..67f1cd8 100644 --- a/spec/helpers/rpc_helper_spec.rb +++ b/spec/helpers/rpc_helper_spec.rb @@ -11,5 +11,4 @@ # end # end RSpec.describe RPCHelper, type: :helper do - pending "add some examples to (or delete) #{__FILE__}" end diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb index f1a7252..ab3e397 100644 --- a/spec/requests/dashboard_spec.rb +++ b/spec/requests/dashboard_spec.rb @@ -2,29 +2,54 @@ RSpec.describe "Dashboards", type: :request do let(:account) { create(:account) } + let(:invalid_jwt_payload) { + { + quicknode_id: "bad-quicknode-id", + email: "hello@example.com", + name: "John Smith", + organization_name: "Example", + } + } + let(:valid_jwt_payload) { + { + quicknode_id: account.quicknode_id, + email: "hello@example.com", + name: "John Smith", + organization_name: "Example", + } + } + let(:invalid_jwt_token) { JWT.encode(invalid_jwt_payload, Rails.application.credentials.jwt.secret, 'HS256') } + let(:valid_jwt_token) { JWT.encode(valid_jwt_payload, Rails.application.credentials.jwt.secret, 'HS256') } describe "GET /dashboard/:quicknode_id" do - it "should 400 if missing jwt token in params" - - it "should 503 if the jwt token is invalid" - - it "should 503 if the jwt token is expired" - - it "should 503 if the jwt token is not allowed" - - it "should 503 if the jwt token is not supported" + it "should 400 if missing jwt token in params" do + get "/dashboard" + expect(response).to have_http_status(400) + end - it "should 503 if the jwt token is not well formed" + it "should 401 if the jwt token is invalid" do + get "/dashboard?jwt=invalid_token" + expect(response).to have_http_status(401) + end - it "should 404 if the account is not provisioned" + it "should 401 if the account is not provisioned" do + get "/dashboard?jwt=#{invalid_jwt_token}" + expect(response).to have_http_status(401) + end describe "valid JWT token" do it "returns http success" do - get "/dashboard/#{account.quicknode_id}" + get "/dashboard?jwt=#{valid_jwt_token}" expect(response).to have_http_status(:success) end - it "should render dashboard" + it "should render dashboard" do + get "/dashboard?jwt=#{valid_jwt_token}" + expect(response.body).to include("Dashboard") + expect(response.body).to include("John Smith") + expect(response.body).to include("hello@example.com") + expect(response.body).to include("Example") + end end end diff --git a/spec/requests/rpc_spec.rb b/spec/requests/rpc_spec.rb index e6f3dfa..de876a8 100644 --- a/spec/requests/rpc_spec.rb +++ b/spec/requests/rpc_spec.rb @@ -2,13 +2,105 @@ RSpec.describe "RPC", type: :request do describe "POST /rpc" do - it "should 400 if missing X-QUICKNODE-ID header" - it "should 400 if missing X-INSTANCE-ID header" - it "should 400 if missing X-QN-CHAIN header" - it "should 400 if missing X-QN-NETWORK header" - it "should 404 if account was not provisioned" - it "should 404 if the method is not supported" - - it "should 200 if the method is supported" + let (:account) { create(:account) } + let (:discarded_account) { create(:account, discarded_at: 2.months.ago) } + + it "should 400 if missing X-QUICKNODE-ID header" do + post "/rpc", headers: { + 'X-INSTANCE-ID': 'foobar', + 'X-QN-CHAIN': 'ethereum', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(400) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(400) + end + + it "should 400 if missing X-INSTANCE-ID header" do + post "/rpc", headers: { + 'X-QUICKNODE-ID': 'quicknode-id', + 'X-QN-CHAIN': 'ethereum', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(400) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(400) + end + + it "should 400 if missing X-QN-CHAIN header" do + post "/rpc", headers: { + 'X-QUICKNODE-ID': 'quicknode-id', + 'X-INSTANCE-ID': 'foobar', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(400) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(400) + end + + it "should 400 if missing X-QN-NETWORK header" do + post "/rpc", headers: { + 'X-QUICKNODE-ID': 'quicknode-id', + 'X-INSTANCE-ID': 'foobar', + 'X-QN-CHAIN': 'ethereum', + } + expect(response).to have_http_status(400) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(400) + end + + it "should 404 if account was not provisioned" do + post "/rpc", headers: { + 'X-QUICKNODE-ID': 'quicknode-id', + 'X-INSTANCE-ID': 'foobar', + 'X-QN-CHAIN': 'ethereum', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(404) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(404) + end + + it "should 404 if account was discarded" do + post "/rpc", headers: { + 'X-QUICKNODE-ID': discarded_account.quicknode_id, + 'X-INSTANCE-ID': 'foobar', + 'X-QN-CHAIN': 'ethereum', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(404) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(404) + end + + it "should 404 if the method is not supported" do + post "/rpc", params: { + method: "unsupported_method", + }, + headers: { + 'X-QUICKNODE-ID': account.quicknode_id, + 'X-INSTANCE-ID': 'foobar', + 'X-QN-CHAIN': 'ethereum', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(404) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["error"]["code"]).to eq(404) + end + + it "should 200 if the method is supported" do + post "/rpc", params: { + method: "qn_hello_world", + }, + headers: { + 'X-QUICKNODE-ID': account.quicknode_id, + 'X-INSTANCE-ID': 'foobar', + 'X-QN-CHAIN': 'ethereum', + 'X-QN-NETWORK': 'mainet', + } + expect(response).to have_http_status(200) + expect(JSON.parse(response.body)["jsonrpc"]).to eq("2.0") + expect(JSON.parse(response.body)["result"]).to eq("hello world") + end end end