diff --git a/Gemfile b/Gemfile index fd18f501..4235abf9 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,7 @@ gem "bootstrap-sass", "3.4.1" # Use Puma as the app server gem "puma", "~> 7.2" # Use SCSS for stylesheets -gem "sass-rails", "~> 5.0" +gem "sass-rails", "~> 6.0" gem "bourbon" gem "matrix" @@ -108,4 +108,4 @@ gem "coffee-script" gem "pundit", "~> 2.2" -gem "madmin", "~> 1.2" +gem "madmin", "~> 2.0" diff --git a/Gemfile.lock b/Gemfile.lock index afb692a8..2634140d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,6 +166,10 @@ GEM logger i18n (1.15.2) concurrent-ruby (~> 1.0) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) io-console (0.8.2) irb (1.18.0) pp (>= 0.6.0) @@ -195,9 +199,13 @@ GEM loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) - madmin (1.2.8) + madmin (2.3.3) + importmap-rails pagy (>= 3.5) - rails (>= 6.0.3) + propshaft + rails (>= 7.0.0) + stimulus-rails + turbo-rails mail (2.9.1) logger mini_mime (>= 0.1.1) @@ -273,7 +281,10 @@ GEM actionpack (>= 4.2) omniauth (~> 2.0) orm_adapter (0.5.0) - pagy (6.1.0) + pagy (43.6.0) + json + uri + yaml parallel (2.1.0) parser (3.3.12.0) ast (~> 2.4.1) @@ -283,6 +294,10 @@ GEM prettyprint prettyprint (0.2.0) prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack public_suffix (7.0.5) puma (7.2.1) nio4r (~> 2.0) @@ -398,19 +413,16 @@ GEM rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) rubyzip (2.3.2) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - sass-rails (5.1.0) - railties (>= 5.2.0) - sass (~> 3.1) - sprockets (>= 2.8, < 4.0) - sprockets-rails (>= 2.0, < 4.0) - tilt (>= 1.1, < 3) + sass-rails (6.0.0) + sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) ffi (~> 1.9) + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt securerandom (0.4.1) selenium-webdriver (4.10.0) rexml (~> 3.2, >= 3.2.5) @@ -434,12 +446,13 @@ GEM hashie (>= 0.1.0, < 6) version_gem (~> 1.1, >= 1.1.14) spring (3.0.0) - sprockets (3.7.2) + sprockets (4.2.2) concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) stackprof (0.2.26) standard (1.56.0) @@ -456,12 +469,17 @@ GEM rubocop-performance (~> 1.26.0) standardrb (1.0.1) standard + stimulus-rails (1.3.4) + railties (>= 6.0.0) terser (1.1.18) execjs (>= 0.3.0, < 3) thor (1.5.0) - tilt (2.3.0) + tilt (2.8.0) timeout (0.6.1) tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -492,6 +510,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) + yaml (0.4.0) zeitwerk (2.8.2) PLATFORMS @@ -520,7 +539,7 @@ DEPENDENCIES jquery-rails jquery-ui-rails (~> 8.0) listen (~> 3.7) - madmin (~> 1.2) + madmin (~> 2.0) matrix mimemagic (~> 0.3.8) newrelic_rpm @@ -536,7 +555,7 @@ DEPENDENCIES recursive-open-struct redcarpet (~> 3.5.1) rspec-rails (~> 6.1) - sass-rails (~> 5.0) + sass-rails (~> 6.0) sentry-rails sentry-ruby shoulda-matchers (~> 3.1) diff --git a/app/assets/stylesheets/1-utilities/_basic.scss b/app/assets/stylesheets/1-utilities/_basic.scss index ddedbd03..6dea1aae 100644 --- a/app/assets/stylesheets/1-utilities/_basic.scss +++ b/app/assets/stylesheets/1-utilities/_basic.scss @@ -7,7 +7,7 @@ box-sizing: border-box; } main { - min-height: calc(100vh - 160px); + min-height: unquote("calc(100vh - 160px)"); padding-bottom: 60px; } ::selection { diff --git a/app/assets/stylesheets/4-molecules/_floors.scss b/app/assets/stylesheets/4-molecules/_floors.scss index 7963bd85..b00ddbdf 100644 --- a/app/assets/stylesheets/4-molecules/_floors.scss +++ b/app/assets/stylesheets/4-molecules/_floors.scss @@ -13,7 +13,7 @@ justify-content: center; width: 100%; overflow: hidden; - height: calc(100vh - 160px); + height: unquote("calc(100vh - 160px)"); margin: -100px 0; } } diff --git a/app/assets/stylesheets/project.scss b/app/assets/stylesheets/project.scss index 1a128dd9..a99ec534 100644 --- a/app/assets/stylesheets/project.scss +++ b/app/assets/stylesheets/project.scss @@ -2,6 +2,8 @@ // They will automatically be included in application.css. // You can use Sass (SCSS) here: http://sass-lang.com/ +@import "1-utilities/variables"; + .projects-controller { &.index-action { .btn-group { diff --git a/app/assets/stylesheets/stories.scss b/app/assets/stylesheets/stories.scss index 3ace7f06..ce3bed27 100644 --- a/app/assets/stylesheets/stories.scss +++ b/app/assets/stylesheets/stories.scss @@ -113,7 +113,7 @@ .extra_info_preview .content, .story_preview .content { overflow: auto; - max-height: min(50vh, 700px); + max-height: unquote("min(50vh, 700px)"); // prevent long links from overflowing a { word-break: break-all; diff --git a/app/views/madmin/application/_javascript.html.erb b/app/views/madmin/application/_javascript.html.erb index fb8d4fd5..127d1e37 100644 --- a/app/views/madmin/application/_javascript.html.erb +++ b/app/views/madmin/application/_javascript.html.erb @@ -1,3 +1,6 @@ +<%# madmin's npm_rails_version helper was removed in 2.x; reproduce it here. + Pins @rails/* unpkg packages to the matching Rails version (or "main" for prereleases). %> +<% npm_rails_version = Rails.version.match?(/\A\d+\.\d+\.\d+\z/) ? Rails.version : "main" %> <%= stylesheet_link_tag "https://unpkg.com/flatpickr/dist/flatpickr.min.css", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "https://unpkg.com/trix/dist/trix.css", "data-turbo-track": "reload" %> diff --git a/app/views/madmin/application/index.html.erb b/app/views/madmin/application/index.html.erb index ea987e0d..8fbce9c0 100644 --- a/app/views/madmin/application/index.html.erb +++ b/app/views/madmin/application/index.html.erb @@ -63,4 +63,15 @@ -<%== render(partial: 'madmin/pagy/nav', locals: { pagy: @pagy }) if @pagy.pages > 1 %> +<%# madmin 2.x dropped the madmin/pagy/_nav partial in favor of pagy's own + helpers. series_nav/info_tag are the pagy 43+ API; pagy_nav/pagy_info the + older one. %> + diff --git a/config/environments/production.rb b/config/environments/production.rb index 49f47c6c..ce3ffeb6 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -18,7 +18,11 @@ # Apache or NGINX already handles this. config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? - config.assets.css_compressor = :sass + # Do not use the SassC/libsass CSS compressor: it re-parses already-compiled + # CSS and evaluates arithmetic inside calc()/min() (e.g. `100vh - 160px`), + # raising "Incompatible units" on valid CSS. Set to nil explicitly since + # sprockets-rails otherwise defaults to :sass when sassc is available. + config.assets.css_compressor = nil config.assets.js_compressor = :terser # Do not fallback to assets pipeline if a precompiled asset is missed. diff --git a/spec/requests/madmin/authorization_spec.rb b/spec/requests/madmin/authorization_spec.rb new file mode 100644 index 00000000..bd5bc677 --- /dev/null +++ b/spec/requests/madmin/authorization_spec.rb @@ -0,0 +1,56 @@ +require "rails_helper" + +# The madmin interface is gated by Madmin::ApplicationController#ensure_admin, +# which redirects anyone who is not a signed-in admin back to the root path. +# These specs lock that behavior down across a representative set of routes. +RSpec.describe "Madmin authorization", type: :request do + after { Warden.test_reset! } + + # One route per controller under /madmin, enough to prove the shared + # before_action guards every resource and the dashboard. + let(:guarded_paths) do + [ + "/madmin", + "/madmin/projects", + "/madmin/stories", + "/madmin/estimates", + "/madmin/users", + "/madmin/version_jumps" + ] + end + + context "when not signed in" do + it "redirects every madmin path to the root path" do + guarded_paths.each do |path| + get path + expect(response).to redirect_to("/"), "expected #{path} to redirect to /" + end + end + end + + context "when signed in as a non-admin user" do + let(:user) { FactoryBot.create(:user, admin: false) } + + before { login_as(user, scope: :user) } + + it "redirects every madmin path to the root path" do + guarded_paths.each do |path| + get path + expect(response).to redirect_to("/"), "expected #{path} to redirect to /" + end + end + end + + context "when signed in as an admin user" do + let(:admin) { FactoryBot.create(:user, :admin) } + + before { login_as(admin, scope: :user) } + + it "grants access to every madmin path" do + guarded_paths.each do |path| + get path + expect(response).to have_http_status(:ok), "expected #{path} to return 200" + end + end + end +end diff --git a/spec/requests/madmin/dashboard_spec.rb b/spec/requests/madmin/dashboard_spec.rb new file mode 100644 index 00000000..9cd29bd3 --- /dev/null +++ b/spec/requests/madmin/dashboard_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +# The dashboard renders the madmin layout (including _javascript.html.erb), +# so this request spec also guards against layout/partial regressions such as +# the removed `npm_rails_version` helper. +RSpec.describe "Madmin dashboard", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin" do + it "renders the dashboard successfully" do + get "/madmin" + + expect(response).to have_http_status(:ok) + expect(response.body).to include("madmin") + end + end +end diff --git a/spec/requests/madmin/estimates_spec.rb b/spec/requests/madmin/estimates_spec.rb new file mode 100644 index 00000000..d1bb3fc8 --- /dev/null +++ b/spec/requests/madmin/estimates_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +# Estimates are registered as: resources :estimates, except: [:update, :edit, :create] +RSpec.describe "Madmin estimates", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:estimate) { FactoryBot.create(:estimate) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/estimates" do + it "lists estimates" do + get "/madmin/estimates" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/estimates/new" do + it "renders the new form" do + get "/madmin/estimates/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/estimates/:id" do + it "shows the estimate" do + get "/madmin/estimates/#{estimate.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "DELETE /madmin/estimates/:id" do + it "destroys the estimate" do + expect { + delete "/madmin/estimates/#{estimate.id}" + }.to change(Estimate, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/pagination_spec.rb b/spec/requests/madmin/pagination_spec.rb new file mode 100644 index 00000000..1c171955 --- /dev/null +++ b/spec/requests/madmin/pagination_spec.rb @@ -0,0 +1,28 @@ +require "rails_helper" + +# Guards the shared madmin/application/index.html.erb pagination path. +# +# The index partial only renders the pagination nav when there is more than +# one page of results (pagy's default limit is 20). An older madmin override +# rendered a `madmin/pagy/_nav` partial that no longer ships with madmin 2.x, +# raising ActionView::MissingTemplate once a list grew past one page. The +# single-record index specs never hit this branch, so this spec creates enough +# records to paginate and exercise the nav rendering. +RSpec.describe "Madmin pagination", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/version_jumps with more than one page of records" do + before { FactoryBot.create_list(:version_jump, 21) } + + it "renders the index with pagination navigation" do + get "/madmin/version_jumps" + + expect(response).to have_http_status(:ok) + # pagy builds links to subsequent pages using the `page` query param + expect(response.body).to include("page=2") + end + end +end diff --git a/spec/requests/madmin/projects_spec.rb b/spec/requests/madmin/projects_spec.rb new file mode 100644 index 00000000..3b1b326d --- /dev/null +++ b/spec/requests/madmin/projects_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +# Projects are registered as: resources :projects, except: [:update, :edit, :create] +RSpec.describe "Madmin projects", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:project) { FactoryBot.create(:project) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/projects" do + it "lists projects" do + get "/madmin/projects" + + expect(response).to have_http_status(:ok) + expect(response.body).to include(project.title.truncate(20)) + end + end + + describe "GET /madmin/projects/new" do + it "renders the new form" do + get "/madmin/projects/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/projects/:id" do + it "shows the project" do + get "/madmin/projects/#{project.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "DELETE /madmin/projects/:id" do + it "destroys the project" do + expect { + delete "/madmin/projects/#{project.id}" + }.to change(Project, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/stories_spec.rb b/spec/requests/madmin/stories_spec.rb new file mode 100644 index 00000000..67a49875 --- /dev/null +++ b/spec/requests/madmin/stories_spec.rb @@ -0,0 +1,83 @@ +require "rails_helper" + +# Stories are registered with the full set of resource routes, so this exercises +# create/update in addition to the read-only actions. +RSpec.describe "Madmin stories", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let(:project) { FactoryBot.create(:project) } + let!(:story) { FactoryBot.create(:story, project: project) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/stories" do + it "lists stories" do + get "/madmin/stories" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/stories/new" do + it "renders the new form" do + get "/madmin/stories/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/stories/:id" do + it "shows the story" do + get "/madmin/stories/#{story.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/stories/:id/edit" do + it "renders the edit form" do + get "/madmin/stories/#{story.id}/edit" + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /madmin/stories" do + it "creates a story" do + expect { + post "/madmin/stories", params: { + story: { + title: "A brand new story", + description: "Some description", + real_score: 3, + project_id: project.id + } + } + }.to change(Story, :count).by(1) + + expect(response).to have_http_status(:redirect) + expect(Story.last.title).to eq("A brand new story") + end + end + + describe "PATCH /madmin/stories/:id" do + it "updates the story" do + patch "/madmin/stories/#{story.id}", params: { + story: {title: "Updated title"} + } + + expect(response).to have_http_status(:redirect) + expect(story.reload.title).to eq("Updated title") + end + end + + describe "DELETE /madmin/stories/:id" do + it "destroys the story" do + expect { + delete "/madmin/stories/#{story.id}" + }.to change(Story, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/users_spec.rb b/spec/requests/madmin/users_spec.rb new file mode 100644 index 00000000..a59d6ee1 --- /dev/null +++ b/spec/requests/madmin/users_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +# Users are registered as: resources :users, except: [:update, :edit, :create] +RSpec.describe "Madmin users", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:user) { FactoryBot.create(:user) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/users" do + it "lists users" do + get "/madmin/users" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/users/new" do + it "renders the new form" do + get "/madmin/users/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/users/:id" do + it "shows the user" do + get "/madmin/users/#{user.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "DELETE /madmin/users/:id" do + it "destroys the user" do + expect { + delete "/madmin/users/#{user.id}" + }.to change(User, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end diff --git a/spec/requests/madmin/version_jumps_spec.rb b/spec/requests/madmin/version_jumps_spec.rb new file mode 100644 index 00000000..b12c967d --- /dev/null +++ b/spec/requests/madmin/version_jumps_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +# Version jumps are registered with the full set of resource routes, so this +# exercises create/update in addition to the read-only actions. +RSpec.describe "Madmin version jumps", type: :request do + let(:admin) { FactoryBot.create(:user, :admin) } + let!(:version_jump) { FactoryBot.create(:version_jump) } + + before { login_as(admin, scope: :user) } + after { Warden.test_reset! } + + describe "GET /madmin/version_jumps" do + it "lists version jumps" do + get "/madmin/version_jumps" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/version_jumps/new" do + it "renders the new form" do + get "/madmin/version_jumps/new" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/version_jumps/:id" do + it "shows the version jump" do + get "/madmin/version_jumps/#{version_jump.id}" + + expect(response).to have_http_status(:ok) + end + end + + describe "GET /madmin/version_jumps/:id/edit" do + it "renders the edit form" do + get "/madmin/version_jumps/#{version_jump.id}/edit" + + expect(response).to have_http_status(:ok) + end + end + + describe "POST /madmin/version_jumps" do + it "creates a version jump" do + expect { + post "/madmin/version_jumps", params: { + version_jump: { + technology: "Rails-new", + initial_version: "6.1", + target_version: "7.0" + } + } + }.to change(VersionJump, :count).by(1) + + expect(response).to have_http_status(:redirect) + expect(VersionJump.last.technology).to eq("Rails-new") + end + end + + describe "PATCH /madmin/version_jumps/:id" do + it "updates the version jump" do + patch "/madmin/version_jumps/#{version_jump.id}", params: { + version_jump: {target_version: "8.0"} + } + + expect(response).to have_http_status(:redirect) + expect(version_jump.reload.target_version).to eq("8.0") + end + end + + describe "DELETE /madmin/version_jumps/:id" do + it "destroys the version jump" do + expect { + delete "/madmin/version_jumps/#{version_jump.id}" + }.to change(VersionJump, :count).by(-1) + + expect(response).to have_http_status(:redirect) + end + end +end