diff --git a/CHANGELOG.md b/CHANGELOG.md index c808c89fde..ca4991d5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ - Implement tiered Rack::Attack throttles [#1254](https://github.com/portagenetwork/roadmap/pull/1254) + - Add Internal v2 API Access Token Generation for Users [#1279](https://github.com/portagenetwork/roadmap/pull/1279) + - Add `bundle exec rails db:migrate` to entrypoint.sh [#1278](https://github.com/portagenetwork/roadmap/pull/1278) + - Add copy button next to V2 API Token [#1283](https://github.com/portagenetwork/roadmap/pull/1283) + + - Initial v2 API Implementation & Doorkeeper OAuth Integration [#1276](https://github.com/portagenetwork/roadmap/pull/1276) + ### Changed - Upgrade ROR API From V1 to V2 [#1247](https://github.com/portagenetwork/roadmap/pull/1247) diff --git a/Gemfile b/Gemfile index fcc7af1783..9414e12657 100644 --- a/Gemfile +++ b/Gemfile @@ -88,6 +88,8 @@ gem 'devise' # An invitation strategy for Devise (https://github.com/scambra/devise_invitable) gem 'devise_invitable' +gem 'doorkeeper' + # A generalized Rack framework for multiple-provider authentication. # (https://github.com/omniauth/omniauth) gem 'omniauth' diff --git a/Gemfile.lock b/Gemfile.lock index 23c860084e..5cb460c4eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,723 +1,726 @@ -GIT - remote: https://github.com/ualbertalib/translation_io_rails - revision: f60a5427372b51348eb218755e275f0f34d19746 - specs: - translation (1.22) - gettext (~> 3.2, >= 3.2.5) - -GEM - remote: https://rubygems.org/ - specs: - actioncable (6.1.7.9) - actionpack (= 6.1.7.9) - activesupport (= 6.1.7.9) - nio4r (~> 2.0) - websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.9) - actionpack (= 6.1.7.9) - activejob (= 6.1.7.9) - activerecord (= 6.1.7.9) - activestorage (= 6.1.7.9) - activesupport (= 6.1.7.9) - mail (>= 2.7.1) - actionmailer (6.1.7.9) - actionpack (= 6.1.7.9) - actionview (= 6.1.7.9) - activejob (= 6.1.7.9) - activesupport (= 6.1.7.9) - mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 2.0) - actionpack (6.1.7.9) - actionview (= 6.1.7.9) - activesupport (= 6.1.7.9) - rack (~> 2.0, >= 2.0.9) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.9) - actionpack (= 6.1.7.9) - activerecord (= 6.1.7.9) - activestorage (= 6.1.7.9) - activesupport (= 6.1.7.9) - nokogiri (>= 1.8.5) - actionview (6.1.7.9) - activesupport (= 6.1.7.9) - builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.9) - activesupport (= 6.1.7.9) - globalid (>= 0.3.6) - activemodel (6.1.7.9) - activesupport (= 6.1.7.9) - activerecord (6.1.7.9) - activemodel (= 6.1.7.9) - activesupport (= 6.1.7.9) - activerecord-nulldb-adapter (1.1.1) - activerecord (>= 6.0, < 8.1) - activerecord_json_validator (2.1.5) - activerecord (>= 4.2.0, < 8) - json_schemer (~> 0.2.18) - activestorage (6.1.7.9) - actionpack (= 6.1.7.9) - activejob (= 6.1.7.9) - activerecord (= 6.1.7.9) - activesupport (= 6.1.7.9) - marcel (~> 1.0) - mini_mime (>= 1.1.0) - activesupport (6.1.7.9) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - aes_key_wrap (1.1.0) - annotate (3.2.0) - activerecord (>= 3.2, < 8.0) - rake (>= 10.4, < 14.0) - annotate_gem (0.0.14) - bundler (>= 1.1) - api-pagination (5.0.0) - ast (2.4.2) - attr_required (1.0.2) - autoprefixer-rails (10.4.19.0) - execjs (~> 2) - base64 (0.1.2) - bcrypt (3.1.20) - better_errors (2.10.1) - erubi (>= 1.0.0) - rack (>= 0.9.0) - rouge (>= 1.0.0) - bigdecimal (3.2.2) - bindata (2.5.0) - bindex (0.8.1) - binding_of_caller (1.0.1) - debug_inspector (>= 1.2.0) - bootsnap (1.18.3) - msgpack (~> 1.2) - brakeman (7.0.0) - racc - builder (3.3.0) - bullet (7.1.6) - activesupport (>= 3.0.0) - uniform_notifier (~> 1.11) - bundle-audit (0.1.0) - bundler-audit - bundler-audit (0.9.1) - bundler (>= 1.2.0, < 3) - thor (~> 1.0) - byebug (11.1.3) - capybara (3.39.2) - addressable - matrix - mini_mime (>= 0.1.3) - nokogiri (~> 1.8) - rack (>= 1.6.0) - rack-test (>= 0.6.3) - regexp_parser (>= 1.5, < 3.0) - xpath (~> 3.2) - claide (1.1.0) - claide-plugins (0.9.2) - cork - nap - open4 (~> 1.3) - coderay (1.1.3) - colored2 (3.1.2) - concurrent-ruby (1.3.4) - contact_us (1.2.0) - rails (>= 4.2.0) - cork (0.3.0) - colored2 (~> 3.1) - crack (1.0.0) - bigdecimal - rexml - crass (1.0.6) - cssbundling-rails (1.4.1) - railties (>= 6.0.0) - csv (3.3.5) - danger (9.4.3) - claide (~> 1.0) - claide-plugins (>= 0.9.2) - colored2 (~> 3.1) - cork (~> 0.1) - faraday (>= 0.9.0, < 3.0) - faraday-http-cache (~> 2.0) - git (~> 1.13) - kramdown (~> 2.3) - kramdown-parser-gfm (~> 1.0) - no_proxy_fix - octokit (>= 4.0) - terminal-table (>= 1, < 4) - database_cleaner (2.0.2) - database_cleaner-active_record (>= 2, < 3) - database_cleaner-active_record (2.1.0) - activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) - database_cleaner-core (2.0.1) - date (3.4.1) - debug_inspector (1.2.0) - devise (4.9.4) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) - devise_invitable (2.0.9) - actionmailer (>= 5.0) - devise (>= 4.6) - diff-lcs (1.5.1) - dotenv (2.8.1) - dotenv-rails (2.8.1) - dotenv (= 2.8.1) - railties (>= 3.2) - dragonfly (1.4.1) - addressable (~> 2.3) - multi_json (~> 1.0) - ostruct (~> 0.6.1) - rack (>= 1.3) - dragonfly-s3_data_store (1.3.0) - dragonfly (~> 1.0) - fog-aws - ecma-re-validator (0.4.0) - regexp_parser (~> 2.2) - email_validator (2.2.4) - activemodel - erubi (1.13.1) - excon (0.104.0) - execjs (2.10.0) - factory_bot (6.2.1) - activesupport (>= 5.0.0) - factory_bot_rails (6.2.0) - factory_bot (~> 6.2.0) - railties (>= 5.0.0) - faker (3.4.1) - i18n (>= 1.8.11, < 2) - faraday (2.9.0) - faraday-net_http (>= 2.0, < 3.2) - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) - faraday-http-cache (2.5.1) - faraday (>= 0.8) - faraday-net_http (3.1.0) - net-http - ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86_64-linux-gnu) - ffi (1.17.2-x86_64-linux-musl) - flag_shih_tzu (0.3.23) - fog-aws (3.21.0) - fog-core (~> 2.1) - fog-json (~> 1.1) - fog-xml (~> 0.1) - fog-core (2.3.0) - builder - excon (~> 0.71) - formatador (>= 0.2, < 2.0) - mime-types - fog-json (1.2.0) - fog-core - multi_json (~> 1.10) - fog-xml (0.1.4) - fog-core - nokogiri (>= 1.5.11, < 2.0.0) - formatador (1.1.0) - forwardable (1.3.3) - fuubar (2.5.1) - rspec-core (~> 3.0) - ruby-progressbar (~> 1.4) - gettext (3.5.1) - erubi - locale (>= 2.0.5) - prime - racc - text (>= 1.3.0) - git (1.19.1) - addressable (~> 2.8) - rchardet (~> 1.8) - globalid (1.2.1) - activesupport (>= 6.1) - guard (2.19.1) - formatador (>= 0.2.4) - listen (>= 2.7, < 4.0) - logger (~> 1.6) - lumberjack (>= 1.0.12, < 2.0) - nenv (~> 0.1) - notiffany (~> 0.0) - ostruct (~> 0.6) - pry (>= 0.13.0) - shellany (~> 0.0) - thor (>= 0.18.1) - hana (1.3.7) - hashdiff (1.1.0) - hashie (5.0.0) - highline (3.1.2) - reline - htmltoword (1.1.1) - actionpack - nokogiri - rubyzip (>= 1.0) - httparty (0.23.1) - csv - mini_mime (>= 1.0.0) - multi_xml (>= 0.5.2) - i18n (1.14.6) - concurrent-ruby (~> 1.0) - io-console (0.8.0) - jbuilder (2.12.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) - jsbundling-rails (1.3.0) - railties (>= 6.0.0) - json (2.7.2) - json-jwt (1.16.6) - activesupport (>= 4.2) - aes_key_wrap - base64 - bindata - faraday (~> 2.0) - faraday-follow_redirects - json_schemer (0.2.25) - ecma-re-validator (~> 0.3) - hana (~> 1.3) - regexp_parser (~> 2.0) - simpleidn (~> 0.2) - uri_template (~> 0.7) - jwt (2.10.1) - base64 - kaminari (1.2.2) - activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.2) - kaminari-activerecord (= 1.2.2) - kaminari-core (= 1.2.2) - kaminari-actionview (1.2.2) - actionview - kaminari-core (= 1.2.2) - kaminari-activerecord (1.2.2) - activerecord - kaminari-core (= 1.2.2) - kaminari-core (1.2.2) - kramdown (2.4.0) - rexml - kramdown-parser-gfm (1.1.0) - kramdown (~> 2.0) - language_server-protocol (3.17.0.3) - ledermann-rails-settings (2.6.2) - activerecord (>= 6.1) - listen (3.9.0) - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) - locale (2.1.4) - logger (1.7.0) - loofah (2.24.0) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - lumberjack (1.2.10) - mail (2.8.1) - mini_mime (>= 0.1.1) - net-imap - net-pop - net-smtp - marcel (1.0.4) - matrix (0.4.2) - method_source (1.1.0) - mime-types (3.5.1) - mime-types-data (~> 3.2015) - mime-types-data (3.2023.1003) - mimemagic (0.4.3) - nokogiri (~> 1) - rake - mini_mime (1.1.5) - minitest (5.25.4) - mocha (2.7.1) - ruby2_keywords (>= 0.0.5) - msgpack (1.7.2) - multi_json (1.15.0) - multi_xml (0.7.1) - bigdecimal (~> 3.1) - mysql2 (0.5.6) - nap (1.1.0) - nenv (0.3.0) - net-http (0.4.1) - uri - net-imap (0.4.20) - date - net-protocol - net-pop (0.1.2) - net-protocol - net-protocol (0.2.2) - timeout - net-smtp (0.5.0) - net-protocol - nio4r (2.7.4) - no_proxy_fix (0.1.2) - nokogiri (1.18.9-arm64-darwin) - racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) - racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-musl) - racc (~> 1.4) - notiffany (0.1.3) - nenv (~> 0.1) - shellany (~> 0.0) - oauth2 (2.0.9) - faraday (>= 0.17.3, < 3.0) - jwt (>= 1.0, < 3.0) - multi_xml (~> 0.5) - rack (>= 1.2, < 4) - snaky_hash (~> 2.0) - version_gem (~> 1.1) - octokit (8.1.0) - base64 - faraday (>= 1, < 3) - sawyer (~> 0.9) - omniauth (2.1.4) - hashie (>= 3.4.6) - logger - rack (>= 2.2.3) - rack-protection - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) - omniauth (~> 2.0) - omniauth-orcid (2.1.1) - omniauth-oauth2 (~> 1.3) - ruby_dig (~> 0.0.2) - omniauth-rails_csrf_protection (1.0.2) - actionpack (>= 4.2) - omniauth (~> 2.0) - omniauth-shibboleth (1.3.0) - omniauth (>= 1.0.0) - omniauth_openid_connect (0.7.1) - omniauth (>= 1.9, < 3) - openid_connect (~> 2.2) - open4 (1.3.4) - openid_connect (2.3.0) - activemodel - attr_required (>= 1.0.0) - email_validator - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.16) - mail - rack-oauth2 (~> 2.2) - swd (~> 2.0) - tzinfo - validate_url - webfinger (~> 2.0) - options (2.3.2) - orm_adapter (0.5.0) - ostruct (0.6.1) - parallel (1.26.3) - parser (3.2.2.4) - ast (~> 2.4.1) - racc - pg (1.5.9) - prime (0.1.4) - forwardable - singleton - progress_bar (1.3.4) - highline (>= 1.6) - options (~> 2.3.0) - pry (0.15.2) - coderay (~> 1.1) - method_source (~> 1.0) - public_suffix (6.0.1) - puma (6.6.0) - nio4r (~> 2.0) - pundit (2.3.2) - activesupport (>= 3.0.0) - pundit-matchers (4.0.0) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - racc (1.8.1) - rack (2.2.21) - rack-attack (6.7.0) - rack (>= 1.0, < 4) - rack-mini-profiler (3.3.1) - rack (>= 1.2.0) - rack-oauth2 (2.2.1) - activesupport - attr_required - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.11.0) - rack (>= 2.1.0) - rack-protection (3.2.0) - base64 (>= 0.1.0) - rack (~> 2.2, >= 2.2.4) - rack-test (2.2.0) - rack (>= 1.3) - rails (6.1.7.9) - actioncable (= 6.1.7.9) - actionmailbox (= 6.1.7.9) - actionmailer (= 6.1.7.9) - actionpack (= 6.1.7.9) - actiontext (= 6.1.7.9) - actionview (= 6.1.7.9) - activejob (= 6.1.7.9) - activemodel (= 6.1.7.9) - activerecord (= 6.1.7.9) - activestorage (= 6.1.7.9) - activesupport (= 6.1.7.9) - bundler (>= 1.15.0) - railties (= 6.1.7.9) - sprockets-rails (>= 2.0.0) - rails-controller-testing (1.0.5) - actionpack (>= 5.0.1.rc1) - actionview (>= 5.0.1.rc1) - activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.2.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) - nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (6.1.7.9) - actionpack (= 6.1.7.9) - activesupport (= 6.1.7.9) - method_source - rake (>= 12.2) - thor (~> 1.0) - rainbow (3.1.1) - rake (13.2.1) - rb-fsevent (0.11.2) - rb-inotify (0.11.1) - ffi (~> 1.0) - rchardet (1.8.0) - recaptcha (5.18.0) - regexp_parser (2.8.2) - reline (0.6.1) - io-console (~> 0.5) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rexml (3.4.2) - rollbar (3.5.2) - rouge (4.1.3) - rspec-collection_matchers (1.2.1) - rspec-expectations (>= 2.99.0.beta1) - rspec-core (3.13.2) - rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.13.0) - rspec-rails (6.1.5) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.2) - rubocop (1.57.1) - base64 (~> 0.1.1) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.2.2.4) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.1, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) - parser (>= 3.2.1.0) - rubocop-i18n (3.0.0) - rubocop (~> 1.0) - rubocop-performance (1.19.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - ruby-progressbar (1.13.0) - ruby2_keywords (0.0.5) - ruby_dig (0.0.2) - rubyzip (2.3.2) - sawyer (0.9.2) - addressable (>= 2.3.5) - faraday (>= 0.17.3, < 3) - selenium-webdriver (4.16.0) - rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) - websocket (~> 1.0) - shellany (0.0.1) - shoulda (4.0.0) - shoulda-context (~> 2.0) - shoulda-matchers (~> 4.0) - shoulda-context (2.0.0) - shoulda-matchers (4.5.1) - activesupport (>= 4.2.0) - simpleidn (0.2.1) - unf (~> 0.1.4) - singleton (0.3.0) - snaky_hash (2.0.1) - hashie - version_gem (~> 1.1, >= 1.1.1) - spring (4.2.1) - spring-commands-rspec (1.0.4) - spring (>= 0.9.1) - spring-watcher-listen (2.1.0) - listen (>= 2.7, < 4.0) - spring (>= 4) - sprockets (4.2.1) - concurrent-ruby (~> 1.0) - rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) - sprockets (>= 3.0.0) - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - text (1.3.1) - thor (1.4.0) - timeout (0.4.3) - tomparse (0.4.2) - turbo-rails (2.0.5) - actionpack (>= 6.0.0) - activejob (>= 6.0.0) - railties (>= 6.0.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.5.0) - uniform_notifier (1.16.0) - uri (0.13.2) - uri_template (0.7.0) - validate_url (1.0.15) - activemodel (>= 3.0.0) - public_suffix - version_gem (1.1.3) - warden (1.2.9) - rack (>= 2.0.9) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) - bindex (>= 0.4.0) - railties (>= 6.0.0) - webfinger (2.1.3) - activesupport - faraday (~> 2.0) - faraday-follow_redirects - webmock (3.23.1) - addressable (>= 2.8.0) - crack (>= 0.3.2) - hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.10) - websocket-driver (0.7.6) - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - wicked_pdf (2.8.2) - activesupport - ostruct - wkhtmltopdf-binary (0.12.6.10) - xpath (3.2.0) - nokogiri (~> 1.8) - yard (0.9.37) - yard-tomdoc (0.7.1) - tomparse (>= 0.4.0) - yard - zeitwerk (2.6.18) - -PLATFORMS - arm64-darwin-21 - arm64-darwin-22 - x86_64-linux - x86_64-linux-musl - -DEPENDENCIES - activerecord-nulldb-adapter - activerecord_json_validator - annotate - annotate_gem - api-pagination - autoprefixer-rails - better_errors - binding_of_caller - bootsnap - brakeman - bullet - bundle-audit - byebug - capybara - contact_us - cssbundling-rails - danger - database_cleaner - devise - devise_invitable - dotenv-rails - dragonfly - dragonfly-s3_data_store - factory_bot_rails - faker - flag_shih_tzu - fuubar - guard - htmltoword - httparty - jbuilder - jsbundling-rails - jwt - kaminari - ledermann-rails-settings - listen - mail (= 2.8.1) - mimemagic - mocha - mysql2 - omniauth - omniauth-orcid - omniauth-rails_csrf_protection - omniauth-shibboleth - omniauth_openid_connect - parallel - pg - progress_bar - puma - pundit - pundit-matchers - rack-attack (~> 6.6, >= 6.6.1) - rack-mini-profiler - rails (~> 6.1) - rails-controller-testing - recaptcha - rollbar - rspec-collection_matchers - rspec-rails - rubocop (= 1.57.1) - rubocop-i18n - rubocop-performance - selenium-webdriver - shoulda - spring - spring-commands-rspec - spring-watcher-listen - text - translation! - turbo-rails - web-console - webmock - wicked_pdf - wkhtmltopdf-binary - yard - yard-tomdoc - -RUBY VERSION - ruby 3.1.4p223 - -BUNDLED WITH - 2.4.15 +GIT + remote: https://github.com/ualbertalib/translation_io_rails + revision: f60a5427372b51348eb218755e275f0f34d19746 + specs: + translation (1.22) + gettext (~> 3.2, >= 3.2.5) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (6.1.7.9) + actionpack (= 6.1.7.9) + activesupport (= 6.1.7.9) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.1.7.9) + actionpack (= 6.1.7.9) + activejob (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + mail (>= 2.7.1) + actionmailer (6.1.7.9) + actionpack (= 6.1.7.9) + actionview (= 6.1.7.9) + activejob (= 6.1.7.9) + activesupport (= 6.1.7.9) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (6.1.7.9) + actionview (= 6.1.7.9) + activesupport (= 6.1.7.9) + rack (~> 2.0, >= 2.0.9) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.1.7.9) + actionpack (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + nokogiri (>= 1.8.5) + actionview (6.1.7.9) + activesupport (= 6.1.7.9) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + activejob (6.1.7.9) + activesupport (= 6.1.7.9) + globalid (>= 0.3.6) + activemodel (6.1.7.9) + activesupport (= 6.1.7.9) + activerecord (6.1.7.9) + activemodel (= 6.1.7.9) + activesupport (= 6.1.7.9) + activerecord-nulldb-adapter (1.1.1) + activerecord (>= 6.0, < 8.1) + activerecord_json_validator (2.1.5) + activerecord (>= 4.2.0, < 8) + json_schemer (~> 0.2.18) + activestorage (6.1.7.9) + actionpack (= 6.1.7.9) + activejob (= 6.1.7.9) + activerecord (= 6.1.7.9) + activesupport (= 6.1.7.9) + marcel (~> 1.0) + mini_mime (>= 1.1.0) + activesupport (6.1.7.9) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) + annotate (3.2.0) + activerecord (>= 3.2, < 8.0) + rake (>= 10.4, < 14.0) + annotate_gem (0.0.14) + bundler (>= 1.1) + api-pagination (5.0.0) + ast (2.4.2) + attr_required (1.0.2) + autoprefixer-rails (10.4.19.0) + execjs (~> 2) + base64 (0.1.2) + bcrypt (3.1.20) + better_errors (2.10.1) + erubi (>= 1.0.0) + rack (>= 0.9.0) + rouge (>= 1.0.0) + bigdecimal (3.2.2) + bindata (2.5.0) + bindex (0.8.1) + binding_of_caller (1.0.1) + debug_inspector (>= 1.2.0) + bootsnap (1.18.3) + msgpack (~> 1.2) + brakeman (7.0.0) + racc + builder (3.3.0) + bullet (7.1.6) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) + bundle-audit (0.1.0) + bundler-audit + bundler-audit (0.9.1) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + byebug (11.1.3) + capybara (3.39.2) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + claide (1.1.0) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + coderay (1.1.3) + colored2 (3.1.2) + concurrent-ruby (1.3.4) + contact_us (1.2.0) + rails (>= 4.2.0) + cork (0.3.0) + colored2 (~> 3.1) + crack (1.0.0) + bigdecimal + rexml + crass (1.0.6) + cssbundling-rails (1.4.1) + railties (>= 6.0.0) + csv (3.3.5) + danger (9.4.3) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (~> 3.1) + cork (~> 0.1) + faraday (>= 0.9.0, < 3.0) + faraday-http-cache (~> 2.0) + git (~> 1.13) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.0) + no_proxy_fix + octokit (>= 4.0) + terminal-table (>= 1, < 4) + database_cleaner (2.0.2) + database_cleaner-active_record (>= 2, < 3) + database_cleaner-active_record (2.1.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) + date (3.4.1) + debug_inspector (1.2.0) + devise (4.9.4) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 4.1.0) + responders + warden (~> 1.2.3) + devise_invitable (2.0.9) + actionmailer (>= 5.0) + devise (>= 4.6) + diff-lcs (1.5.1) + doorkeeper (5.8.2) + railties (>= 5) + dotenv (2.8.1) + dotenv-rails (2.8.1) + dotenv (= 2.8.1) + railties (>= 3.2) + dragonfly (1.4.1) + addressable (~> 2.3) + multi_json (~> 1.0) + ostruct (~> 0.6.1) + rack (>= 1.3) + dragonfly-s3_data_store (1.3.0) + dragonfly (~> 1.0) + fog-aws + ecma-re-validator (0.4.0) + regexp_parser (~> 2.2) + email_validator (2.2.4) + activemodel + erubi (1.13.1) + excon (0.104.0) + execjs (2.10.0) + factory_bot (6.2.1) + activesupport (>= 5.0.0) + factory_bot_rails (6.2.0) + factory_bot (~> 6.2.0) + railties (>= 5.0.0) + faker (3.4.1) + i18n (>= 1.8.11, < 2) + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) + faraday-http-cache (2.5.1) + faraday (>= 0.8) + faraday-net_http (3.1.0) + net-http + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + flag_shih_tzu (0.3.23) + fog-aws (3.21.0) + fog-core (~> 2.1) + fog-json (~> 1.1) + fog-xml (~> 0.1) + fog-core (2.3.0) + builder + excon (~> 0.71) + formatador (>= 0.2, < 2.0) + mime-types + fog-json (1.2.0) + fog-core + multi_json (~> 1.10) + fog-xml (0.1.4) + fog-core + nokogiri (>= 1.5.11, < 2.0.0) + formatador (1.1.0) + forwardable (1.3.3) + fuubar (2.5.1) + rspec-core (~> 3.0) + ruby-progressbar (~> 1.4) + gettext (3.5.1) + erubi + locale (>= 2.0.5) + prime + racc + text (>= 1.3.0) + git (1.19.1) + addressable (~> 2.8) + rchardet (~> 1.8) + globalid (1.2.1) + activesupport (>= 6.1) + guard (2.19.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + logger (~> 1.6) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + ostruct (~> 0.6) + pry (>= 0.13.0) + shellany (~> 0.0) + thor (>= 0.18.1) + hana (1.3.7) + hashdiff (1.1.0) + hashie (5.0.0) + highline (3.1.2) + reline + htmltoword (1.1.1) + actionpack + nokogiri + rubyzip (>= 1.0) + httparty (0.23.1) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.6) + concurrent-ruby (~> 1.0) + io-console (0.8.0) + jbuilder (2.12.0) + actionview (>= 5.0.0) + activesupport (>= 5.0.0) + jsbundling-rails (1.3.0) + railties (>= 6.0.0) + json (2.7.2) + json-jwt (1.16.6) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects + json_schemer (0.2.25) + ecma-re-validator (~> 0.3) + hana (~> 1.3) + regexp_parser (~> 2.0) + simpleidn (~> 0.2) + uri_template (~> 0.7) + jwt (2.10.1) + base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + language_server-protocol (3.17.0.3) + ledermann-rails-settings (2.6.2) + activerecord (>= 6.1) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + locale (2.1.4) + logger (1.7.0) + loofah (2.24.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + lumberjack (1.2.10) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) + matrix (0.4.2) + method_source (1.1.0) + mime-types (3.5.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2023.1003) + mimemagic (0.4.3) + nokogiri (~> 1) + rake + mini_mime (1.1.5) + minitest (5.25.4) + mocha (2.7.1) + ruby2_keywords (>= 0.0.5) + msgpack (1.7.2) + multi_json (1.15.0) + multi_xml (0.7.1) + bigdecimal (~> 3.1) + mysql2 (0.5.6) + nap (1.1.0) + nenv (0.3.0) + net-http (0.4.1) + uri + net-imap (0.4.20) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.0) + net-protocol + nio4r (2.7.4) + no_proxy_fix (0.1.2) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-musl) + racc (~> 1.4) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) + octokit (8.1.0) + base64 + faraday (>= 1, < 3) + sawyer (~> 0.9) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-orcid (2.1.1) + omniauth-oauth2 (~> 1.3) + ruby_dig (~> 0.0.2) + omniauth-rails_csrf_protection (1.0.2) + actionpack (>= 4.2) + omniauth (~> 2.0) + omniauth-shibboleth (1.3.0) + omniauth (>= 1.0.0) + omniauth_openid_connect (0.7.1) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + open4 (1.3.4) + openid_connect (2.3.0) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) + options (2.3.2) + orm_adapter (0.5.0) + ostruct (0.6.1) + parallel (1.26.3) + parser (3.2.2.4) + ast (~> 2.4.1) + racc + pg (1.5.9) + prime (0.1.4) + forwardable + singleton + progress_bar (1.3.4) + highline (>= 1.6) + options (~> 2.3.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (6.0.1) + puma (6.6.0) + nio4r (~> 2.0) + pundit (2.3.2) + activesupport (>= 3.0.0) + pundit-matchers (4.0.0) + rspec-core (~> 3.12) + rspec-expectations (~> 3.12) + rspec-mocks (~> 3.12) + rspec-support (~> 3.12) + racc (1.8.1) + rack (2.2.21) + rack-attack (6.7.0) + rack (>= 1.0, < 4) + rack-mini-profiler (3.3.1) + rack (>= 1.2.0) + rack-oauth2 (2.2.1) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-test (2.2.0) + rack (>= 1.3) + rails (6.1.7.9) + actioncable (= 6.1.7.9) + actionmailbox (= 6.1.7.9) + actionmailer (= 6.1.7.9) + actionpack (= 6.1.7.9) + actiontext (= 6.1.7.9) + actionview (= 6.1.7.9) + activejob (= 6.1.7.9) + activemodel (= 6.1.7.9) + activerecord (= 6.1.7.9) + activestorage (= 6.1.7.9) + activesupport (= 6.1.7.9) + bundler (>= 1.15.0) + railties (= 6.1.7.9) + sprockets-rails (>= 2.0.0) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (6.1.7.9) + actionpack (= 6.1.7.9) + activesupport (= 6.1.7.9) + method_source + rake (>= 12.2) + thor (~> 1.0) + rainbow (3.1.1) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rchardet (1.8.0) + recaptcha (5.18.0) + regexp_parser (2.8.2) + reline (0.6.1) + io-console (~> 0.5) + responders (3.1.1) + actionpack (>= 5.2) + railties (>= 5.2) + rexml (3.4.2) + rollbar (3.5.2) + rouge (4.1.3) + rspec-collection_matchers (1.2.1) + rspec-expectations (>= 2.99.0.beta1) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.2) + rubocop (1.57.1) + base64 (~> 0.1.1) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.4) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.29.0) + parser (>= 3.2.1.0) + rubocop-i18n (3.0.0) + rubocop (~> 1.0) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + ruby_dig (0.0.2) + rubyzip (2.3.2) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + selenium-webdriver (4.16.0) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + shellany (0.0.1) + shoulda (4.0.0) + shoulda-context (~> 2.0) + shoulda-matchers (~> 4.0) + shoulda-context (2.0.0) + shoulda-matchers (4.5.1) + activesupport (>= 4.2.0) + simpleidn (0.2.1) + unf (~> 0.1.4) + singleton (0.3.0) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) + spring (4.2.1) + spring-commands-rspec (1.0.4) + spring (>= 0.9.1) + spring-watcher-listen (2.1.0) + listen (>= 2.7, < 4.0) + spring (>= 4) + sprockets (4.2.1) + concurrent-ruby (~> 1.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + text (1.3.1) + thor (1.4.0) + timeout (0.4.3) + tomparse (0.4.2) + turbo-rails (2.0.5) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (2.5.0) + uniform_notifier (1.16.0) + uri (0.13.2) + uri_template (0.7.0) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix + version_gem (1.1.3) + warden (1.2.9) + rack (>= 2.0.9) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects + webmock (3.23.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + websocket (1.2.10) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + wicked_pdf (2.8.2) + activesupport + ostruct + wkhtmltopdf-binary (0.12.6.10) + xpath (3.2.0) + nokogiri (~> 1.8) + yard (0.9.37) + yard-tomdoc (0.7.1) + tomparse (>= 0.4.0) + yard + zeitwerk (2.6.18) + +PLATFORMS + arm64-darwin-21 + arm64-darwin-22 + x86_64-linux + x86_64-linux-musl + +DEPENDENCIES + activerecord-nulldb-adapter + activerecord_json_validator + annotate + annotate_gem + api-pagination + autoprefixer-rails + better_errors + binding_of_caller + bootsnap + brakeman + bullet + bundle-audit + byebug + capybara + contact_us + cssbundling-rails + danger + database_cleaner + devise + devise_invitable + doorkeeper + dotenv-rails + dragonfly + dragonfly-s3_data_store + factory_bot_rails + faker + flag_shih_tzu + fuubar + guard + htmltoword + httparty + jbuilder + jsbundling-rails + jwt + kaminari + ledermann-rails-settings + listen + mail (= 2.8.1) + mimemagic + mocha + mysql2 + omniauth + omniauth-orcid + omniauth-rails_csrf_protection + omniauth-shibboleth + omniauth_openid_connect + parallel + pg + progress_bar + puma + pundit + pundit-matchers + rack-attack (~> 6.6, >= 6.6.1) + rack-mini-profiler + rails (~> 6.1) + rails-controller-testing + recaptcha + rollbar + rspec-collection_matchers + rspec-rails + rubocop (= 1.57.1) + rubocop-i18n + rubocop-performance + selenium-webdriver + shoulda + spring + spring-commands-rspec + spring-watcher-listen + text + translation! + turbo-rails + web-console + webmock + wicked_pdf + wkhtmltopdf-binary + yard + yard-tomdoc + +RUBY VERSION + ruby 3.1.4p223 + +BUNDLED WITH + 2.4.15 diff --git a/Rakefile b/Rakefile index 42f9525d64..16a081e804 100755 --- a/Rakefile +++ b/Rakefile @@ -13,5 +13,6 @@ require_relative 'config/application' DMPRoadmap::Application.load_tasks +Doorkeeper::Rake.load_tasks task default: :test diff --git a/app/controllers/api/v2/base_api_controller.rb b/app/controllers/api/v2/base_api_controller.rb new file mode 100644 index 0000000000..fa4aad2b79 --- /dev/null +++ b/app/controllers/api/v2/base_api_controller.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Api + module V2 + class BaseApiController < ApplicationController # rubocop:todo Style/Documentation + # skipping the standard rails authenticity tokens passed in the UI + skip_before_action :verify_authenticity_token + + # call doorkeeper to authorize the request + before_action :doorkeeper_authorize!, except: %i[heartbeat] + # get details of server (e.g. DMPonline) and client app + before_action :base_response_content + + before_action :log_access + + before_action :require_read_scope, except: %i[heartbeat me] + # controller can respond to json format requests + respond_to :json + + # set up pages in response + before_action :pagination_params, except: %i[heartbeat] + + rescue_from StandardError, with: :handle_exception + + # GET /api/v2/heartbeat + def heartbeat + render '/api/v2/heartbeat' + end + + # GET /me.json - recommended for doorkeeper gem + def me + render json: @resource_owner.slice(:firstname, :surname, :email).merge( + organisation: @resource_owner.org.name, + language: @resource_owner.language&.name + ) + end + + private + + # define instance variable json and associated getter and setter methods + attr_accessor :json + + def base_response_content + @application = ApplicationService.application_name + @client = doorkeeper_token&.application + @caller = @client&.name || request.remote_ip + return unless doorkeeper_token&.resource_owner_id + + @resource_owner = User.find(doorkeeper_token.resource_owner_id) + end + + def log_access + if @client.present? + Rails.logger.info "Client (OAuth) application name: #{@client.name}" + Rails.logger.info "Client (OAuth) application uid: #{@client.uid}" + end + Rails.logger.info "Resource owner id: #{@resource_owner.id}" if @resource_owner + end + + def handle_exception(exception) + if exception.is_a?(Pundit::NotAuthorizedError) + handle_client_not_authorized + else + handle_internal_server_error(exception) + end + end + + def handle_internal_server_error(exception) + # log server errors + Rails.logger.error "Exception message: #{exception.message}" + + # inform client of server error + message = _('There was a problem in the server.') + @payload = { message: [message] } + render '/api/v2/error', status: :internal_server_error + end + + def handle_client_not_authorized + message = _('The client is not authorized to perform this action.') + @payload = { message: [message] } + render '/api/v2/error', status: :forbidden + end + + # retrieve the requested pagination params or use defaults + # only allow 100 per page as the max + def pagination_params + max_per_page = Rails.configuration.x.application.api_max_page_size + @page = params.fetch('page', 1).to_i + @per_page = params.fetch('per_page', max_per_page).to_i + @per_page = max_per_page if @per_page > max_per_page + end + + def paginate_response(results:) + results = results.page(@page).per(@per_page) + @total_items = results.total_count + results + end + + def require_read_scope + raise Pundit::NotAuthorizedError unless doorkeeper_token.scopes.include?('read') + end + end + end +end diff --git a/app/controllers/api/v2/internal_user_access_tokens_controller.rb b/app/controllers/api/v2/internal_user_access_tokens_controller.rb new file mode 100644 index 0000000000..6cb19454ec --- /dev/null +++ b/app/controllers/api/v2/internal_user_access_tokens_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Api + module V2 + # Controller for managing the current user's internal V2 API access token. + # Provides token rotation for authenticated internal users. + # See Api::V2::InternalUserAccessTokenService for token implementation details. + class InternalUserAccessTokensController < ApplicationController + # POST "/api/v2/internal_user_access_token" + def create + authorize current_user, :internal_user_v2_access_token? + @v2_token = Api::V2::InternalUserAccessTokenService.rotate!(current_user) + @success = true + respond_to do |format| + format.js { render 'users/refresh_token' } + end + end + end + end +end diff --git a/app/controllers/api/v2/plans_controller.rb b/app/controllers/api/v2/plans_controller.rb new file mode 100644 index 0000000000..ba4c507bb6 --- /dev/null +++ b/app/controllers/api/v2/plans_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Api + module V2 + class PlansController < BaseApiController # rubocop:todo Style/Documentation + respond_to :json + before_action :set_complete_param, only: %i[show index] + + # GET /api/v2/plans/:id + def show + @plan = plans_scope.find_by(id: params[:id]) + + plans_policy = PlansPolicy.new(@resource_owner, @plan) + raise Pundit::NotAuthorizedError unless plans_policy.show? + + @items = [@plan] + render '/api/v2/plans/index', status: :ok + end + + # GET /api/v2/plans + def index + @plans = plans_scope + @items = paginate_response(results: @plans) + render '/api/v2/plans/index', status: :ok + end + + private + + # GET /api/v2/plans?complete=true and /api/v2/plans/:id?complete=true + def set_complete_param + @complete = params[:complete].to_s.casecmp('true').zero? + end + + def plans_scope + scope = PlansPolicy::Scope.new(@resource_owner).resolve + @complete ? scope.includes(answers: { question: :section }) : scope + end + end + end +end diff --git a/app/controllers/api/v2/templates_controller.rb b/app/controllers/api/v2/templates_controller.rb new file mode 100644 index 0000000000..2b9a4bf4bb --- /dev/null +++ b/app/controllers/api/v2/templates_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Api + module V2 + # provides a list of templates for API V2 + class TemplatesController < BaseApiController + respond_to :json + + # GET /api/v2/templates + def index + templates = Api::V2::TemplatesPolicy::Scope.new(@resource_owner).resolve + @items = paginate_response(results: templates) + render '/api/v2/templates/index', status: :ok + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cc3c422974..65b1ca6400 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -61,28 +61,25 @@ def store_location end end - # rubocop:disable Metrics/AbcSize def after_sign_in_path_for(_resource) - referer_path = URI(request.referer).path unless request.referer.nil? - if from_external_domain? || referer_path.eql?(new_user_session_path) || - referer_path.eql?(new_user_registration_path) || - referer_path.nil? - root_path - else - request.referer - end + after_auth_path(disallowed_paths: [new_user_session_path, new_user_registration_path]) end - # rubocop:enable Metrics/AbcSize def after_sign_up_path_for(_resource) - referer_path = URI(request.referer).path unless request.referer.nil? - if from_external_domain? || - referer_path.eql?(new_user_session_path) || - referer_path.nil? - root_path - else - request.referer - end + after_auth_path(disallowed_paths: [new_user_session_path]) + end + + def after_auth_path(disallowed_paths:) + # ensure oauth2 authorization flow is not interrupted + # TODO: Unless nil, should stored_location_for(resource) always be returned? + return stored_location_for(:user) if user_is_in_oauth_flow? + + return root_path if request.referer.nil? || from_external_domain? + + referer_path = URI(request.referer).path + return root_path if disallowed_paths.include?(referer_path) + + request.referer end def after_sign_in_error_path_for(_resource) @@ -194,4 +191,8 @@ def render_respond_to_format_with_error_message(msg, url_or_path, http_status, e end end end + + def user_is_in_oauth_flow? + session[:user_return_to]&.start_with?(oauth_authorization_path) + end end diff --git a/app/controllers/concerns/plan_permitted_params.rb b/app/controllers/concerns/plan_permitted_params.rb new file mode 100644 index 0000000000..90363f9de8 --- /dev/null +++ b/app/controllers/concerns/plan_permitted_params.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module PlanPermittedParams # rubocop:todo Metrics/ModuleLength, Style/Documentation + extend ActiveSupport::Concern + + def plan_permitted_params + [ + :created, + :title, + :description, + :language, + :ethical_issues_exist, + :ethical_issues_description, + :ethical_issues_report, + { dmp_ids: identifier_permitted_params }, + { contact: contributor_permitted_params }, + { contributors: contributor_permitted_params }, + { costs: cost_permitted_params }, + { project: project_permitted_params }, + { datasets: dataset_permitted_params } + ] + end + + def identifier_permitted_params + %i[ + type + identifier + ] + end + + def contributor_permitted_params + [ + :firstname, + :surname, + :mbox, + :role, + { affiliations: affiliation_permitted_params }, + { contributor_ids: identifier_permitted_params } + ] + end + + def affiliation_permitted_params + [ + :name, + :abbreviation, + { affiliation_ids: identifier_permitted_params } + ] + end + + def cost_permitted_params + %i[ + title + description + value + currency_code + ] + end + + def project_permitted_params + [ + :title, + :description, + :start_on, + :end_on, + { funding: funding_permitted_params } + ] + end + + def funding_permitted_params + [ + :name, + :funding_status, + { funder_ids: identifier_permitted_params }, + { grant_ids: identifier_permitted_params } + ] + end + + def dataset_permitted_params + [ + :title, + :doi_url, + :description, + :type, + :issued, + :language, + :personal_data, + :sensitive_data, + :keywords, + :data_quality_assurance, + :preservation_statement, + { dataset_ids: identifier_permitted_params }, + { metadata: metadatum_permitted_params }, + { security_and_privacy_statements: security_and_privacy_statement_permitted_params }, + { technical_resources: technical_resource_permitted_params }, + { distributions: distribution_permitted_params } + ] + end + + def metadatum_permitted_params + [ + :description, + :language, + { identifier: identifier_permitted_params } + ] + end + + def security_and_privacy_statement_permitted_params + %i[ + title + description + ] + end + + def technical_resource_permitted_params + [ + :description, + { identifier: identifier_permitted_params } + ] + end + + def distribution_permitted_params + [ + :title, + :description, + :format, + :byte_size, + :access_url, + :download_url, + :data_access, + :available_until, + { licenses: license_permitted_params }, + { host: host_permitted_params } + ] + end + + def license_permitted_params + %i[ + license_ref + start_date + ] + end + + def host_permitted_params + [ + :title, + :description, + :supports_versioning, + :backup_type, + :backup_frequency, + :storage_type, + :availability, + :geo_location, + :certified_with, + :pid_system, + { host_ids: identifier_permitted_params } + ] + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 1345f66a4d..8c22536928 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -24,6 +24,7 @@ import 'bootstrap-select'; // Utilities import './src/utils/accordion'; import './src/utils/autoComplete'; +import './src/utils/copyToken.js'; import './src/utils/externalLink'; import './src/utils/modalSearch'; import './src/utils/outOfFocus'; diff --git a/app/javascript/src/utils/copyToken.js b/app/javascript/src/utils/copyToken.js new file mode 100644 index 0000000000..3da9c90fb7 --- /dev/null +++ b/app/javascript/src/utils/copyToken.js @@ -0,0 +1,35 @@ +const initCopyToken = () => { + document.addEventListener('click', function (e) { + const button = e.target.closest('#copy-token-btn'); + if (!button) return; + + e.preventDefault(); + + // Prevent spam clicking + if (button.disabled) return; + + const tokenInput = document.getElementById('api-token-val'); + if (!tokenInput) return; + + const originalHTML = button.innerHTML; + + // Disable immediately + button.disabled = true; + + navigator.clipboard.writeText(tokenInput.value).then(() => { + // Replace button contents with check icon + button.innerHTML = ''; + + // Restore after 2s + setTimeout(() => { + button.innerHTML = originalHTML; + button.disabled = false; + }, 2000); + }).catch(() => { + button.disabled = false; + alert('Failed to copy token'); + }); + }); +}; + +initCopyToken(); diff --git a/app/models/plan.rb b/app/models/plan.rb index 772e5c7ed5..51cdb3ae01 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -207,6 +207,39 @@ class Plan < ApplicationRecord includes(:phases, :sections, :questions, template: [:org]).find(id) } + # Eager loads all associations needed for API v2 serialization, + # and restricts to plans where the user_id has an active role. + scope :for_api_v2, lambda { |user_id| + joins(:roles) + .includes( + :research_outputs, + :template, + { identifiers: :identifier_scheme }, + funder: { identifiers: :identifier_scheme }, + contributors: [ + { identifiers: :identifier_scheme }, + { org: { identifiers: :identifier_scheme } } + ], + roles: [ + user: [ + :language, + { identifiers: :identifier_scheme }, + { org: { identifiers: :identifier_scheme } } + ] + ], + # plan.org is only executed when `plan.funder.present? || plan.grant_id.present? == true` + # - (see `app/views/api/v2/plans/_project.json.jbuilder`) + # Thus, the following line avoids N+1 queries in some cases, + # but performs unnecessary eager loading in others + org: [ + :region, + { identifiers: :identifier_scheme } + ] + ) + .where(roles: { user_id: user_id, active: true }) + .distinct + } + ## # Settings for the template has_settings :export, class_name: 'Settings::Template' do |s| diff --git a/app/models/template.rb b/app/models/template.rb index 3dd54abb02..d32416d453 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -215,6 +215,16 @@ class Template < ApplicationRecord term: "%#{term}%") } + scope :for_api_v2, lambda { |org_id| + org_templates = organisationally_visible.where(org_id: org_id) + public_templates = publicly_visible.where(customization_of: nil) + includes(org: { identifiers: :identifier_scheme }) + .joins(:org) + .published + .merge(org_templates.or(public_templates)) + .order(:title) + } + # defines the export setting for a template object has_settings :export, class_name: 'Settings::Template' do |s| s.key :export, defaults: Settings::Template::DEFAULT_SETTINGS diff --git a/app/models/user.rb b/app/models/user.rb index fe9579eb79..fe149e75c9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,6 +113,12 @@ class User < ApplicationRecord has_and_belongs_to_many :notifications, dependent: :destroy, join_table: 'notification_acknowledgements' + has_many :access_grants, class_name: 'Doorkeeper::AccessGrant', foreign_key: :resource_owner_id, + dependent: :delete_all + + has_many :access_tokens, class_name: 'Doorkeeper::AccessToken', foreign_key: :resource_owner_id, + dependent: :delete_all + # =============== # = Validations = # =============== diff --git a/app/policies/api/v2/plans_policy.rb b/app/policies/api/v2/plans_policy.rb new file mode 100644 index 0000000000..16fc07edd4 --- /dev/null +++ b/app/policies/api/v2/plans_policy.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Api + module V2 + # Security rules for API V2 Plan endpoints + class PlansPolicy < ApplicationPolicy + # overriding the initializer due to resource owner / user + # not needing to be logged in for client app to make requests + def initialize(resource_owner, plan = nil) # rubocop:todo Lint/MissingSuper + @resource_owner = resource_owner + @plan = plan + end + + def show? + # The show action uses the resolve method, so only a presence check + # is needed here (see the resolve method comment for more). + @plan.present? + end + + class Scope < Scope # rubocop:todo Style/Documentation + def initialize(resource_owner) # rubocop:todo Lint/MissingSuper + @resource_owner = resource_owner + end + + # Eager loads all associations needed for API v2 serialization, + # and restricts to plans where the user_id has an active role. + # - (i.e. .where(roles: { user_id: @resource_owner.id, active: true })) + def resolve + Plan.for_api_v2(@resource_owner.id) + end + end + end + end +end diff --git a/app/policies/api/v2/templates_policy.rb b/app/policies/api/v2/templates_policy.rb new file mode 100644 index 0000000000..c12a14737b --- /dev/null +++ b/app/policies/api/v2/templates_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Api + module V2 + class TemplatesPolicy < ApplicationPolicy + class Scope < Scope # rubocop:todo Style/Documentation + def initialize(resource_owner) # rubocop:todo Lint/MissingSuper + @resource_owner = resource_owner + end + + def resolve + # get the templates + Template.for_api_v2(@resource_owner.org&.id) + end + end + end + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 978c40d412..dd4527d941 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -55,6 +55,12 @@ def refresh_token? (@user.can_org_admin? && @user.can_use_api?) end + # Safe: only allows the signed-in user to generate/rotate their own token. + # These are first-party, user-scoped tokens and do not affect other users. + def internal_user_v2_access_token? + true + end + def merge? @user.can_super_admin? end diff --git a/app/presenters/api/v2/api_presenter.rb b/app/presenters/api/v2/api_presenter.rb new file mode 100644 index 0000000000..1ad7290ab6 --- /dev/null +++ b/app/presenters/api/v2/api_presenter.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module V2 + # Generic helper methods for API V2 + class ApiPresenter + class << self + def boolean_to_yes_no_unknown(value:) + return 'unknown' unless value.present? + + value ? 'yes' : 'no' + end + end + end + end +end diff --git a/app/presenters/api/v2/contributor_presenter.rb b/app/presenters/api/v2/contributor_presenter.rb new file mode 100644 index 0000000000..77232c2838 --- /dev/null +++ b/app/presenters/api/v2/contributor_presenter.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 contributors views + class ContributorPresenter + class << self + # Convert the specified role into a CRediT Taxonomy URL + def role_as_uri(role:) + return nil unless role.present? + return 'other' if role.to_s.casecmp('other').zero? + + "#{Contributor::ONTOLOGY_BASE_URL}/#{role.to_s.downcase.tr('_', '-')}" + end + + def contributor_id(identifiers:) + identifiers.find { |id| id.identifier_scheme.name == 'orcid' } + end + end + end + end +end diff --git a/app/presenters/api/v2/funding_presenter.rb b/app/presenters/api/v2/funding_presenter.rb new file mode 100644 index 0000000000..c878daadb6 --- /dev/null +++ b/app/presenters/api/v2/funding_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 funding section + class FundingPresenter + class << self + # If the plan has a grant number then it has been awarded/granted + # otherwise it is 'planned' + def status(plan:) + return 'planned' unless plan.present? + + case plan.funding_status + when 'funded' + 'granted' + when 'denied' + 'rejected' + else + 'planned' + end + end + end + end + end +end diff --git a/app/presenters/api/v2/language_presenter.rb b/app/presenters/api/v2/language_presenter.rb new file mode 100644 index 0000000000..2be21c7e38 --- /dev/null +++ b/app/presenters/api/v2/language_presenter.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 language values + class LanguagePresenter + class << self + LANGUAGE_MAP = { + aa: 'aar', ab: 'abk', af: 'afr', ak: 'aka', am: 'amh', ar: 'ara', an: 'arg', + as: 'asm', av: 'ava', ae: 'ave', ay: 'aym', az: 'aze', + + ba: 'bak', bm: 'bam', be: 'bel', bn: 'ben', bh: 'bih', bi: 'bis', bo: 'tib', + bs: 'bos', br: 'bre', bg: 'bul', + + ca: 'cat', cs: 'cze', ch: 'cha', ce: 'che', cu: 'chu', cv: 'chv', co: 'cos', + cr: 'cre', cy: 'wel', + + da: 'dan', de: 'deu', dv: 'div', dz: 'dzo', + + el: 'gre', en: 'eng', eo: 'epo', es: 'spa', et: 'est', eu: 'baq', ee: 'ewe', + + fo: 'fao', fa: 'per', fj: 'fij', fi: 'fin', fr: 'fre', fy: 'fry', ff: 'ful', + + gd: 'gla', ga: 'gle', gl: 'glg', gv: 'glv', gn: 'grn', gu: 'guj', + + ht: 'hat', ha: 'hau', he: 'heb', hz: 'her', hi: 'hin', ho: 'hmo', hr: 'hrv', + hu: 'hun', hy: 'arm', + + ig: 'ibo', io: 'ido', ii: 'iii', iu: 'iku', ie: 'ile', ia: 'ina', id: 'ind', + ik: 'ipk', is: 'ice', it: 'ita', + + jv: 'jav', ja: 'jpn', + + kl: 'kal', kn: 'kan', ks: 'kas', kr: 'kau', kk: 'kaz', km: 'khm', ki: 'kik', + ky: 'kir', kv: 'kom', kg: 'kon', ko: 'kor', kj: 'kua', ku: 'kur', ka: 'geo', + kw: 'cor', + + lo: 'lao', la: 'lat', lv: 'lav', li: 'lim', ln: 'lin', lt: 'lit', lb: 'ltz', + lu: 'lub', lg: 'lug', + + mk: 'mac', mh: 'mah', ml: 'mal', mi: 'mao', mr: 'mar', ms: 'may', mg: 'mlg', + mt: 'mlt', mn: 'mon', my: 'bur', + + na: 'nau', nv: 'nav', nr: 'nbl', nd: 'nde', ng: 'ndo', ne: 'nep', nl: 'dut', + nn: 'nno', nb: 'nob', no: 'nor', ny: 'nya', + + oc: 'oci', oj: 'oji', or: 'ori', om: 'orm', os: 'oss', + + pa: 'pan', pi: 'pli', pl: 'pol', pt: 'por', ps: 'pus', + + qu: 'que', + + rm: 'roh', ro: 'rum', rn: 'run', ru: 'rus', rw: 'kin', + + sg: 'sag', sa: 'san', si: 'sin', sk: 'slo', sl: 'slv', se: 'sme', sm: 'smo', + sn: 'sna', sd: 'snd', so: 'som', st: 'sot', sq: 'alb', sc: 'srd', sr: 'srp', + ss: 'ssw', su: 'sun', sw: 'swa', sv: 'swe', + + ty: 'tah', ta: 'tam', tt: 'tat', te: 'tel', tg: 'tgk', tl: 'tgl', th: 'tha', + ti: 'tir', to: 'ton', tn: 'tsn', ts: 'tso', tk: 'tuk', tr: 'tur', tw: 'twi', + + ug: 'uig', uk: 'ukr', ur: 'urd', uz: 'uzb', + + ve: 'ven', vi: 'vie', vo: 'vol', + + wa: 'wln', wo: 'wol', + + xh: 'xho', + + yi: 'yid', yo: 'yor', + + za: 'zha', zh: 'chi', zu: 'zul' + }.freeze + + # Convert the incoming 2 (e.g. en - ISO 639-1) or 2+region (e.g. en-UK) + # into the 3 character code (e.g. eng - ISO 639-2) + def three_char_code(lang:) + lang ||= LocaleService.default_locale + two_char_code = lang.to_s.split('-').first + LANGUAGE_MAP[two_char_code.to_sym] + end + end + end + end +end diff --git a/app/presenters/api/v2/org_presenter.rb b/app/presenters/api/v2/org_presenter.rb new file mode 100644 index 0000000000..5302acb7a7 --- /dev/null +++ b/app/presenters/api/v2/org_presenter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 affiliation sections + class OrgPresenter + class << self + def affiliation_id(identifiers:) + ident = identifiers.find { |id| id.identifier_scheme&.name == 'ror' } + return ident if ident.present? + + identifiers.find { |id| id.identifier_scheme&.name == 'fundref' } + end + end + end + end +end diff --git a/app/presenters/api/v2/pagination_presenter.rb b/app/presenters/api/v2/pagination_presenter.rb new file mode 100644 index 0000000000..1b8fbc1109 --- /dev/null +++ b/app/presenters/api/v2/pagination_presenter.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for genewric API V2 pagination + class PaginationPresenter + def initialize(current_url:, per_page:, total_items:, current_page: 1) + @url = current_url + @per_page = per_page + @total_items = total_items + @page = current_page + end + + def url_without_pagination + return nil unless @url.present? && @url.is_a?(String) + + url = @url.gsub(/per_page=\d+/, '') + .gsub(/page=\d+/, '') + .gsub(/(&)+$/, '').gsub(/\?$/, '') + + (url.include?('?') ? "#{url}&" : "#{url}?") + end + + def prev_page? + total_pages > 1 && @page != 1 + end + + def next_page? + total_pages > 1 && @page < total_pages + end + + def prev_page_link + "#{url_without_pagination}page=#{@page - 1}&per_page=#{@per_page}" + end + + def next_page_link + "#{url_without_pagination}page=#{@page + 1}&per_page=#{@per_page}" + end + + private + + def total_pages + return 1 unless @total_items.present? && @per_page.present? && + @total_items.positive? && @per_page.positive? + + (@total_items.to_f / @per_page).ceil + end + end + end +end diff --git a/app/presenters/api/v2/plan_presenter.rb b/app/presenters/api/v2/plan_presenter.rb new file mode 100644 index 0000000000..8cb7eca6ef --- /dev/null +++ b/app/presenters/api/v2/plan_presenter.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 project / DMP + class PlanPresenter + attr_reader :data_contact, :contributors, :costs, :complete_plan_data + + def initialize(plan:, complete: false) + @contributors = [] + return unless plan.present? + + @plan = plan + + # Use owner or first data_curation role as the data_contact + @data_contact = @plan.owner || @plan.contributors.find(&:data_curation?) + @contributors = @plan.contributors.to_a + + @costs = plan_costs(plan: @plan) + + @complete_plan_data = fetch_all_q_and_a if complete + end + + # Extract the ARK or DOI for the DMP OR use its URL if none exists + def identifier + doi = @plan.identifiers.select do |id| + ::Plan::DMP_ID_TYPES.include?(id.identifier_format) + end + return doi.first if doi.first.present? + + # if no DOI then use the URL for the API's 'show' method + Identifier.new(value: Rails.application.routes.url_helpers.api_v2_plan_url(@plan)) + end + + private + + # Retrieve the answers that have the Budget theme + def plan_costs(plan:) + theme = Theme.where(title: 'Cost').first + return [] unless theme.present? + + # TODO: define a new 'Currency' question type that includes a float field + # any currency type selector (e.g GBP or USD) + answers = plan.answers + .joins(question: :themes) + .where(themes: { id: theme.id }) + .includes(:question) + + answers.map do |answer| + # TODO: Investigate whether question level guidance should be the description + { title: answer.question.text, description: nil, + currency_code: 'usd', value: answer.text } + end + end + + # Fetch all questions and answers from a plan, regardless of theme + def fetch_all_q_and_a + answers = @plan.answers + return [] unless answers.present? + + answers.filter_map do |answer| + q = answer.question + next unless q.present? + + { + id: q.id, + title: "Question #{q.number || q.id}", + section: q.section&.title, + question: q.text.to_s, + answer: answer.text.to_s + } + end + end + end + end +end diff --git a/app/presenters/api/v2/research_output_presenter.rb b/app/presenters/api/v2/research_output_presenter.rb new file mode 100644 index 0000000000..33308bd9ae --- /dev/null +++ b/app/presenters/api/v2/research_output_presenter.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper methods for research outputs + class ResearchOutputPresenter + attr_reader :dataset_id, :preservation_statement, :security_and_privacy, :license_start_date, + :data_quality_assurance, :distributions, :metadata, :technical_resources + + def initialize(output:) + @research_output = output + return unless output.is_a?(ResearchOutput) + + @plan = output.plan + @dataset_id = identifier + + load_narrative_content + + @license_start_date = determine_license_start_date(output: output) + end + + private + + def identifier + Identifier.new(identifiable: @research_output, value: @research_output.id) + end + + def determine_license_start_date(output:) + return nil unless output.present? + return output.release_date.to_formatted_s(:iso8601) if output.release_date.present? + + output.created_at.to_formatted_s(:iso8601) + end + + def load_narrative_content + @preservation_statement = '' + @security_and_privacy = [] + @data_quality_assurance = '' + + # Disabling rubocop here since a guard clause would make the line too long + # rubocop:disable Style/GuardClause + if Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions + @preservation_statement = fetch_q_and_a_as_single_statement(themes: %w[Preservation]) + end + if Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions + @security_and_privacy = fetch_q_and_a(themes: ['Ethics & privacy', 'Storage & security']) + end + if Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions + @data_quality_assurance = fetch_q_and_a_as_single_statement(themes: ['Data Collection']) + end + # rubocop:enable Style/GuardClause + end + + def fetch_q_and_a_as_single_statement(themes:) + fetch_q_and_a(themes: themes).collect { |item| item[:description] }.join('
') + end + + def fetch_q_and_a(themes:) + return [] unless themes.is_a?(Array) && themes.any? + + answers = answers_for_themes(themes) + + descs_by_theme = build_descriptions_by_theme_hash(answers, themes) + + descs_by_theme.map do |theme, descs| + { title: theme, description: descs } + end + end + + def answers_for_themes(themes) + @plan.answers + .joins(question: :themes) + .where(themes: { title: themes }) + .includes(question: :themes) + .distinct + end + + def build_descriptions_by_theme_hash(answers, themes) + descs_by_theme = Hash.new { |h, k| h[k] = [] } + + answers.each do |answer| + answer.question.themes.each do |theme| + next unless themes.include?(theme.title) + + descs_by_theme[theme.title] << format_q_and_a(answer.question, answer) + end + end + descs_by_theme + end + + def format_q_and_a(question, answer) + "Question: #{question.text}
Answer: #{answer.text}" + end + end + end +end diff --git a/app/presenters/api/v2/template_presenter.rb b/app/presenters/api/v2/template_presenter.rb new file mode 100644 index 0000000000..2d32a45d37 --- /dev/null +++ b/app/presenters/api/v2/template_presenter.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper class for the API V2 template info + class TemplatePresenter + def initialize(template:) + @template = template + end + + # If the plan has a grant number then it has been awarded/granted + # otherwise it is 'planned' + def title + return @template.title unless @template.customization_of.present? + + "#{@template.title} - with additional questions for #{@template.org.name}" + end + end + end +end diff --git a/app/services/api/v2/conversion_service.rb b/app/services/api/v2/conversion_service.rb new file mode 100644 index 0000000000..4a9a932878 --- /dev/null +++ b/app/services/api/v2/conversion_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Api + module V2 + # Helper service that translates to/from the RDA common standard + class ConversionService + class << self + # Converts a boolean field to [yes, no, unknown] + def boolean_to_yes_no_unknown(value) + return 'yes' if [true, 1].include?(value) + + return 'no' if [false, 0].include?(value) + + 'unknown' + end + + # Converts a [yes, no, unknown] field to boolean (or nil) + def yes_no_unknown_to_boolean(value) + return true if value&.downcase == 'yes' + + return nil if value.blank? || value&.downcase == 'unknown' + + false + end + + # Converts the context and value into an Identifier with a psuedo + # IdentifierScheme for display in JSON partials. Which will result in: + # { type: 'context', identifier: 'value' } + def to_identifier(context:, value:) + return nil unless value.present? && context.present? + + scheme = IdentifierScheme.new(name: context) + Identifier.new(value: value, identifier_scheme: scheme) + end + end + end + end +end diff --git a/app/services/api/v2/internal_user_access_token_service.rb b/app/services/api/v2/internal_user_access_token_service.rb new file mode 100644 index 0000000000..41f65397a8 --- /dev/null +++ b/app/services/api/v2/internal_user_access_token_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Api + module V2 + # Service responsible for user-scoped v2 API access tokens, strictly for + # internal users of this application. + # + # Tokens issued by this service are functionally equivalent to Personal Access + # Tokens (PATs) for first-party usage. They are minted directly for a user + # who is already authenticated in the application, bypassing the standard + # OAuth 2.0 authorization_code redirect and consent flow. + # + # This design is intentional: + # - tokens are internal to this application (first-party) + # - tokens are owned by a single user and scoped accordingly + # - token creation, rotation, and revocation happen entirely within the app UI + # + # Tokens are stored as Doorkeeper::AccessToken records to leverage existing + # scoping, expiry, and revocation mechanisms. + # + # This service does NOT support third-party OAuth clients or delegated consent flows. + class InternalUserAccessTokenService + READ_SCOPE = 'read' + INTERNAL_OAUTH_APP_NAME = Rails.application.config.x.application.internal_oauth_app_name + + class << self + def rotate!(user) + revoke_existing!(user) + + token = Doorkeeper::AccessToken.create!( + application_id: application!.id, + resource_owner_id: user.id, + scopes: READ_SCOPE, + expires_in: nil # Overrides Doorkeeper's `access_token_expires_in` + ) + token.plaintext_token + end + + # Used by views (e.g. devise/registrations/_v2_api_token.html.erb) to safely + # gate token UI if the internal OAuth application is missing. + def application_present? + application! + true + rescue StandardError => e + Rails.logger.error(e.message) + false + end + + private + + def application! + Doorkeeper::Application.find_by(name: INTERNAL_OAUTH_APP_NAME) || + raise( + StandardError, + "Required Doorkeeper application '#{INTERNAL_OAUTH_APP_NAME}' not found. " \ + 'Please ensure the application exists in the database.' + ) + end + + def revoke_existing!(user) + Doorkeeper::AccessToken.revoke_all_for(application!.id, user) + end + end + end + end +end diff --git a/app/views/api/v2/_standard_response.json.jbuilder b/app/views/api/v2/_standard_response.json.jbuilder new file mode 100644 index 0000000000..372c42cb31 --- /dev/null +++ b/app/views/api/v2/_standard_response.json.jbuilder @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# locals: response, request, total_items + +total_items ||= 0 + +paginator = Api::V2::PaginationPresenter.new(current_url: request.path, + per_page: @per_page, + total_items: total_items, + current_page: @page) + +json.prettify! +json.ignore_nil! + +json.application @application +json.source "#{request.method} #{request.path}" +json.time Time.now.to_formatted_s(:iso8601) +json.caller @caller +json.code response.status +json.message Rack::Utils::HTTP_STATUS_CODES[response.status] + +if response.status == 200 + + # Pagination Links + if total_items.positive? + json.page @page + json.per_page @per_page + json.total_items total_items + + # Prepare the base URL by removing the old pagination params + json.prev paginator.prev_page_link if paginator.prev_page? + json.next paginator.next_page_link if paginator.next_page? + else + json.total_items 0 + end + +end diff --git a/app/views/api/v2/contributors/_show.json.jbuilder b/app/views/api/v2/contributors/_show.json.jbuilder new file mode 100644 index 0000000000..267ff7e3d2 --- /dev/null +++ b/app/views/api/v2/contributors/_show.json.jbuilder @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# locals: contributor, is_contact + +is_contact ||= false + +json.name contributor.is_a?(User) ? contributor.name(false) : contributor.name +json.mbox contributor.email + +if !is_contact && contributor.selected_roles.any? + roles = contributor.selected_roles.map do |role| + Api::V2::ContributorPresenter.role_as_uri(role: role) + end + json.role roles if roles.any? +end + +if contributor.org.present? + json.affiliation do + json.partial! 'api/v2/orgs/show', org: contributor.org + end +end + +orcid = contributor.identifier_for_scheme(scheme: 'orcid') +if orcid.present? + id = Api::V2::ContributorPresenter.contributor_id( + identifiers: contributor.identifiers + ) + if is_contact + json.contact_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end + else + json.contributor_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end + end +end diff --git a/app/views/api/v2/datasets/_show.json.jbuilder b/app/views/api/v2/datasets/_show.json.jbuilder new file mode 100644 index 0000000000..6e856019cd --- /dev/null +++ b/app/views/api/v2/datasets/_show.json.jbuilder @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# locals: output + +if output.is_a?(ResearchOutput) + presenter = Api::V2::ResearchOutputPresenter.new(output: output) + + json.type output.output_type + json.title output.title + json.description output.description + json.personal_data Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: output.personal_data) + json.sensitive_data Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: output.sensitive_data) + json.issued output.release_date&.to_formatted_s(:iso8601) + + json.preservation_statement presenter.preservation_statement + json.security_and_privacy presenter.security_and_privacy + json.data_quality_assurance presenter.data_quality_assurance + + json.dataset_id do + json.partial! "api/v2/identifiers/show", identifier: presenter.dataset_id + end + + json.distribution output.repositories do |repository| + json.title "Anticipated distribution for #{output.title}" + json.byte_size output.byte_size + json.data_access output.access + + json.host do + json.title repository.name + json.description repository.description + json.url repository.homepage + + # DMPTool extensions to the RDA common metadata standard + json.dmproadmap_host_id do + json.type "url" + json.identifier repository.uri + end + end + + if output.license.present? + json.license [output.license] do |license| + json.license_ref license.uri + json.start_date presenter.license_start_date + end + end + end + + json.metadata output.metadata_standards do |metadata_standard| + website = metadata_standard.locations.find { |loc| loc["type"] == "website" } + website = { url: "" } unless website.present? + + descr_array = [metadata_standard.title, metadata_standard.description, website["url"]] + json.description descr_array.join(" - ") + + json.metadata_standard_id do + json.type "url" + json.identifier metadata_standard.uri + end + end + + json.technical_resource [] + + if output.plan.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.plan.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end + +else + json.type "dataset" + json.title "Generic dataset" + json.description "No individual datasets have been defined for this DMP." + + if output.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end +end diff --git a/app/views/api/v2/error.json.jbuilder b/app/views/api/v2/error.json.jbuilder new file mode 100644 index 0000000000..ac08f26d9f --- /dev/null +++ b/app/views/api/v2/error.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response' + +# json.items [] +json.message @payload[:message] +json.details @payload[:details] diff --git a/app/views/api/v2/heartbeat.json.jbuilder b/app/views/api/v2/heartbeat.json.jbuilder new file mode 100644 index 0000000000..70b165b95d --- /dev/null +++ b/app/views/api/v2/heartbeat.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response' + +json.items [] diff --git a/app/views/api/v2/identifiers/_show.json.jbuilder b/app/views/api/v2/identifiers/_show.json.jbuilder new file mode 100644 index 0000000000..c219222aee --- /dev/null +++ b/app/views/api/v2/identifiers/_show.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# locals: identifier + +json.type identifier&.identifier_format +json.identifier identifier&.value diff --git a/app/views/api/v2/me.json.jbuilder b/app/views/api/v2/me.json.jbuilder new file mode 100644 index 0000000000..18a1e7c114 --- /dev/null +++ b/app/views/api/v2/me.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response' + +if current_user.present? + json.items [current_user] do |user| + json.name [user.surname, user.firstname].join(', ') + json.mbox user.email + json.token user.ui_token + + if user.org.present? && ['No funder', 'Non Partner Institution'].exclude?(user.org.name) + json.affiliation do + json.partial! 'api/v2/orgs/show', org: user.org + end + end + + orcid = user.identifier_for_scheme(scheme: 'orcid') + if orcid.present? + json.user_id do + json.partial! 'api/v2/identifiers/show', identifier: orcid + end + end + end + +else + json.items [] +end diff --git a/app/views/api/v2/orgs/_show.json.jbuilder b/app/views/api/v2/orgs/_show.json.jbuilder new file mode 100644 index 0000000000..934e429a17 --- /dev/null +++ b/app/views/api/v2/orgs/_show.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# locals: org + +json.name org.name +json.abbreviation org.abbreviation +json.region org.region&.abbreviation + +if org.identifiers.any? + json.affiliation_id do + id = Api::V2::OrgPresenter.affiliation_id(identifiers: org.identifiers) + json.partial! 'api/v2/identifiers/show', identifier: id + end +end diff --git a/app/views/api/v2/plans/_cost.json.jbuilder b/app/views/api/v2/plans/_cost.json.jbuilder new file mode 100644 index 0000000000..ad36e3540e --- /dev/null +++ b/app/views/api/v2/plans/_cost.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# locals: cost + +json.title cost[:title] +json.description cost[:description] +json.currency_code cost[:currency_code] +json.value cost[:value] diff --git a/app/views/api/v2/plans/_funding.json.jbuilder b/app/views/api/v2/plans/_funding.json.jbuilder new file mode 100644 index 0000000000..35786ac2dc --- /dev/null +++ b/app/views/api/v2/plans/_funding.json.jbuilder @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# locals: plan + +json.name plan.funder&.name + +if plan.funder.present? + id = Api::V2::OrgPresenter.affiliation_id(identifiers: plan.funder.identifiers) + + if id.present? + json.funder_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end + end +end + +if plan.grant_id.present? && plan.grant.present? + json.grant_id do + json.partial! 'api/v2/identifiers/show', identifier: plan.grant + end +end + +json.funding_status Api::V2::FundingPresenter.status(plan: plan) + +# DMPTool extensions to the RDA common metadata standard +# ------------------------------------------------------ + +# We collect a user entered ID on the form, so this is a way to convey it to other systems +# The ID would typically be something relevant to the funder or research organization +if plan.identifier.present? + json.dmproadmap_funding_opportunity_id do + json.partial! 'api/v2/identifiers/show', identifier: Identifier.new(identifiable: plan, + value: plan.identifier) + end +end + +# Since the Plan owner (aka contact) and contributor orgs could be different than the +# one associated with the Plan, we add it here. +json.dmproadmap_funded_affiliations [plan.org] do |funded_org| + json.partial! 'api/v2/orgs/show', org: funded_org +end diff --git a/app/views/api/v2/plans/_project.json.jbuilder b/app/views/api/v2/plans/_project.json.jbuilder new file mode 100644 index 0000000000..9bf3f97fea --- /dev/null +++ b/app/views/api/v2/plans/_project.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# locals: plan + +json.title plan.title +json.description plan.description + +start_date = plan.start_date || Time.now +json.start start_date.to_formatted_s(:iso8601) + +end_date = plan.end_date || (Time.now + 2.years) +json.end end_date&.to_formatted_s(:iso8601) + +if plan.funder.present? || plan.grant_id.present? + json.funding [plan] do + json.partial! 'api/v2/plans/funding', plan: plan + end +end diff --git a/app/views/api/v2/plans/_show.json.jbuilder b/app/views/api/v2/plans/_show.json.jbuilder new file mode 100644 index 0000000000..3e78991447 --- /dev/null +++ b/app/views/api/v2/plans/_show.json.jbuilder @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# locals: plan + +json.schema 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard/tree/master/examples/JSON/JSON-schema/1.0' + +presenter = Api::V2::PlanPresenter.new(plan: plan, complete: @complete) + +# Note the symbol of the dmproadmap json object +# nested in extensions which is the container for the json template object, etc. + +# A JSON representation of a Data Management Plan in the +# RDA Common Standard format +json.title plan.title +json.description plan.description +json.language Api::V2::LanguagePresenter.three_char_code( + lang: plan.owner&.language&.abbreviation +) +json.created plan.created_at.to_formatted_s(:iso8601) +json.modified plan.updated_at.to_formatted_s(:iso8601) + +json.ethical_issues_exist Api::V2::ConversionService.boolean_to_yes_no_unknown(plan.ethical_issues) +json.ethical_issues_description plan.ethical_issues_description +json.ethical_issues_report plan.ethical_issues_report + +id = presenter.identifier +if id.present? + json.dmp_id do + json.partial! 'api/v2/identifiers/show', identifier: id + end +end + +if presenter.data_contact.present? + json.contact do + json.partial! 'api/v2/contributors/show', contributor: presenter.data_contact, + is_contact: true + end +end + +unless @minimal + if presenter.contributors.any? + json.contributor presenter.contributors do |contributor| + json.partial! 'api/v2/contributors/show', contributor: contributor, + is_contact: false + end + end + + if presenter.costs.any? + json.cost presenter.costs do |cost| + json.partial! 'api/v2/plans/cost', cost: cost + end + end + + json.project [plan] do |pln| + json.partial! 'api/v2/plans/project', plan: pln + end + + outputs = plan.research_outputs.any? ? plan.research_outputs : [plan] + + json.dataset outputs do |output| + json.partial! "api/v2/datasets/show", output: output + end + + json.extension [plan.template] do |template| + json.set! :dmproadmap do + json.template do + json.id template.id + json.title template.title + end + end + + if @complete + json.complete_plan do + q_and_a = presenter.complete_plan_data + next if q_and_a.blank? + + json.array! q_and_a do |item| + json.question_id item[:id] + json.title item[:title] + json.section item[:section] + json.question item[:question] + json.answer item[:answer] + end + end + end + end +end diff --git a/app/views/api/v2/plans/index.json.jbuilder b/app/views/api/v2/plans/index.json.jbuilder new file mode 100644 index 0000000000..f19f41d1dc --- /dev/null +++ b/app/views/api/v2/plans/index.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response', total_items: @total_items + +json.items @items do |item| + json.dmp do + json.partial! 'api/v2/plans/show', plan: item + end +end diff --git a/app/views/api/v2/templates/index.json.jbuilder b/app/views/api/v2/templates/index.json.jbuilder new file mode 100644 index 0000000000..cdb35f54fb --- /dev/null +++ b/app/views/api/v2/templates/index.json.jbuilder @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +json.partial! 'api/v2/standard_response', total_items: @total_items + +json.items @items do |template| + presenter = Api::V2::TemplatePresenter.new(template: template) + + json.dmp_template do + json.title presenter.title + json.description template.description + json.version template.version + json.created template.created_at.to_formatted_s(:iso8601) + json.modified template.updated_at.to_formatted_s(:iso8601) + + json.affiliation do + json.partial! 'api/v2/orgs/show', org: template.org + end + + json.template_id do + identifier = Api::V2::ConversionService.to_identifier(context: @application, + value: template.id) + json.partial! 'api/v2/identifiers/show', identifier: identifier + end + end +end diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index 704be320b1..783c3bc9f5 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -1,25 +1,12 @@ <%# locals: user %> +<% v2_token ||= nil %> -<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> -
-
- <%= label_tag(:api_token, _('Access token'), class: 'control-label') %> - <% if user.api_token.present? %> - <%= user.api_token %> - <% else %> - <%= _("Click the button below to generate an API token") %> - <% end %> -
-
- <%= label_tag(:api_information, _('Documentation'), class: 'control-label') %> -
- <%= _('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.').html_safe % { api_v0_wiki: api_wikis[:v0] } %> -

- <%= _('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.').html_safe % { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' } %> -
-
- <%= link_to _("Regenerate token"), - refresh_token_user_path(user), - class: "btn btn-default", remote: true %> -
+
+ <%# v2 API token %> + <%= render partial: "devise/registrations/v2_api_token", locals: { user: user, token: v2_token } %> + + <% if user.can_use_api? %> + <%# v0/v1 API token %> + <%= render partial: "devise/registrations/legacy_api_token", locals: { user: user } %> + <% end %>
diff --git a/app/views/devise/registrations/_legacy_api_token.html.erb b/app/views/devise/registrations/_legacy_api_token.html.erb new file mode 100644 index 0000000000..26386ecbdf --- /dev/null +++ b/app/views/devise/registrations/_legacy_api_token.html.erb @@ -0,0 +1,30 @@ +<%# locals: user %> + +<% api_wikis = Rails.configuration.x.application.api_documentation_urls %> +
+ +
+
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if user.api_token.present? %> + <%= user.api_token %> + <% else %> + <%= _("Click the button below to generate an API token") %> + <% end %> +
+
+ <%= label_tag(:api_information, _('Documentation'), class: 'form-label') %> +
+ <%= _('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.').html_safe % { api_v0_wiki: api_wikis[:v0] } %> +

+ <%= _('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.').html_safe % { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' } %> +
+
+ <%= link_to _("Regenerate token"), + refresh_token_user_path(user), + class: "btn btn-default", remote: true %> +
+
+
diff --git a/app/views/devise/registrations/_v2_api_token.html.erb b/app/views/devise/registrations/_v2_api_token.html.erb new file mode 100644 index 0000000000..04fdf983aa --- /dev/null +++ b/app/views/devise/registrations/_v2_api_token.html.erb @@ -0,0 +1,53 @@ +<%# locals: user %> + +
+ +
+ <% if Api::V2::InternalUserAccessTokenService.application_present? %> +
+ <%= label_tag(:api_token, _('Access token'), class: 'form-label') %> + <% if token.present? %> + <%= text_field_tag( + :api_token_val, + token, + id: 'api-token-val', + class: 'form-control', + style: 'width: auto;', + readonly: true + ) %> + + <%= button_tag( + _('Copy'), + id: 'copy-token-btn', + type: 'button', + class: 'btn btn-default' + ) %> +
+ <%= _( "Please copy this token now and store it somewhere safely." ) %>
+ <%= _( "It will disappear after you leave or refresh this page." ) %> +
+ <% else %> + <%= _( "Click the button below to generate an API token" ) %>
+
+ <%= _("If you previously generated and saved a token, please continue using that token.") %> +
+ <% end %> +
+ +
+ <%= button_tag _("Regenerate token"), + type: :submit, + class: "btn btn-default", + disabled: token.present?, + data: { remote: true, url: api_v2_internal_user_access_token_path, method: :post } %> +
+ <% else %> +
+ <%= _("V2 API token service is currently unavailable. Please contact us for help.") %> + <%= mail_to Rails.application.config.x.organisation.helpdesk_email %> +
+ <% end %> +
+
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index f83d208068..906243caa5 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -16,12 +16,10 @@ <%= _('Password') %> - <% if @user.can_use_api? %> - - <% end %> +
- <% if @user.can_use_api? %> -
-
-
- <%= render partial: 'devise/registrations/api_token', locals: { user: @user } %> -
+
+
+
+ <%= render partial: 'devise/registrations/api_token', locals: { user: @user } %>
- <% end %> +
diff --git a/app/views/doorkeeper/applications/_delete_form.html.erb b/app/views/doorkeeper/applications/_delete_form.html.erb new file mode 100644 index 0000000000..b6377d35f5 --- /dev/null +++ b/app/views/doorkeeper/applications/_delete_form.html.erb @@ -0,0 +1,6 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_application_path(application), method: :delete do %> + <%= submit_tag t('doorkeeper.applications.buttons.destroy'), + onclick: "return confirm('#{ t('doorkeeper.applications.confirmations.destroy') }')", + class: submit_btn_css, style: "border-radius: 0;" %> +<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/_form.html.erb b/app/views/doorkeeper/applications/_form.html.erb new file mode 100644 index 0000000000..5aeeeaf9ab --- /dev/null +++ b/app/views/doorkeeper/applications/_form.html.erb @@ -0,0 +1,42 @@ +<%= form_for application, url: doorkeeper_submit_path(application), as: :doorkeeper_application, html: { role: 'form' } do |f| %> + <% if application.errors.any? %> +

<%= t('doorkeeper.applications.form.error') %>

+ <% end %> + +
+ <%= f.label :name, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :name, class: "form-control #{ 'is-invalid' if application.errors[:name].present? }", required: true, style: "border: 1px solid #ced4da;" %> + <%= doorkeeper_errors_for application, :name %> +
+
+ +
+ <%= f.label "Scope(s)", class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :scopes, class: "form-control #{ 'has-error' if application.errors[:scopes].present? }", style: "border: 1px solid #ced4da;" %> + <%= doorkeeper_errors_for application, :scopes %> + + <%= "Separate multiple scopes with spaces." %> + +
+
+ +
+ <%= f.label nil, 'Redirect URI(s)'.html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_area :redirect_uri, class: "form-control #{ 'is-invalid' if application.errors[:redirect_uri].present? }", style: "border: 1px solid #ced4da;" %> + <%= doorkeeper_errors_for application, :redirect_uri %> + + <%= "Separate multiple URIs with spaces." %> + +
+
+ +
+
+ <%= f.submit t('doorkeeper.applications.buttons.submit'), class: 'btn btn-primary' %> + <%= link_to t('doorkeeper.applications.buttons.cancel'), oauth_applications_path, class: 'btn btn-secondary' %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/edit.html.erb b/app/views/doorkeeper/applications/edit.html.erb new file mode 100644 index 0000000000..95fb9ab2d5 --- /dev/null +++ b/app/views/doorkeeper/applications/edit.html.erb @@ -0,0 +1,5 @@ +
+

<%= t('.title') %>

+
+ +<%= render 'form', application: @application %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/index.html.erb b/app/views/doorkeeper/applications/index.html.erb new file mode 100644 index 0000000000..835e0d917f --- /dev/null +++ b/app/views/doorkeeper/applications/index.html.erb @@ -0,0 +1,34 @@ +
+

<%= t('.title') %>

+
+ +

<%= link_to t('.new'), new_oauth_application_path, class: 'btn btn-secondary', style: "border-radius: 0;" %>

+ + + + + + + + + + + + <% @applications.each do |application| %> + + + + + + + <% end %> + +
<%= t('.name') %><%= t('.callback_url') %><%= t('.actions') %>
+ <%= link_to application.name, oauth_application_path(application) %> + + <%= simple_format(application.redirect_uri) %> + + <%= link_to t('doorkeeper.applications.buttons.edit'), edit_oauth_application_path(application), class: 'btn btn-link' %> + + <%= render 'delete_form', application: application %> +
\ No newline at end of file diff --git a/app/views/doorkeeper/applications/new.html.erb b/app/views/doorkeeper/applications/new.html.erb new file mode 100644 index 0000000000..95fb9ab2d5 --- /dev/null +++ b/app/views/doorkeeper/applications/new.html.erb @@ -0,0 +1,5 @@ +
+

<%= t('.title') %>

+
+ +<%= render 'form', application: @application %> \ No newline at end of file diff --git a/app/views/doorkeeper/applications/show.html.erb b/app/views/doorkeeper/applications/show.html.erb new file mode 100644 index 0000000000..71e06b573e --- /dev/null +++ b/app/views/doorkeeper/applications/show.html.erb @@ -0,0 +1,42 @@ +<%= fields_for @application, as: :doorkeeper_application, html: { role: 'form' } do |f| %> +
+ <%= f.label :name, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :name, class: "form-control", required: true, style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label nil, "Client ID".html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :uid, class: "form-control", required: true, style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label nil, "Client secret".html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :secret, class: "form-control", required: true, style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label "Scope(s)", class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_field :scopes, class: "form-control", style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+ <%= f.label nil, 'Redirect URI(s)'.html_safe, class: 'col-sm-2 col-form-label font-weight-bold' %> +
+ <%= f.text_area :redirect_uri, class: "form-control", style: "border: 1px solid #ced4da;", readonly: true %> +
+
+ +
+
+ <%= link_to "Back", oauth_applications_path, class: 'btn btn-secondary' %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 0000000000..4173caf8f2 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,9 @@ +
+

<%= t('doorkeeper.authorizations.error.title') %>

+
+ +
+
+    <%= (local_assigns[:error_response] ? error_response : @pre_auth.error_response).body[:error_description] %>
+  
+
\ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/form_post.html.erb b/app/views/doorkeeper/authorizations/form_post.html.erb new file mode 100644 index 0000000000..4b0df6c0c6 --- /dev/null +++ b/app/views/doorkeeper/authorizations/form_post.html.erb @@ -0,0 +1,15 @@ + + +<%= form_tag @pre_auth.redirect_uri, method: :post, name: :redirect_form, authenticity_token: false do %> + <% auth.body.compact.each do |key, value| %> + <%= hidden_field_tag key, value %> + <% end %> +<% end %> + + \ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 0000000000..c7b8d0f224 --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,46 @@ + + +
+

+ <%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %> +

+ + <% if @pre_auth.scopes.count > 0 %> +
+

<%= t('.able_to') %>:

+ +
    + <% @pre_auth.scopes.each do |scope| %> +
  • <%= t(scope, scope: [:doorkeeper, :scopes]) + " your plans" %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> + <%= hidden_field_tag :state, @pre_auth.state, id: nil %> + <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> + <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> + <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> + <%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success mt-3 mb-3", style: "border-radius: 0;" %> + <% end %> + <%= form_tag oauth_authorization_path, method: :delete do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %> + <%= hidden_field_tag :state, @pre_auth.state, id: nil %> + <%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %> + <%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %> + <%= hidden_field_tag :scope, @pre_auth.scope, id: nil %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %> + <%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger", style: "border-radius: 0;" %> + <% end %> +
+
\ No newline at end of file diff --git a/app/views/doorkeeper/authorizations/show.html.erb b/app/views/doorkeeper/authorizations/show.html.erb new file mode 100644 index 0000000000..385fc9f24b --- /dev/null +++ b/app/views/doorkeeper/authorizations/show.html.erb @@ -0,0 +1,7 @@ + + +
+ <%= params[:code] %> +
\ No newline at end of file diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.erb b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb new file mode 100644 index 0000000000..b39ef93edb --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.erb @@ -0,0 +1,4 @@ +<%- submit_btn_css ||= 'btn btn-link' %> +<%= form_tag oauth_authorized_application_path(application), method: :delete do %> + <%= submit_tag t('doorkeeper.authorized_applications.buttons.revoke'), onclick: "return confirm('#{ t('doorkeeper.authorized_applications.confirmations.revoke') }')", class: submit_btn_css %> +<% end %> \ No newline at end of file diff --git a/app/views/doorkeeper/authorized_applications/index.html.erb b/app/views/doorkeeper/authorized_applications/index.html.erb new file mode 100644 index 0000000000..08e8429fd2 --- /dev/null +++ b/app/views/doorkeeper/authorized_applications/index.html.erb @@ -0,0 +1,24 @@ + + +
+ + + + + + + + + + <% @applications.each do |application| %> + + + + + + <% end %> + +
<%= t('doorkeeper.authorized_applications.index.application') %><%= t('doorkeeper.authorized_applications.index.created_at') %>
<%= application.name %><%= application.created_at.strftime(t('doorkeeper.authorized_applications.index.date_format')) %><%= render 'delete_form', application: application %>
+
\ No newline at end of file diff --git a/app/views/layouts/_branding.html.erb b/app/views/layouts/_branding.html.erb index 8d451c38be..1d0fa96909 100644 --- a/app/views/layouts/_branding.html.erb +++ b/app/views/layouts/_branding.html.erb @@ -121,6 +121,9 @@ <% end %> <% if current_user.can_super_admin? %> +
  • > <%= link_to(_('Api Clients'), super_admin_api_clients_path) %>
  • diff --git a/app/views/users/refresh_token.js.erb b/app/views/users/refresh_token.js.erb index 1c7f52e44a..76f409f940 100644 --- a/app/views/users/refresh_token.js.erb +++ b/app/views/users/refresh_token.js.erb @@ -1,6 +1,8 @@ -var msg = '<%= @success ? _("Successfully regenerate your API token.") : _("Unable to regenerate your API token.") %>'; +// This view is called by both InternalUserAccessTokensController#create (provides @v2_token) +// and UsersController#refresh_token (does not provide @v2_token). +var msg = '<%= @success ? _("Successfully regenerated your API token.") : _("Unable to regenerate your API token.") %>'; -var context = $('#api-token'); -context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user }) %>'); +var context = $('#api-tokens'); +context.html('<%= escape_javascript(render partial: "/devise/registrations/api_token", locals: { user: current_user, v2_token: @v2_token }) %>'); renderNotice(msg); toggleSpinner(false); diff --git a/config/application.rb b/config/application.rb index 792dd4b0d2..87745f74aa 100644 --- a/config/application.rb +++ b/config/application.rb @@ -103,5 +103,12 @@ class Application < Rails::Application # customized templates. For this reason we are specifying in the # documentation the funder that config.default_funder_id = Rails.application.secrets.default_funder_id.to_i + + # apply application styling to doorkeeper views + config.to_prepare do + Doorkeeper::ApplicationsController.layout "application" + Doorkeeper::AuthorizationsController.layout "application" + Doorkeeper::AuthorizedApplicationsController.layout "application" + end end end diff --git a/config/initializers/_dmproadmap.rb b/config/initializers/_dmproadmap.rb index 23b5fcf13c..50fb61bfed 100644 --- a/config/initializers/_dmproadmap.rb +++ b/config/initializers/_dmproadmap.rb @@ -64,6 +64,8 @@ class Application < Rails::Application # Used throughout the system via ApplicationService.application_name config.x.application.name = 'DMP Assistant' + # Name of the internal Doorkeeper OAuth application for v2 API access tokens + config.x.application.internal_oauth_app_name = 'Internal v2 API Client' # Used as the default domain when 'archiving' (aka anonymizing) a user account # for example `jane.doe@uni.edu` becomes `1234@removed_accounts-example.org` config.x.application.archived_accounts_email_suffix = '@removed_accounts-example.org' diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 0000000000..887239f1fd --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +Doorkeeper.configure do + # set the object-relational-model (ORM) + orm :active_record + + # ensure resource owner is authenticated + resource_owner_authenticator do + if request.path == native_oauth_authorization_path + # Deactivate native_oauth_authorization_path (intended for mobile devices) + redirect_to root_path, alert: "You are not authorized to perform this action." + else + # https://doorkeeper.gitbook.io/guides/ruby-on-rails/configuration + current_user || warden.authenticate!(scope: :user) + end + end + + # ensure only super-admins can manage oauth applications + admin_authenticator do |_routes| + redirect_to root_path, alert: "You are not authorized to perform this action." unless current_user&.can_super_admin? + end + + # grant flows enabled + # Authorization Code Grant Flow (ACGF) + grant_flows %w[authorization_code client_credentials] + + # allow for redirect-uri to be blank + # (required for client_credentials apps for org-admins) + allow_blank_redirect_uri true + + # scopes enabled + default_scopes :read + + # ensure client apps cannot ask for scopes outwith those specified here + enforce_configured_scopes + + # set the token endpoint configurations + access_token_expires_in 2.hours + + # enable refresh tokens of duration 90 days + use_refresh_token expiry: 90.days + + # enable ssl requirement for redirect url + # - Allow HTTP in test and development environments + force_ssl_in_redirect_uri !(Rails.env.test? || Rails.env.development?) + + hash_application_secrets + hash_token_secrets +end diff --git a/config/locales/.translation_io b/config/locales/.translation_io index 153210ff4c..58a904ba80 100644 --- a/config/locales/.translation_io +++ b/config/locales/.translation_io @@ -1,2 +1,2 @@ --- -timestamp: 1676485914 +timestamp: 1770842278 diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 0000000000..b3c4b2c660 --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,154 @@ +en: + activerecord: + attributes: + doorkeeper/application: + name: 'Name' + redirect_uri: 'Redirect URI' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'cannot contain a fragment.' + invalid_uri: 'must be a valid URI.' + unspecified_scheme: 'must specify a scheme.' + relative_uri: 'must be an absolute URI.' + secured_uri: 'must be an HTTPS/SSL URI.' + forbidden_uri: 'is forbidden by the server.' + scopes: + not_match_configured: "doesn't match configured on the server." + + doorkeeper: + applications: + confirmations: + destroy: 'Are you sure?' + buttons: + edit: 'Edit' + destroy: 'Destroy' + submit: 'Submit' + cancel: 'Cancel' + authorize: 'Authorize' + form: + error: 'Whoops! Check your form for possible errors' + help: + confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.' + redirect_uri: 'Use one line per URI' + blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI." + scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.' + edit: + title: 'Edit application' + index: + title: 'Your applications' + new: 'New Application' + name: 'Name' + callback_url: 'Callback URL' + confidential: 'Confidential?' + actions: 'Actions' + confidentiality: + 'yes': 'Yes' + 'no': 'No' + new: + title: 'New Application' + show: + title: 'Application: %{name}' + application_id: 'UID' + secret: 'Secret' + secret_hashed: 'Secret hashed' + scopes: 'Scopes' + confidential: 'Confidential' + callback_urls: 'Callback urls' + actions: 'Actions' + not_defined: 'Not defined' + + authorizations: + buttons: + authorize: 'Authorize' + deny: 'Deny' + error: + title: 'An error has occurred' + new: + title: 'Authorization required' + prompt: 'Authorize %{client_name} to use your account?' + able_to: 'This application will be able to' + show: + title: 'Authorization code' + form_post: + title: 'Submit this form' + + authorized_applications: + confirmations: + revoke: 'Are you sure?' + buttons: + revoke: 'Revoke' + index: + title: 'Your authorized applications' + application: 'Application' + created_at: 'Created At' + date_format: '%Y-%m-%d %H:%M:%S' + + pre_authorization: + status: 'Pre-authorization' + + errors: + messages: + # Common error messages + invalid_request: + unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' + missing_param: 'Missing required parameter: %{value}.' + request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.' + invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI." + unauthorized_client: 'The client is not authorized to perform this request using this method.' + access_denied: 'The resource owner or authorization server denied the request.' + invalid_scope: 'The requested scope is invalid, unknown, or malformed.' + invalid_code_challenge_method: + zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.' + one: 'The code_challenge_method must be %{challenge_methods}.' + other: 'The code_challenge_method must be one of %{challenge_methods}.' + server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' + temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' + + # Configuration error messages + credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' + resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.' + admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.' + + # Access grant errors + unsupported_response_type: 'The authorization server does not support this response type.' + unsupported_response_mode: 'The authorization server does not support this response mode.' + + # Access token errors + invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' + invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' + unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' + + invalid_token: + revoked: "The access token was revoked" + expired: "The access token expired" + unknown: "The access token is invalid" + revoke: + unauthorized: "You are not authorized to revoke this token" + + forbidden_token: + missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".' + + flash: + applications: + create: + notice: 'Application created.' + destroy: + notice: 'Application deleted.' + update: + notice: 'Application updated.' + authorized_applications: + destroy: + notice: 'Application revoked.' + + layouts: + admin: + title: 'Doorkeeper' + nav: + oauth2_provider: 'OAuth2 Provider' + applications: 'Applications' + home: 'Home' + application: + title: 'OAuth authorization required' diff --git a/config/locales/localization.en-CA.yml b/config/locales/localization.en-CA.yml index 5182a9f0f5..c176fa139c 100644 --- a/config/locales/localization.en-CA.yml +++ b/config/locales/localization.en-CA.yml @@ -67,10 +67,3 @@ en-CA: default: "%a, %d %b %Y %H:%M:%S %z" long: "%d %B, %Y %H:%M" short: "%d %b %H:%M" - devise: - registrations: - signed_up: Welcome! You have signed up successfully. - sessions: - already_signed_out: Signed out successfully. - signed_in: Signed in successfully. - signed_out: Signed out successfully. diff --git a/config/locales/localization.fr-CA.yml b/config/locales/localization.fr-CA.yml index 2d80ac843e..00513632f1 100644 --- a/config/locales/localization.fr-CA.yml +++ b/config/locales/localization.fr-CA.yml @@ -90,10 +90,3 @@ fr-CA: spree: date_picker: first_day: 0 - devise: - registrations: - signed_up: Bienvenue ! Vous vous êtes enregistré(e) avec succès. - sessions: - already_signed_out: Déconnecté(e). - signed_in: Connecté(e) avec succès. - signed_out: Déconnecté(e) avec succès. diff --git a/config/locales/translation.en-CA.yml b/config/locales/translation.en-CA.yml index f6b002c8bd..9d4b63f736 100644 --- a/config/locales/translation.en-CA.yml +++ b/config/locales/translation.en-CA.yml @@ -128,11 +128,27 @@ en-CA: activerecord: errors: messages: - complexity: "complexity requirement not met." record_invalid: 'Validation failed: %{errors}' restrict_dependent_destroy: has_one: Cannot delete record because a dependent %{record} exists has_many: Cannot delete record because dependent %{record} exist + complexity: complexity requirement not met. + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: cannot contain a fragment. + invalid_uri: must be a valid URI. + unspecified_scheme: must specify a scheme. + relative_uri: must be an absolute URI. + secured_uri: must be an HTTPS/SSL URI. + forbidden_uri: is forbidden by the server. + scopes: + not_match_configured: doesn't match configured on the server. + attributes: + doorkeeper/application: + name: Name + redirect_uri: Redirect URI datetime: distance_in_words: half_a_minute: half a minute @@ -343,3 +359,140 @@ en-CA: previous: "‹ Prev" next: Next › truncate: "…" + doorkeeper: + applications: + confirmations: + destroy: Are you sure? + buttons: + edit: Edit + destroy: Destroy + submit: Submit + cancel: Cancel + authorize: Authorize + form: + error: Whoops! Check your form for possible errors + help: + confidential: Application will be used where the client secret can be kept + confidential. Native mobile apps and Single Page Apps are considered non-confidential. + redirect_uri: Use one line per URI + blank_redirect_uri: Leave it blank if you configured your provider to use + Client Credentials, Resource Owner Password Credentials or any other grant + type that doesn't require redirect URI. + scopes: Separate scopes with spaces. Leave blank to use the default scopes. + edit: + title: Edit application + index: + title: Your applications + new: New Application + name: Name + callback_url: Callback URL + confidential: Confidential? + actions: Actions + confidentiality: + 'yes': 'Yes' + 'no': 'No' + new: + title: New Application + show: + title: 'Application: %{name}' + application_id: Application UID + secret: Secret + secret_hashed: + scopes: Scopes + confidential: Confidential + callback_urls: Callback urls + actions: Actions + not_defined: + authorizations: + buttons: + authorize: Authorize + deny: Deny + error: + title: An error has occurred + new: + title: Authorization required + prompt: Authorize %{client_name} to use your account? + able_to: This application will be able to + show: + title: Authorization code + form_post: + title: + authorized_applications: + confirmations: + revoke: Are you sure? + buttons: + revoke: Revoke + index: + title: Your authorized applications + application: Application + created_at: Created At + date_format: "%Y-%m-%d %H:%M:%S" + pre_authorization: + status: Pre-authorization + errors: + messages: + invalid_request: + unknown: The request is missing a required parameter, includes an unsupported + parameter value, or is otherwise malformed. + missing_param: 'Missing required parameter: %{value}.' + request_not_authorized: Request need to be authorized. Required parameter + for authorizing request is missing or invalid. + invalid_code_challenge: + invalid_redirect_uri: The requested redirect uri is malformed or doesn't match + client redirect URI. + unauthorized_client: The client is not authorized to perform this request + using this method. + access_denied: The resource owner or authorization server denied the request. + invalid_scope: The requested scope is invalid, unknown, or malformed. + invalid_code_challenge_method: + zero: + one: + other: + server_error: The authorization server encountered an unexpected condition + which prevented it from fulfilling the request. + temporarily_unavailable: The authorization server is currently unable to handle + the request due to a temporary overloading or maintenance of the server. + credential_flow_not_configured: Resource Owner Password Credentials flow failed + due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured. + resource_owner_authenticator_not_configured: Resource Owner find failed due + to Doorkeeper.configure.resource_owner_authenticator being unconfigured. + admin_authenticator_not_configured: Access to admin panel is forbidden due + to Doorkeeper.configure.admin_authenticator being unconfigured. + unsupported_response_type: The authorization server does not support this + response type. + unsupported_response_mode: + invalid_client: Client authentication failed due to unknown client, no client + authentication included, or unsupported authentication method. + invalid_grant: The provided authorization grant is invalid, expired, revoked, + does not match the redirection URI used in the authorization request, or + was issued to another client. + unsupported_grant_type: The authorization grant type is not supported by the + authorization server. + invalid_token: + revoked: The access token was revoked + expired: The access token expired + unknown: The access token is invalid + revoke: + unauthorized: You are not authorized to revoke this token + forbidden_token: + missing_scope: + flash: + applications: + create: + notice: Application created. + destroy: + notice: Application deleted. + update: + notice: Application updated. + authorized_applications: + destroy: + notice: Application revoked. + layouts: + admin: + title: Doorkeeper + nav: + oauth2_provider: OAuth2 Provider + applications: Applications + home: Home + application: + title: OAuth authorization required diff --git a/config/locales/translation.fr-CA.yml b/config/locales/translation.fr-CA.yml index 8d72ae013e..54505d4a93 100644 --- a/config/locales/translation.fr-CA.yml +++ b/config/locales/translation.fr-CA.yml @@ -136,13 +136,29 @@ fr-CA: activerecord: errors: messages: - complexity: ' Le mot de passe ne respecte pas les exigences de complexité.' record_invalid: 'La validation a échoué : %{errors}' restrict_dependent_destroy: has_one: Vous ne pouvez pas supprimer l'enregistrement car un(e) %{record} dépendant(e) existe has_many: Vous ne pouvez pas supprimer l'enregistrement parce que les %{record} dépendants existent + complexity: " Le mot de passe ne respecte pas les exigences de complexité." + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: ne peut contenir un fragment. + invalid_uri: doit être une URL valide. + unspecified_scheme: doit spécifier un schéma. + relative_uri: doit être une URL absolue. + secured_uri: doit être une URL HTTP/SSL. + forbidden_uri: est interdit par le serveur. + scopes: + not_match_configured: doesn't match configured on the server. + attributes: + doorkeeper/application: + name: Nom + redirect_uri: L'URL de redirection datetime: distance_in_words: half_a_minute: une demi-minute @@ -365,3 +381,148 @@ fr-CA: previous: "‹ précédent(e)" next: "› suivant(e)" truncate: "…" + doorkeeper: + applications: + confirmations: + destroy: Êtes-vous certain? + buttons: + edit: Modifier + destroy: Supprimer + submit: Envoyer + cancel: Annuler + authorize: Autoriser + form: + error: Oups! Vérifier votre formulaire pour des erreurs possibles + help: + confidential: | + L'application sera utilisée quand la confidentialité du secret pourra + être maintenue. Les application mobile native, et les Applications + mono-page ne sont pas considérées comme sûr. + redirect_uri: Utiliser une ligne par URL + blank_redirect_uri: Leave it blank if you configured your provider to use + Client Credentials, Resource Owner Password Credentials or any other grant + type that doesn't require redirect URI. + scopes: Utilisez un espace entre chaque portée. Laissez vide pour utiliser + la portée par defaut + edit: + title: Modifier l'application + index: + title: Vos applications + new: Nouvelle application + name: Nom + callback_url: URL de retour d'appel + confidential: Confidential? + actions: Actions + confidentiality: + 'yes': Oui + 'no': Non + new: + title: Nouvelle application + show: + title: 'Application : %{name}' + application_id: ID de l'application + secret: Secret + secret_hashed: + scopes: Portées + confidential: Confidential + callback_urls: URL du retour d'appel + actions: Actions + not_defined: + authorizations: + buttons: + authorize: Autoriser + deny: Refuser + error: + title: Une erreur est survenue + new: + title: Autorisation requise + prompt: Autorisez %{client_name} à utiliser votre compte? + able_to: Cette application pourra + show: + title: Code d'autorisation + form_post: + title: + authorized_applications: + confirmations: + revoke: Êtes-vous certain? + buttons: + revoke: Annuler + index: + title: Vos applications autorisées + application: Application + created_at: Créé le + date_format: "%Y-%m-%d %H:%M:%S" + pre_authorization: + status: Pre-authorization + errors: + messages: + invalid_request: + unknown: La demande manque un paramètre requis, inclut une valeur de paramètre + non prise en charge, ou est autrement mal formée. + missing_param: 'Missing required parameter: %{value}.' + request_not_authorized: Request need to be authorized. Required parameter + for authorizing request is missing or invalid. + invalid_code_challenge: + invalid_redirect_uri: L'URL de redirection n'est pas valide. + unauthorized_client: Le client n'est pas autorisé à effectuer cette demande + à l'aide de cette méthode. + access_denied: Le propriétaire de la ressource ou le serveur d'autorisation + a refusé la demande. + invalid_scope: Le scope demandé n'est pas valide, est inconnu, ou est mal + formé. + invalid_code_challenge_method: + zero: + one: + other: + server_error: Le serveur d'autorisation a rencontré une condition inattendue + qui l'a empêché de remplir la demande. + temporarily_unavailable: Le serveur d'autorisation est actuellement incapable + de traiter la demande à cause d'une surcharge ou d'un entretien temporaire + du serveur. + credential_flow_not_configured: Le flux des identifiants du mot de passe du + propriétaire de la ressource a échoué en raison de Doorkeeper.configure.resource_owner_from_credentials + n'est pas configuré. + resource_owner_authenticator_not_configured: La recherche du propriétaire + de la ressource a échoué en raison de Doorkeeper.configure.resource_owner_authenticator + n'est pas configuré. + admin_authenticator_not_configured: Access to admin panel is forbidden due + to Doorkeeper.configure.admin_authenticator being unconfigured. + unsupported_response_type: Le serveur d'autorisation ne prend pas en charge + ce type de réponse. + unsupported_response_mode: + invalid_client: L'authentification du client a échoué à cause d'un client + inconnu, d'aucune authentification de client incluse, ou d'une méthode d'authentification + non prise en charge. + invalid_grant: Le consentement d'autorisation accordé n'est pas valide, a + expiré, est annulé, ne concorde pas avec l'URL de redirection utilisée dans + la demande d'autorisation, ou a été émis à un autre client. + unsupported_grant_type: Le type de consentement d'autorisation n'est pas pris + en charge par le serveur d'autorisation. + invalid_token: + revoked: Le jeton d'accès a été annulé + expired: Le jeton d'accès a expiré + unknown: Le jeton d'accès n'est pas valide + revoke: + unauthorized: Vous n'êtes pas autorisé à révoquer ce jeton + forbidden_token: + missing_scope: + flash: + applications: + create: + notice: Application créée. + destroy: + notice: Application supprimée. + update: + notice: Application mise à jour. + authorized_applications: + destroy: + notice: Application annulée. + layouts: + admin: + title: Doorkeeper + nav: + oauth2_provider: Fournisseur OAuth2 + applications: Applications + home: Home + application: + title: Autorisation OAuth requise diff --git a/config/routes.rb b/config/routes.rb index daf0d3e1a7..0991ecb08d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,7 @@ # rubocop:disable Metrics/BlockLength Rails.application.routes.draw do + use_doorkeeper # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html devise_for(:users, controllers: { @@ -208,6 +209,15 @@ namespace :ca_dashboard do resources :stats, only: [:index] end + + namespace :v2 do + get :heartbeat, controller: :base_api + get :me, controller: :base_api + + resources :plans, only: %i[index show] + resources :templates, only: :index + resource :internal_user_access_token, only: :create, defaults: { format: :js } + end end namespace :paginable do diff --git a/db/migrate/20260211183556_create_doorkeeper_tables.rb b/db/migrate/20260211183556_create_doorkeeper_tables.rb new file mode 100644 index 0000000000..4dd60d10bf --- /dev/null +++ b/db/migrate/20260211183556_create_doorkeeper_tables.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class CreateDoorkeeperTables < ActiveRecord::Migration[6.1] + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + # Remove `null: false` or use conditional constraint if you are planning to use public clients. + t.string :secret, null: false + + # Remove `null: false` if you are planning to use grant flows + # that doesn't require redirect URI to be used during authorization + # like Client Credentials flow or Resource Owner Password. + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.boolean :confidential, null: false, default: true + t.timestamps null: false + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_grants do |t| + t.references :resource_owner, null: false + t.references :application, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.datetime :created_at, null: false + t.datetime :revoked_at + end + + add_index :oauth_access_grants, :token, unique: true + add_foreign_key( + :oauth_access_grants, + :oauth_applications, + column: :application_id + ) + + create_table :oauth_access_tokens do |t| + t.references :resource_owner, index: true + + # Remove `null: false` if you are planning to use Password + # Credentials Grant flow that doesn't require an application. + t.references :application, null: false + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.string :scopes + t.datetime :created_at, null: false + t.datetime :revoked_at + + # The authorization server MAY issue a new refresh token, in which case + # *the client MUST discard the old refresh token* and replace it with the + # new refresh token. The authorization server MAY revoke the old + # refresh token after issuing a new refresh token to the client. + # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 + # + # Doorkeeper implementation: if there is a `previous_refresh_token` column, + # refresh tokens will be revoked after a related access token is used. + # If there is no `previous_refresh_token` column, previous tokens are + # revoked as soon as a new access token is created. + # + # Comment out this line if you want refresh tokens to be instantly + # revoked after use. + t.string :previous_refresh_token, null: false, default: "" + end + + add_index :oauth_access_tokens, :token, unique: true + + # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592 + if ActiveRecord::Base.connection.adapter_name == "SQLServer" + execute <<~SQL.squish + CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token) + WHERE refresh_token IS NOT NULL + SQL + else + add_index :oauth_access_tokens, :refresh_token, unique: true + end + + add_foreign_key( + :oauth_access_tokens, + :oauth_applications, + column: :application_id + ) + + # Uncomment below to ensure a valid reference to the resource owner's table + add_foreign_key :oauth_access_grants, :users, column: :resource_owner_id + add_foreign_key :oauth_access_tokens, :users, column: :resource_owner_id + end +end diff --git a/db/migrate/20260219200258_allow_null_redirect_uri_in_oauth_applications.rb b/db/migrate/20260219200258_allow_null_redirect_uri_in_oauth_applications.rb new file mode 100644 index 0000000000..1c67c7cf1d --- /dev/null +++ b/db/migrate/20260219200258_allow_null_redirect_uri_in_oauth_applications.rb @@ -0,0 +1,9 @@ +class AllowNullRedirectUriInOauthApplications < ActiveRecord::Migration[6.1] + def change + # We currently have `allow_blank_redirect_uri true` in + # `config/initializers/doorkeeper.rb`. Removing the NOT NULL constraint + # allows us to save OAuthApplications with blank redirect_uri values + # https://github.com/doorkeeper-gem/doorkeeper/wiki/Allow-blank-redirect-URI-for-Applications + change_column_null :oauth_applications, :redirect_uri, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 466b1fbda9..fdd8b29448 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: 2024_08_20_190548) do +ActiveRecord::Schema.define(version: 2026_02_19_200258) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -315,6 +315,48 @@ t.boolean "enabled", default: true end + create_table "oauth_access_grants", force: :cascade do |t| + t.bigint "resource_owner_id", null: false + t.bigint "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.index ["application_id"], name: "index_oauth_access_grants_on_application_id" + t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true + end + + create_table "oauth_access_tokens", force: :cascade do |t| + t.bigint "resource_owner_id" + t.bigint "application_id", null: false + t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "previous_refresh_token", default: "", null: false + t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true + end + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri" + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + create_table "option_warnings", id: :serial, force: :cascade do |t| t.integer "organisation_id" t.integer "option_id" @@ -879,6 +921,10 @@ add_foreign_key "notes", "users" add_foreign_key "notification_acknowledgements", "notifications" add_foreign_key "notification_acknowledgements", "users" + add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id" + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id" add_foreign_key "org_token_permissions", "orgs" add_foreign_key "org_token_permissions", "token_permission_types" add_foreign_key "orgs", "languages" diff --git a/lib/tasks/doorkeeper.rake b/lib/tasks/doorkeeper.rake new file mode 100644 index 0000000000..60b279eb71 --- /dev/null +++ b/lib/tasks/doorkeeper.rake @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +namespace :doorkeeper do + desc 'Ensure internal OAuth application exists' + task ensure_internal_app: :environment do + app = Doorkeeper::Application.find_or_create_by!( + name: Rails.application.config.x.application.internal_oauth_app_name + ) do |a| + a.scopes = 'read' + a.confidential = true + end + + puts "Internal OAuth app ready (id=#{app.id}, uid=#{app.uid})" + end +end diff --git a/spec/factories/identifier_schemes.rb b/spec/factories/identifier_schemes.rb index d9e3de64bb..03b1e38726 100644 --- a/spec/factories/identifier_schemes.rb +++ b/spec/factories/identifier_schemes.rb @@ -38,5 +38,19 @@ description { 'CILogon' } identifier_prefix { 'https://www.cilogon.org/' } end + + %i[ + authentication + orgs + plans + users + contributors + identification + research_outputs + ].each do |context| + trait :"for_#{context}" do + add_attribute(:"for_#{context}") { true } + end + end end end diff --git a/spec/factories/oauth_access_grants.rb b/spec/factories/oauth_access_grants.rb new file mode 100644 index 0000000000..2cd8dc8875 --- /dev/null +++ b/spec/factories/oauth_access_grants.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_access_grants +# +# id :integer not null, primary key +# resource_owner_id :integer not null +# application_id :integer not null +# token :string not null +# expires_in :integer +# revoked_at :datetime +# created_at :datetime not null +# scopes :string not null +# +# Indexes +# +# index_oauth_access_grants_on_token (token) +# +# Foreign Keys +# +# fk_rails_... (resource_owner_id => users.id) +# fk_rails_... (application_id => oauth_applications.id) + +FactoryBot.define do + factory :oauth_access_grant, class: 'doorkeeper/access_grant' do + token { SecureRandom.uuid } + expires_in { Faker::Number.number(digits: 8) } + scopes { Doorkeeper.config.default_scopes + Doorkeeper.config.optional_scopes } + redirect_uri { Faker::Internet.url } + + trait :revoked do + revoked_at { 2.hours.ago } + end + end +end diff --git a/spec/factories/oauth_access_tokens.rb b/spec/factories/oauth_access_tokens.rb new file mode 100644 index 0000000000..aa94d6b373 --- /dev/null +++ b/spec/factories/oauth_access_tokens.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_access_tokens +# +# id :integer not null, primary key +# resource_owner_id :integer not null +# application_id :integer not null +# token :string not null +# refresh_token :string +# expires_in :integer +# revoked_at :datetime +# created_at :datetime not null +# scopes :string not null +# previous_refresh_token :string +# +# Indexes +# +# index_oauth_access_tokens_on_token (token) +# +# Foreign Keys +# +# fk_rails_... (resource_owner_id => users.id) +# fk_rails_... (application_id => oauth_applications.id) + +FactoryBot.define do + factory :oauth_access_token, class: 'doorkeeper/access_token' do + token { SecureRandom.uuid } + refresh_token { SecureRandom.uuid } + expires_in { Faker::Number.number(digits: 8) } + scopes { Doorkeeper.config.default_scopes + Doorkeeper.config.optional_scopes } + + trait :revoked do + revoked_at { 2.hours.ago } + end + end +end diff --git a/spec/factories/oauth_applications.rb b/spec/factories/oauth_applications.rb new file mode 100644 index 0000000000..469dd66269 --- /dev/null +++ b/spec/factories/oauth_applications.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: oauth_application +# +# id: :integer +# name: :string +# uid: :string +# secret: :string +# redirect_uri: :text +# scopes: :string +# confidential: :boolean +# created_at: :datetime +# updated_at: :datetime + +FactoryBot.define do + factory :oauth_application, class: 'doorkeeper/application' do + name { Faker::Lorem.unique.word } + uid { SecureRandom.uuid } + secret { SecureRandom.uuid } + redirect_uri { "https://#{Faker::Internet.unique.domain_name}/callback" } + scopes { 'read' } + end +end diff --git a/spec/factories/research_domains.rb b/spec/factories/research_domains.rb index 7d4017f924..23049903be 100644 --- a/spec/factories/research_domains.rb +++ b/spec/factories/research_domains.rb @@ -23,6 +23,5 @@ factory :research_domain do identifier { SecureRandom.uuid } label { Faker::Lorem.unique.word } - uri { Faker::Internet.url } end end diff --git a/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb new file mode 100644 index 0000000000..3a59becfb3 --- /dev/null +++ b/spec/requests/api/v2/internal_user_access_tokens_controller_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::InternalUserAccessTokensController do + let(:user) { create(:user) } + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + describe 'POST #create' do + def post_create_token + post api_v2_internal_user_access_token_path + end + + context 'when user is not authenticated' do + # In production, CSRF protection would reject the request with a 422 error + # before it reaches Pundit. However, RSpec bypasses CSRF checks, so this + # test verifies that Pundit raises NotDefinedError when authorize is called + # with nil. This error won't occur in production due to CSRF protection. + it 'raises Pundit::NotDefinedError and does not create a token' do + expect do + expect do + post_create_token + end.to raise_error(Pundit::NotDefinedError) + end.not_to change { Doorkeeper::AccessToken.count } + end + end + + context 'when user is authenticated' do + before { sign_in(user) } + + it 'rotates the user token' do + post_create_token + + expect(response).to have_http_status(:ok) + end + + it 'creates a new token' do + expect do + post_create_token + end.to change { Doorkeeper::AccessToken.count }.by(1) + end + + it 'assigns the plaintext token' do + post_create_token + + expect(assigns(:v2_token)).to be_a(String) + expect(assigns(:v2_token)).not_to be_blank + end + + it 'renders the refresh_token template' do + post_create_token + + expect(response).to render_template('users/refresh_token') + end + + context 'when a token already exists' do + let!(:old_token) do + create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read') + end + + it 'revokes the old token' do + post_create_token + + old_token.reload + expect(old_token.revoked_at).not_to be_nil + end + + it 'creates a new token' do + post_create_token + + new_token = assigns(:token) + expect(new_token).not_to eq(old_token) + end + end + end + + context 'when the internal OAuth application is missing' do + before do + sign_in(user) + oauth_app.destroy + end + + it 'raises a StandardError' do + expect do + post_create_token + end.to raise_error(StandardError, /not found/) + end + end + end +end diff --git a/spec/requests/api/v2/plans_controller_spec.rb b/spec/requests/api/v2/plans_controller_spec.rb new file mode 100644 index 0000000000..dc528ca179 --- /dev/null +++ b/spec/requests/api/v2/plans_controller_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::PlansController do + include ApiHelper + include Mocks::ApiV2JsonSamples + include Webmocks + include IdentifierHelper + + context 'OAuth (authorization_code grant type) — on behalf of a user' do + before do + @user = create(:user) + @client = create(:oauth_application) + token = mock_authorization_code_token(oauth_application: @client, user: @user).plaintext_token + + @headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{token}" + } + end + + def fetch_plans_json_response + get(api_v2_plans_path, headers: @headers) + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/plans/index') + JSON.parse(response.body).with_indifferent_access + end + + describe 'GET /api/v2/plans (index)' do + context 'an invalid API token is included' do + it 'returns a 401 and the expected Oauth 2.0 headers' do + # Swap actual token with a random string + @headers['Authorization'] = "Bearer #{SecureRandom.uuid}" + get(api_v2_plans_path, headers: @headers) + + expect(response.code).to eql('401') + expect(response.body).to be_empty + + # Expect Doorkeeper to return the standard OAuth 2.0 WWW-Authenticate header for invalid tokens + expect(response.headers['WWW-Authenticate']).to match( + /Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"/ + ) + end + end + + context 'a valid API token is included' do + let(:json) { fetch_plans_json_response } + it 'returns a 200 and the expected response body' do + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + + # Status code and message are correct + expect(json[:code]).to eq(200) + expect(json[:message]).to eq('OK') + + # Application and source are present and sensible + expect(json[:application]).to eq(ApplicationService.application_name) + expect(json[:source]).to eq('GET /api/v2/plans') + + # Time is present and parseable + expect { Time.iso8601(json[:time]) }.not_to raise_error + + # Caller is included + expect(json[:caller]).to eq(@client.name) + end + + it 'returns an empty array if no plans are available' do + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + end + + it 'returns the expected plans' do + # See `app/policies/api/v2/plans_policy.rb for plans included/excluded via `GET api/v2/plans` + + # Create the included plans + included_plans = [create(:plan, org: @user.org), create(:plan)] + included_plans[0].add_user!(@user.id, :creator) + # Add multiple roles for testing (ensure duplicate plans will not returned) + included_plans[1].add_user!(@user.id, :editor) + included_plans[1].add_user!(@user.id, :commenter) + + # Created the excluded plans + create(:plan, :creator, org: @user.org) + inactive_plan = create(:plan, :creator) + inactive_plan.add_user!(@user.id, :editor) + Role.where(plan_id: inactive_plan.id, user_id: @user.id).update(active: false) + + expect(json[:items].length).to be(included_plans.length) + + # Api::V2::PlanPresenter.identifier uses api_v2_plan_url(@plan) to set the "identifier". + # That url is constructed using `request.host` / "www.example.com" + # api_v2_plan_url(@plan) within this test will construct the url via + # default_url_options[:host] / "example.org" + # Because the urls are misaligned, we will only compare the paths here. + # TODO: Consider aligning default_url_options[:host] (in test.rb) with `request.host` + returned_identifiers = json[:items].map { |item| item[:dmp][:dmp_id][:identifier] } + returned_paths = returned_identifiers.map { |url| URI(url).path } + expected_paths = included_plans.map { |plan| api_v2_plan_path(plan) } + expect(returned_paths).to eq(expected_paths) + end + + it 'allows for paging' do + original_page_size = Rails.configuration.x.application.api_max_page_size + Rails.configuration.x.application.api_max_page_size = 10 + + create_list(:plan, 11, :publicly_visible) do |plan| + plan.add_user!(@user.id, :commenter) + end + json = fetch_plans_json_response + + test_paging(json: json, headers: @headers) + + Rails.configuration.x.application.api_max_page_size = original_page_size + end + end + end + end +end diff --git a/spec/requests/api/v2/templates_controller_spec.rb b/spec/requests/api/v2/templates_controller_spec.rb new file mode 100644 index 0000000000..e84825ae51 --- /dev/null +++ b/spec/requests/api/v2/templates_controller_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::TemplatesController do + include ApiHelper + + before do + @user = create(:user) + @client = create(:oauth_application) + token = mock_authorization_code_token(oauth_application: @client, user: @user).plaintext_token + + @headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: "Bearer #{token}" + } + end + + def fetch_templates_json_response + get(api_v2_templates_path, headers: @headers) + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/templates/index') + JSON.parse(response.body).with_indifferent_access + end + + describe 'GET /api/v2/templates (index)' do + context 'an invalid API token is included' do + it 'returns 401 if the token is invalid' do + @headers['Authorization'] = "Bearer #{SecureRandom.uuid}" + get(api_v2_templates_path, headers: @headers) + + expect(response.code).to eql('401') + expect(response.body).to be_empty + + # Expect Doorkeeper to return the standard OAuth 2.0 WWW-Authenticate header for invalid tokens + expect(response.headers['WWW-Authenticate']).to match( + /Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"/ + ) + end + end + + context 'a valid API token is included' do + it 'returns a 200 and the expected response body' do + json = fetch_templates_json_response + + # Items array is empty + expect(json[:items]).to eq([]) + + # total_items reflects that nothing is returned + expect(json[:total_items]).to eq(0) + + # Status code and message are correct + expect(json[:code]).to eq(200) + expect(json[:message]).to eq('OK') + + # Application and source are present and sensible + expect(json[:application]).to eq(ApplicationService.application_name) + expect(json[:source]).to eq('GET /api/v2/templates') + + # Time is present and parseable + expect { Time.iso8601(json[:time]) }.not_to raise_error + + # Caller is included + expect(json[:caller]).to eq(@client.name) + end + + it 'returns an empty array if no templates are available' do + get(api_v2_templates_path, headers: @headers) + + expect(response.code).to eql('200') + expect(response).to render_template('api/v2/_standard_response') + expect(response).to render_template('api/v2/templates/index') + + json = JSON.parse(response.body).with_indifferent_access + expect(json[:items].empty?).to be(true) + expect(json[:errors].nil?).to be(true) + end + + it 'returns the expected templates' do + # See `app/policies/api/v2/templates_policy.rb for templates included/excluded via `GET api/v2/templates` + + # All included templates must be published and are either: + # - 1) organisationally_visible and template.org_id == user.org_id + # - 2) publicly_visible and customization of == nil + + public_template = create(:template, :publicly_visible, published: true) + + included_templates = [ + public_template, + create(:template, :organisationally_visible, published: true, org: @user.org) + ] + + # excluded_templates + # unpublished template + create(:template, :publicly_visible, published: false, org: @user.org) + # organisationally_visible and template.org_id != user.org_id + create(:template, :organisationally_visible, published: true) + # publicly_visible and customization of != nil + create(:template, :publicly_visible, published: true, customization_of: public_template.family_id) + + json = fetch_templates_json_response + + expect(json[:items].length).to be(2) + template_ids = json[:items].map { |item| item[:dmp_template][:template_id][:identifier] } + expect(template_ids).to match_array(included_templates.map { |t| t.id.to_s }) + end + + it 'allows for paging' do + original_page_size = Rails.configuration.x.application.api_max_page_size + Rails.configuration.x.application.api_max_page_size = 10 + create_list(:template, 11, visibility: 1, published: true) + get(api_v2_templates_path, headers: @headers) + + test_paging(json: JSON.parse(response.body), headers: @headers) + Rails.configuration.x.application.api_max_page_size = original_page_size + end + end + end +end diff --git a/spec/services/api/v2/internal_user_access_token_service_spec.rb b/spec/services/api/v2/internal_user_access_token_service_spec.rb new file mode 100644 index 0000000000..7455771740 --- /dev/null +++ b/spec/services/api/v2/internal_user_access_token_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V2::InternalUserAccessTokenService do + let(:user) { create(:user) } + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + def create_internal_user_access_token + create(:oauth_access_token, application: oauth_app, resource_owner_id: user.id, scopes: 'read') + end + + describe '#rotate!' do + def rotate_token_expectations(plaintext_token, old_token = nil) # rubocop:disable Metrics/AbcSize + # Doorkeeper hashes token via Digest::SHA256 + hashed = Digest::SHA256.hexdigest(plaintext_token) + new_token = Doorkeeper::AccessToken.find_by!(token: hashed) + expect(new_token).to be_present + expect(new_token.resource_owner_id).to eq(user.id) + expect(new_token.revoked_at).to be_nil + expect(new_token.scopes.to_s).to include('read') + expect(old_token.revoked_at).not_to be_nil if old_token + end + + shared_examples 'token rotation' do |has_old_token| + it "#{if has_old_token + 'revokes the old token and creates a new one' + else + 'creates a new token' + end} + (returns plaintext)" do + plaintext_token = nil + # Ensure .rotate!(user) creates a new AccessToken db entry for user + expect { plaintext_token = described_class.rotate!(user) } + .to change { Doorkeeper::AccessToken.where(resource_owner_id: user.id).count } + .by(1) + if has_old_token + old_token.reload + rotate_token_expectations(plaintext_token, old_token) + else + rotate_token_expectations(plaintext_token) + end + end + end + + context 'when a token already exists' do + let!(:old_token) { create_internal_user_access_token } + include_examples 'token rotation', true + end + + context 'when no token exists' do + include_examples 'token rotation', false + end + end + + describe '#application_present?' do + context 'when the app exists' do + it 'returns true' do + expect(described_class.application_present?).to be true + end + end + + context 'when the app does not exist' do + before { oauth_app.destroy } + + it 'returns false' do + expect(described_class.application_present?).to be false + end + end + end +end diff --git a/spec/support/helpers/api.rb b/spec/support/helpers/api.rb index c87931c12e..2a2398dfe7 100644 --- a/spec/support/helpers/api.rb +++ b/spec/support/helpers/api.rb @@ -18,4 +18,44 @@ def mock_authorization_for_user(user: nil) Api::V1::BaseApiController.any_instance.stubs(:authorize_request).returns(true) Api::V1::BaseApiController.any_instance.stubs(:client).returns(user) end + + # API V2+ - Oauth authorization_code grant flow (on behalf of a user) + def mock_authorization_code_token(oauth_application: create(:oauth_application), user: create(:user), + scopes: 'read') + create(:oauth_access_grant, application_id: oauth_application.id, resource_owner_id: user.id, scopes: scopes) + create(:oauth_access_token, application: oauth_application, resource_owner_id: user.id, scopes: scopes) + end + + # Tests the standard pagination functionality + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def test_paging(json: {}, headers: {}) + json = json.with_indifferent_access + original = json[:items].first + if json[:next].present? + # Move to the next page + get(json[:next], headers: headers) + expect(response.code).to eql('200') + next_json = JSON.parse(response.body).with_indifferent_access + expect(next_json[:prev].present?).to be(true) + expect(next_json[:items].first).not_to eql(original) + # Move back to previous page + get(next_json[:prev], headers: headers) + expect(response.code).to eql('200') + prev_json = JSON.parse(response.body).with_indifferent_access + expect(prev_json[:items].first).to eql(original) + elsif json[:prev].present? + get(json[:prev], headers: headers) + expect(response.code).to eql('200') + prev_json = JSON.parse(response.body).with_indifferent_access + expect(prev_json[:next].present?).to be(true) + expect(next_json[:items].first).not_to eql(original) + get(prev_json[:next], headers: headers) + expect(response.code).to eql('200') + next_json = JSON.parse(response.body).with_indifferent_access + expect(next_json[:items].first).to eql(original) + else + raise StandardError, 'Expected to test API pagination but there are not enough items!' + end + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end diff --git a/spec/support/mocks/api_v2_json_samples.rb b/spec/support/mocks/api_v2_json_samples.rb new file mode 100644 index 0000000000..aaec320811 --- /dev/null +++ b/spec/support/mocks/api_v2_json_samples.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +# Mock JSON submissions +module Mocks + # Disabling rubocop checks here since its basically just large hashes and + # would be difficult to read if broken up into multiple smaller functions. + # One option might be to store them as .json files and then just load them here + # but we would lose the use of Faker + + # rubocop:disable Metrics/ModuleLength, Metrics/MethodLength + module ApiV2JsonSamples + ROLES = %w[Investigation Project_administration Data_curation].freeze + + def mock_identifier_schemes + create(:identifier_scheme, name: 'ror') + create(:identifier_scheme, name: 'fundref') + create(:identifier_scheme, name: 'orcid') + create(:identifier_scheme, name: 'grant') + end + + def minimal_update_json + { + total_items: 1, + items: [ + { + dmp: { + title: Faker::Lorem.sentence, + contact: { + mbox: Faker::Internet.email, + affiliation: { name: Faker::Movies::StarWars.planet } + }, + dataset: [{ + title: Faker::Lorem.sentence + }], + dmp_id: { + type: 'doi', + identifier: SecureRandom.uuid + } + } + } + ] + }.to_json + end + + def minimal_create_json + { + dmp: { + title: Faker::Lorem.sentence, + contact: { + mbox: Faker::Internet.email, + affiliation: { name: Faker::Movies::StarWars.planet } + }, + dataset: [{ + title: Faker::Lorem.sentence + }], + extension: [ + "#{ApplicationService.application_name.split('-').first}": { + template: { + id: Template.last.id, + title: Faker::Lorem.sentence + } + } + ] + } + }.to_json + end + + # rubocop:disable Metrics/AbcSize + def complete_create_json(client: nil) + template = create(:template, :published, :publicly_visible) + lang = Language.all.pluck(:abbreviation).sample || 'en-UK' + ror_scheme = IdentifierScheme.find_or_create_by(name: 'ror') + fundref_scheme = IdentifierScheme.find_or_create_by(name: 'fundref') + ror = create(:identifier, identifiable: create(:org), identifier_scheme: ror_scheme) + fundref = create(:identifier, identifiable: create(:org), identifier_scheme: fundref_scheme) + + contact = { + name: [ + Faker::TvShows::Simpsons.character.split.first, + Faker::TvShows::Simpsons.character.split.last + ].join(' '), + email: Faker::Internet.email, + id: SecureRandom.uuid + } + + { + dmp: { + created: 3.months.ago.to_formatted_s(:iso8601), + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + language: Api::V1::LanguagePresenter.three_char_code(lang: lang), + ethical_issues_exist: %w[yes no unknown].sample, + ethical_issues_description: Faker::Lorem.paragraph, + ethical_issues_report: Faker::Internet.url, + dmp_id: { + type: client.present? ? client.name.downcase : 'other', + identifier: SecureRandom.uuid + }, + contact: { + name: contact[:name], + mbox: contact[:email], + affiliation: { + name: ror.identifiable.name, + abbreviation: ror.identifiable.abbreviation, + region: Faker::Space.planet, + affiliation_id: { + type: 'ror', + identifier: ror.value + } + }, + contact_id: { + type: 'orcid', + identifier: contact[:id] + } + }, + contributor: [{ + role: [ + 'http://credit.niso.org/contributor-roles/project-administration', + 'http://credit.niso.org/contributor-roles/investigation', + 'other' + ], + name: Faker::Movies::StarWars.character, + mbox: Faker::Internet.email, + affiliation: { + name: Faker::Movies::StarWars.planet, + abbreviation: Faker::Lorem.word.upcase, + affiliation_id: { + type: 'ror', + identifier: SecureRandom.uuid + } + }, + contributor_id: { + type: 'orcid', + identifier: SecureRandom.uuid + } + }, { + role: [ + 'http://credit.niso.org/contributor-roles/investigation' + ], + name: contact[:name], + mbox: contact[:email], + affiliation: { + name: ror.identifiable.name, + abbreviation: ror.identifiable.abbreviation, + affiliation_id: { + type: 'ror', + identifier: ror.value + } + }, + contributor_id: { + type: 'orcid', + identifier: contact[:id] + } + }], + project: [{ + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + start: 3.months.from_now.to_formatted_s(:iso8601), + end: 2.years.from_now.to_formatted_s(:iso8601), + funding: [{ + name: fundref.identifiable.name, + funder_id: { + type: 'fundref', + identifier: fundref.value + }, + grant_id: { + type: 'other', + identifier: SecureRandom.uuid + }, + dmproadmap_funding_opportunity_id: { + type: 'other', + identifier: SecureRandom.uuid + }, + funding_status: %w[planned rejected granted].sample + }] + }], + dataset: [{ + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + personal_data: %w[yes no unknown].sample, + sensitive_data: %w[yes no unknown].sample, + issued: 6.months.from_now.to_formatted_s(:iso8601), + dataset_id: { + type: 'url', + identifier: Faker::Internet.url + }, + distribution: [{ + title: Faker::Lorem.sentence, + byte_size: Faker::Number.number(digits: 6), + data_access: %w[open embargoed restricted closed].sample, + host: { + title: Faker::Company.name, + description: Faker::Lorem.paragraph, + url: Faker::Internet.url, + dmproadmap_host_id: { + type: 'url', + identifier: Faker::Internet.url + } + }, + license: [ + { + license_ref: 'http://spdx.org/licenses/CC0-1.0.json', + start_date: 6.months.from_now.to_formatted_s(:iso8601) + } + ] + }] + }], + dmproadmap_template: { id: template.family_id, title: template.title } + } + }.to_json + end + # rubocop:enable Metrics/AbcSize + end + # rubocop:enable Metrics/ModuleLength, Metrics/MethodLength +end diff --git a/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb b/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb new file mode 100644 index 0000000000..2758d51d22 --- /dev/null +++ b/spec/views/api/v2/_standard_response.json_jbuilder_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/_standard_response.json.jbuilder' do + before do + @application = Faker::Lorem.word + # @caller = Faker::Lorem.word + @url = Faker::Internet.url + @code = 200 + + assign :application, @application + # assign :caller, @caller + + @response = OpenStruct.new(status: @code) + @request = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + end + + describe 'standard response items - Also the same as: GET /heartbeat' do + before do + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :code' do + expect(@json[:code]).to eql(@code) + end + + it 'includes the :message' do + expect(@json[:message]).to eql(Rack::Utils::HTTP_STATUS_CODES[@code]) + end + + it 'includes the :time' do + expect(@json[:time].present?).to be(true) + end + + it ':time is in UTC format' do + expect(Date.parse(@json[:time]).is_a?(Date)).to be(true) + end + + # it 'includes the :caller' do + # expect(@json[:caller]).to eql(@caller) + # end + + it 'includes the :source' do + expect(@json[:source].include?(@url)).to be(true) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(0) + end + end + + context 'responses with pagination' do + describe 'On the 1st page and there is only one page' do + before do + assign :page, 1 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 3 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(1) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(3) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(false) + end + + it "does not show a 'next' page link" do + expect(@json[:prev].present?).to be(false) + end + end + + describe 'On the 1st page and there multiple pages' do + before do + assign :page, 1 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 4 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(1) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(4) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(false) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(true) + end + end + + describe 'On the 2nd page and there more than 2 pages' do + before do + assign :page, 2 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 7 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(2) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(7) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(true) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(true) + end + end + + describe 'On the last page' do + before do + assign :page, 2 + assign :per_page, 3 + + render partial: 'api/v2/standard_response', + locals: { response: @response, request: @request, + total_items: 5 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'shows the correct page number' do + expect(@json[:page]).to be(2) + end + + it 'includes the per_page number' do + expect(@json[:per_page]).to be(3) + end + + it 'includes the :total_items' do + expect(@json[:total_items]).to be(5) + end + + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to be(true) + end + + it "does not show a 'next' page link" do + expect(@json[:next].present?).to be(false) + end + end + end +end diff --git a/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb b/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c2652decaf --- /dev/null +++ b/spec/views/api/v2/contributors/_show.json.jbuilder_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/contributors/_show.json.jbuilder' do + before do + @plan = create(:plan) + scheme = create(:identifier_scheme, name: 'orcid') + @contact = create(:contributor, org: create(:org), plan: @plan, roles_count: 0, + data_curation: true) + @ident = create(:identifier, identifiable: @contact, value: Faker::Lorem.word, + identifier_scheme: scheme) + @contact.reload + end + + describe 'includes all of the Contributor attributes' do + before do + render partial: 'api/v2/contributors/show', locals: { contributor: @contact } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :name' do + expect(@json[:name]).to eql(@contact.name) + end + + it 'includes the :mbox' do + expect(@json[:mbox]).to eql(@contact.email) + end + + it 'includes the :role' do + expect(@json[:role].first.ends_with?('data-curation')).to be(true) + end + + it 'includes :affiliation' do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it 'includes :contributor_id' do + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + + it 'ignores non-orcid identifiers :contributor_id' do + scheme = create(:identifier_scheme, name: 'shibboleth') + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + end + + describe 'includes all of the Contact attributes' do + before do + render partial: 'api/v2/contributors/show', locals: { contributor: @contact, + is_contact: true } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :name' do + expect(@json[:name]).to eql(@contact.name) + end + + it 'includes the :mbox' do + expect(@json[:mbox]).to eql(@contact.email) + end + + it 'does NOT include the :role' do + expect(@json[:role]).to be_nil + end + + it 'includes :affiliation' do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it 'includes :contact_id' do + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + + it 'ignores non-orcid identifiers :contact_id' do + scheme = create(:identifier_scheme, name: 'shibboleth') + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + end +end diff --git a/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb b/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..fc1b7d4e7b --- /dev/null +++ b/spec/views/api/v2/datasets/_show.json.jbuilder_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/datasets/_show.json.jbuilder' do + context ':output is a ResearchOutput' do + describe 'includes all of the dataset attributes' do + before do + @research_output = create(:research_output, plan: create(:plan)) + @presenter = Api::V2::ResearchOutputPresenter.new(output: @research_output) + end + + describe 'base :dataset attributes' do + before do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :type' do + expect(@json[:type]).to eql(@research_output.output_type) + end + + it 'includes :title' do + expect(@json[:title]).to eql(@research_output.title) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@research_output.description) + end + + it 'includes :personal_data' do + val = Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: @research_output.personal_data) + expect(@json[:personal_data]).to eql(val) + end + + it 'includes :sensitive_data' do + val = Api::V2::ApiPresenter.boolean_to_yes_no_unknown(value: @research_output.sensitive_data) + expect(@json[:sensitive_data]).to eql(val) + end + + it 'includes :issued' do + expect(@json[:issued]).to eql(@research_output.release_date.to_formatted_s(:iso8601)) + end + + it 'includes :preservation_statement' do + expect(@json[:preservation_statement]).to eql(@presenter.preservation_statement) + end + + it 'includes :security_and_privacy' do + expect(@json[:security_and_privacy]).to eql(@presenter.security_and_privacy) + end + + it 'includes :data_quality_assurance' do + expect(@json[:data_quality_assurance]).to eql(@presenter.data_quality_assurance) + end + end + + describe ':distribution' do + before do + @repo = @research_output.repositories.first + @license = create(:license) + @research_output.license_id = @license.id + + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :distributions' do + expect(@json[:distribution].any?).to be(true) + end + + it 'includes :title' do + expected = "Anticipated distribution for #{@research_output.title}" + expect(@json[:distribution].first[:title]).to eql(expected) + end + + it 'includes :byte_size' do + expect(@json[:distribution].first[:byte_size]).to eql(@research_output.byte_size) + end + + it 'includes :data_access' do + expect(@json[:distribution].first[:data_access]).to eql(@research_output.access) + end + + it 'includes host[:title]' do + expect(@json[:distribution].first[:host][:title]).to eql(@repo.name) + end + + it 'includes host[:description]' do + expect(@json[:distribution].first[:host][:description]).to eql(@repo.description) + end + + it 'includes host[:url]' do + expect(@json[:distribution].first[:host][:url]).to eql(@repo.homepage) + end + + it 'includes host[:dmproadmap_host_id]' do + result = @json[:distribution].first[:host][:dmproadmap_host_id][:identifier] + expect(result).to eql(@repo.uri) + end + + it 'includes license[:license_ref]' do + expect(@json[:distribution].first[:license].first[:license_ref]).to eql(@license.uri) + end + + it 'includes license[:start_date]' do + expected = @research_output.release_date.to_formatted_s(:iso8601) + expect(@json[:distribution].first[:license].first[:start_date]).to eql(expected) + end + end + + describe ':metadata' do + before do + @standard = create(:metadata_standard) + @research_output.metadata_standards << @standard + + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :metadata' do + expect(@json[:metadata].any?).to be(true) + end + + it 'includes :description' do + uri = @standard.uri + metadata = @json[:metadata].select { |ms| ms[:metadata_standard_id][:identifier] == uri } + expected = "#{@standard.title} - #{@standard.description}" + expect(metadata.first[:description].start_with?(expected)).to be(true) + expect(metadata.first[:metadata_standard_id].present?).to be(true) + expect(metadata.first[:metadata_standard_id][:type]).to eql('url') + expect(metadata.first[:metadata_standard_id][:identifier]).to eql(uri) + end + end + + describe ':technical_resources' do + it 'is always an empty array because this has not been implemented' do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:technical_resource].any?).to be(false) + end + end + + describe ':keyword' do + it 'includes the ResearchDomain' do + research_domain = create(:research_domain) + @research_output.plan.research_domain_id = research_domain.id + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:keyword].any?).to be(true) + expect(@json[:keyword].include?(research_domain.label)) + expect(@json[:keyword].include?("#{research_domain.identifier} - #{research_domain.label}")) + end + + it 'is not included if no ResearchDomain is defined' do + render partial: 'api/v2/datasets/show', locals: { output: @research_output } + @json = JSON.parse(rendered).with_indifferent_access + expect(@json[:keyword].present?).to be(false) + end + end + end + end + + context ':output is a Plan' do + describe 'includes all of the dataset attributes' do + before do + @plan = create(:plan) + @research_domain = create(:research_domain) + @plan.research_domain_id = @research_domain.id + + render partial: 'api/v2/datasets/show', locals: { output: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes :type' do + expect(@json[:type]).to eql('dataset') + end + + it 'includes :title' do + expect(@json[:title]).to eql('Generic dataset') + end + + it 'includes :description' do + expect(@json[:description]).to eql('No individual datasets have been defined for this DMP.') + end + + describe ':keyword' do + it 'includes the ResearchDomain' do + expect(@json[:keyword].any?).to be(true) + expect(@json[:keyword].include?(@research_domain.label)) + expect(@json[:keyword].include?("#{@research_domain.identifier} - #{@research_domain.label}")) + end + end + end + end +end diff --git a/spec/views/api/v2/error.json.jbuilder_spec.rb b/spec/views/api/v2/error.json.jbuilder_spec.rb new file mode 100644 index 0000000000..ca2337ff6d --- /dev/null +++ b/spec/views/api/v2/error.json.jbuilder_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/error.json.jbuilder' do + before do + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + @errors = [Faker::Lorem.sentence, Faker::Lorem.sentence] + + assign :payload, { message: @errors } + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + + render template: 'api/v2/error', locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'error responses from controllers' do + it 'renders the standard_response partial' do + expect(response).to render_template(partial: 'api/v2/_standard_response') + end + + it ':errors contains an array of error messages' do + expect(@json[:message]).to eql(@errors) + end + end +end diff --git a/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb b/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb new file mode 100644 index 0000000000..1808117b19 --- /dev/null +++ b/spec/views/api/v2/heartbeat.json.jbuilder_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/heartbeat.json.jbuilder' do + before do + render template: 'api/v2/heartbeat', locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'renders the _standard_response template' do + expect(response).to render_template('api/v2/_standard_response') + end + + it ':items array to be empty' do + expect(@json[:items]).to eql([]) + end +end diff --git a/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb b/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..602169fd75 --- /dev/null +++ b/spec/views/api/v2/identifiers/_show.json.jbuilder_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/identifiers/_show.json.jbuilder' do + before do + @scheme = create(:identifier_scheme) + @identifier = create(:identifier, value: Faker::Lorem.word, + identifier_scheme: @scheme) + render partial: 'api/v2/identifiers/show', locals: { identifier: @identifier } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the identifier attributes' do + it 'includes :type' do + expect(@json[:type]).to eql(@identifier.identifier_format) + end + + it 'includes :identifier' do + expect(@json[:identifier]).to eql(@identifier.value) + end + end +end diff --git a/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb b/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5480ae0ee1 --- /dev/null +++ b/spec/views/api/v2/orgs/_show.json.jbuilder_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/orgs/_show.json.jbuilder' do + before do + scheme = create(:identifier_scheme, name: 'ror') + @org = create(:org) + @ident = create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + render partial: 'api/v2/orgs/show', locals: { org: @org } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the org attributes' do + it 'includes :name' do + expect(@json[:name]).to eql(@org.name) + end + + it 'includes :abbreviation' do + expect(@json[:abbreviation]).to eql(@org.abbreviation) + end + + it 'includes :region' do + expect(@json[:region]).to eql(@org.region.abbreviation) + end + + it 'includes :affiliation_id' do + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + + it 'uses the ROR over the FundRef :affiliation_id' do + scheme = create(:identifier_scheme, name: 'fundref') + create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + end +end diff --git a/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb new file mode 100644 index 0000000000..712e9300e9 --- /dev/null +++ b/spec/views/api/v2/plans/_cost.json.jbuilder_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_cost.json.jbuilder' do + before do + # TODO: Implement this once the Currency question and Cost theme are in place + # and the PlanPresenter is extracting the info + @cost = { + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + currency_code: Faker::Currency.code, + value: Faker::Number.decimal(l_digits: 2) + }.with_indifferent_access + + render partial: 'api/v2/plans/cost', locals: { cost: @cost } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the cost attributes' do + it 'includes :title' do + expect(@json[:title]).to eql(@cost[:title]) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@cost[:description]) + end + + it 'includes :currency_code' do + expect(@json[:currency_code]).to eql(@cost[:currency_code]) + end + + it 'includes :value' do + expect(@json[:value]).to eql(@cost[:value]) + end + end +end diff --git a/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c9fe9ea015 --- /dev/null +++ b/spec/views/api/v2/plans/_funding.json.jbuilder_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_funding.json.jbuilder' do + before do + @funder = create(:org, :funder) + create(:identifier, identifiable: @funder, + identifier_scheme: create(:identifier_scheme, name: 'fundref')) + @funder.reload + @plan = create(:plan, funder: @funder, org: create(:org), identifier: SecureRandom.uuid) + create(:identifier, identifiable: @plan.org, + identifier_scheme: create(:identifier_scheme, name: 'ror')) + @grant = create(:identifier, identifiable: @plan) + @plan.update(grant_id: @grant.id, funding_status: 'funded') + @plan.reload + + render partial: 'api/v2/plans/funding', locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the funding attributes' do + it 'includes :name' do + expect(@json[:name]).to eql(@funder.name) + end + + it 'includes :funding_status' do + expected = Api::V2::FundingPresenter.status(plan: @plan) + expect(@json[:funding_status]).to eql(expected) + end + + it 'includes :funder_ids' do + id = @funder.identifiers.first + expect(@json[:funder_id][:type]).to eql(id.identifier_format) + expect(@json[:funder_id][:identifier]).to eql(id.value) + end + + it 'includes :dmproadmap_funding_opportunity_identifier' do + identifier = @plan.identifier + expect(@json[:dmproadmap_funding_opportunity_id][:type]).to eql('other') + expect(@json[:dmproadmap_funding_opportunity_id][:identifier]).to eql(identifier) + end + + it 'includes :grant_ids' do + expect(@json[:grant_id][:type]).to eql(@grant.identifier_format) + expect(@json[:grant_id][:identifier]).to eql(@grant.value) + end + + it 'includes :dmproadmap_funded_affiliations' do + org = @plan.org + expect(@json[:dmproadmap_funded_affiliations].any?).to be(true) + affil = @json[:dmproadmap_funded_affiliations].last + expect(affil[:name]).to eql(org.name) + expect(affil[:affiliation_id][:type]).to eql(org.identifiers.last.identifier_format) + expect(affil[:affiliation_id][:identifier]).to eql(org.identifiers.last.value) + end + end +end diff --git a/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb new file mode 100644 index 0000000000..53af088379 --- /dev/null +++ b/spec/views/api/v2/plans/_project.json.jbuilder_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_project.json.jbuilder' do + before do + @plan = build(:plan, funder: build(:org, :funder)) + render partial: 'api/v2/plans/project', locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe 'includes all of the project attributes' do + it 'includes :title' do + expect(@json[:title]).to eql(@plan.title) + end + + it 'includes :description' do + expect(@json[:description]).to eql(@plan.description) + end + + it 'includes :start' do + expect(@json[:start]).to eql(@plan.start_date.to_formatted_s(:iso8601)) + end + + it 'includes :end' do + expect(@json[:end]).to eql(@plan.end_date.to_formatted_s(:iso8601)) + end + + it 'includes the :funder' do + expect(@json[:funding].length).to be(1) + end + end +end diff --git a/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb b/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..c706ca3bb6 --- /dev/null +++ b/spec/views/api/v2/plans/_show.json.jbuilder_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/_show.json.jbuilder' do + before do + Rails.configuration.x.madmp.enable_dmp_id_registration = true + + @plan = create(:plan) + @data_contact = create(:contributor, data_curation: true, plan: @plan) + @pi = create(:contributor, investigation: true, plan: @plan) + @plan.contributors = [@data_contact, @pi] + create(:identifier, identifiable: @plan) + + # Create an Api Client and connect it to the Plan + @client = create(:api_client) + scheme = create(:identifier_scheme, :for_plans, name: @client.name.downcase) + @client_identifier = create(:identifier, identifier_scheme: scheme, identifiable: @plan) + + @plan.save + @plan.reload + @presenter = Api::V2::PlanPresenter.new(plan: @plan) + end + + describe 'includes all of the DMP attributes' do + before do + render partial: 'api/v2/plans/show', locals: { client: @client, plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'includes the :title' do + expect(@json[:title]).to eql(@plan.title) + end + + it 'includes the :description' do + expect(@json[:description]).to eql(@plan.description) + end + + it 'includes the :language' do + # Set @plan.owner + @plan.roles << create(:role, :creator, user: create(:user)) + expected = Api::V2::LanguagePresenter.three_char_code( + lang: @plan.owner&.language&.abbreviation + ) + expect(@json[:language]).to eql(expected) + end + + it 'includes the :created' do + expect(@json[:created]).to eql(@plan.created_at.to_formatted_s(:iso8601)) + end + + it 'includes the :modified' do + expect(@json[:modified]).to eql(@plan.updated_at.to_formatted_s(:iso8601)) + end + + it 'includes :ethical_issues' do + expected = Api::V2::ConversionService.boolean_to_yes_no_unknown(@plan.ethical_issues) + expect(@json[:ethical_issues_exist]).to eql(expected) + end + + it 'includes :ethical_issues_description' do + expect(@json[:ethical_issues_description]).to eql(@plan.ethical_issues_description) + end + + it 'includes :ethical_issues_report' do + expect(@json[:ethical_issues_report]).to eql(@plan.ethical_issues_report) + end + + it 'returns the URL of the plan as the :dmp_id if no DMP ID is defined' do + expected = Rails.application.routes.url_helpers.api_v2_plan_url(@plan) + expect(@json[:dmp_id][:type]).to eql('url') + expect(@json[:dmp_id][:identifier]).to eql(expected) + end + + it 'includes the :contact' do + expect(@json[:contact][:mbox]).to eql(@data_contact.email) + end + + it 'includes the :contributors' do + emails = @json[:contributor].pluck(:mbox) + expect(emails.include?(@pi.email)).to be(true) + end + + # TODO: make sure this is working once the new Cost theme and Currency + # question type have been implemented + it 'includes the :cost' do + expect(@json[:cost]).to be_nil + end + + it 'includes the :project' do + expect(@json[:project].length).to be(1) + end + + it 'includes the :dataset' do + expect(@json[:dataset].length).to be(1) + end + end + + describe 'when the system mints DMP IDs', skip: 'DmpIdService not implemented' do + before do + scheme = create(:identifier_scheme) + DmpIdService.expects(:identifier_scheme).at_least(1).returns(scheme) + @doi = create(:identifier, value: '10.9999/123abc.zy/x23', identifiable: @plan, + identifier_scheme: scheme) + @plan.reload + render partial: 'api/v2/plans/show', locals: { client: @client, plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'returns the DMP ID for the :dmp_id if one is present' do + expect(@json[:dmp_id][:type]).to eql('doi') + expect(@json[:dmp_id][:identifier]).to eql(@doi.value) + end + end +end diff --git a/spec/views/api/v2/plans/index.json.jbuilder_spec.rb b/spec/views/api/v2/plans/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5d47770c52 --- /dev/null +++ b/spec/views/api/v2/plans/index.json.jbuilder_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/plans/index.json.jbuilder' do + before do + @plan = create(:plan) + + @client = create(:api_client) + @items = [@plan] + @total_items = 1 + + render template: 'api/v2/plans/index' + @json = JSON.parse(rendered).with_indifferent_access + end + + it 'renders the _standard_response template' do + expect(response).to render_template('api/v2/_standard_response') + end + + it ':items array to be empty' do + expect(@json[:items].length).to be(1) + expect(@json[:items].first[:dmp][:title]).to eql(@plan.title) + end +end diff --git a/spec/views/api/v2/templates/index.json.jbuilder_spec.rb b/spec/views/api/v2/templates/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..99b35375ec --- /dev/null +++ b/spec/views/api/v2/templates/index.json.jbuilder_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'api/v2/templates/index.json.jbuilder' do + before do + @application = Faker::Lorem.word + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + + @template1 = create(:template, :published, org: create(:org), phases: 1) + @template2 = create(:template, :published) + + assign :server, @application + assign :items, [@template1, @template2] + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new('GET', nil, nil, @url) + end + + describe 'includes all of the Template attributes' do + before do + render template: 'api/v2/templates/index', + locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + + @template = @json[:items].first[:dmp_template] + end + + it 'includes both templates' do + expect(@json[:items].length).to be(2) + end + + it 'includes the :title' do + expect(@template[:title]).to eql(@template1.title) + end + + it 'includes the :description' do + expect(@template[:description]).to eql(@template1.description) + end + + it 'includes the :version' do + expect(@template[:version]).to eql(@template1.version) + end + + it 'includes the :created' do + expect(@template[:created]).to eql(@template1.created_at.to_formatted_s(:iso8601)) + end + + it 'includes the :modified' do + expect(@template[:modified]).to eql(@template1.updated_at.to_formatted_s(:iso8601)) + end + + it 'includes the :affiliation' do + expect(@template[:affiliation][:name]).to eql(@template1.org.name) + end + + it 'includes the :template_ids' do + expect(@template[:template_id][:identifier]).to eql(@template1.id.to_s) + expect(@template[:template_id][:type]).to eql('other') + end + + # The show_url parameter is either false or not included + it 'includes the :phases' do + expect(@json[:items].first[:dmp_template][:phases]).to be_nil + end + end + + # The show_url parameter is true + describe 'when the show_phases url parameter is true' do + before do + @show_phases = true + render template: 'api/v2/templates/index', + locals: { response: @resp, request: @req, show_phases: @show_phases } + @json = JSON.parse(rendered).with_indifferent_access + end + end +end diff --git a/spec/views/devise/registrations/_api_token.html.erb_spec.rb b/spec/views/devise/registrations/_api_token.html.erb_spec.rb new file mode 100644 index 0000000000..e543ba83f7 --- /dev/null +++ b/spec/views/devise/registrations/_api_token.html.erb_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'devise/registrations/_api_token.html.erb' do + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + context 'When a user has the `use_api` permission' do + it 'renders both the v2 and legacy API token sections' do + user = create(:user, :org_admin) + + render partial: 'devise/registrations/api_token', locals: { user: user } + + expect(rendered).to have_selector('#v2-api-token') + expect(rendered).to have_selector('#legacy-api-token') + end + end + + context 'When a user does not have the `use_api` permission' do + it 'renders only the v2 API token section' do + user = create(:user) + + render partial: 'devise/registrations/api_token', locals: { user: user } + + expect(rendered).to have_selector('#v2-api-token') + expect(rendered).not_to have_selector('#legacy-api-token') + end + end +end diff --git a/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb new file mode 100644 index 0000000000..712431bd60 --- /dev/null +++ b/spec/views/devise/registrations/_v2_api_token.html.erb_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'devise/registrations/_v2_api_token.html.erb' do + let(:user) { create(:user) } + let(:app_name) { Rails.application.config.x.application.internal_oauth_app_name } + + def render_token_partial(token: nil) + render partial: 'devise/registrations/v2_api_token', locals: { user: user, token: token } + end + + context 'when the OAuth application exists' do + let!(:oauth_app) { create(:oauth_application, name: app_name) } + + it 'displays the regenerate button when no token is present' do + render_token_partial(token: nil) + expect(rendered).to have_selector('button', text: 'Regenerate token') + end + + context 'when user has a token' do + let(:plaintext_token) { 'plaintext-token-value' } + + it 'displays the token and disables the regenerate button' do + render_token_partial(token: plaintext_token) + expect(rendered).to have_selector('#api-token-val') + expect(rendered).not_to have_content('Click the button below to generate an API token') + expect(rendered).to have_selector('button[disabled]', text: 'Regenerate token') + end + end + + context 'when user does not have a token' do + it 'displays the generate message' do + render_token_partial(token: nil) + expect(rendered).to have_content('Click the button below to generate an API token') + expect(rendered).not_to have_selector('#api-token-val') + end + end + end + + context 'when the OAuth application does not exist' do + it 'displays the warning message and helpdesk email link' do + render_token_partial(token: nil) + expect(rendered).to have_selector('.alert-warning') + expect(rendered).to have_content('V2 API token service is currently unavailable') + expect(rendered).to have_link(href: "mailto:#{Rails.application.config.x.organisation.helpdesk_email}") + end + + it 'does not display the token or regenerate button' do + render_token_partial(token: nil) + expect(rendered).not_to have_selector('button', text: 'Regenerate token') + expect(rendered).not_to have_selector('code') + end + end +end