Skip to content

Controller/routing cleanup: trailing-slash middleware, admin_only! helper, test settings isolation#238

Open
alexskr wants to merge 8 commits into
developfrom
refactor/controller-routing-cleanup
Open

Controller/routing cleanup: trailing-slash middleware, admin_only! helper, test settings isolation#238
alexskr wants to merge 8 commits into
developfrom
refactor/controller-routing-cleanup

Conversation

@alexskr

@alexskr alexskr commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

Controller/routing cleanup plus test-isolation hardening. No client-visible behavior change beyond trailing-slash redirects now using correct per-verb status codes and Cache-Control: no-store.

1. Move trailing-slash redirect to Rack middleware

Trailing-slash canonicalization was done in init.rb by registering a companion "#{pattern}/" 301-redirect route for every route. That approach:

  • ran in the route-dispatch phase, so it sat behind every before filter and doubled the route table;
  • hardcoded 301 for all verbs, which can downgrade the method and drop POST/PUT/PATCH bodies.

Replaced with Rack::TrailingSlashRedirect (lib/rack/trailing_slash_redirect.rb), wired as the innermost middleware so it runs before Sinatra routing. It strips the trailing slash and redirects — 301 for GET/HEAD, 308 for other verbs (preserves method + body) — preserving forwarded scheme (X-Forwarded-Proto), query string, and percent-encoded path segments. HEAD redirects carry no body, and the redirect is marked Cache-Control: no-store (its scheme-dependent Location must not be stored by the outer Rack::Cache and replayed to a different-scheme client).

2. Extract admin_only! helper

The guard error 403, "Access denied" unless current_user && current_user.admin? was copy-pasted across slices (4x) and annotator (2x), with a nil-guard-less variant in users. Extracted a single admin_only! helper next to current_user, called from all seven sites.

3. Isolate process-wide settings in tests

Settings like enable_security live on global singletons, so a suite that flips one (or errors after flipping it) leaked the value into later tests. Added a with_settings block helper (restores prior values even on raise) plus per-test and per-suite snapshot/restore nets in the test base class (test/test_case.rb), and converted the suites. Drops the ad-hoc enable_security/disable_security/reset_security helpers.

Tests

  • Added test/middleware/test_trailing_slash_redirect.rb — backend-free Rack::MockRequest unit tests: verb split (301 GET/HEAD, 308 others), HEAD → 301 with empty body, root + no-trailing-slash pass-through, Location building (query string, %2F-encoded segments, X-Forwarded-Proto), and Cache-Control: no-store. Runs in ~3ms with no backend.
  • Added test/controllers/test_admin_only_endpoints.rb — data-driven gate test asserting every reachable admin_only! endpoint returns 403 for an authenticated non-admin.
  • Remaining suites need the backend stack — CI validates.

Notes

Trailing-slash canonicalization was implemented in init.rb by registering
a companion "#{pattern}/" 301-redirect route for every route. That route
ran in the route-dispatch phase, so it sat behind every before filter and
doubled the route table, and it hardcoded 301 for all verbs (which can
downgrade the method and drop POST/PUT/PATCH bodies).

Replace it with Rack::TrailingSlashRedirect, used as the innermost
middleware so it runs before Sinatra routing and filters. It strips the
trailing slash and redirects (301 for GET/HEAD, 308 for other verbs to
preserve method and body), preserving the forwarded scheme
(X-Forwarded-Proto), the query string, and percent-encoded path segments.
@alexskr alexskr force-pushed the refactor/controller-routing-cleanup branch from 7df10b1 to a7d7172 Compare June 24, 2026 02:10
The guard `error 403, "Access denied" unless current_user && current_user.admin?`
was copy-pasted across slices (4x) and annotator (2x), with a nil-guard-less
variant in users. Extract a single admin_only! helper next to current_user
and call it from all seven sites.
@alexskr alexskr closed this Jun 24, 2026
@alexskr alexskr force-pushed the refactor/controller-routing-cleanup branch from a7d7172 to 0850b6b Compare June 24, 2026 16:11
@alexskr alexskr changed the title Dedupe controller boilerplate + move trailing-slash redirect to Rack middleware Move trailing-slash redirect to Rack middleware; extract admin_only! helper Jun 24, 2026
@alexskr alexskr reopened this Jun 24, 2026
@codecov-commenter

codecov-commenter commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 78.30%. Comparing base (50595c0) to head (001b03c).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
controllers/slices_controller.rb 75.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #238      +/-   ##
===========================================
+ Coverage    78.10%   78.30%   +0.20%     
===========================================
  Files           66       67       +1     
  Lines         3727     3748      +21     
===========================================
+ Hits          2911     2935      +24     
+ Misses         816      813       -3     
Flag Coverage Δ
ag 78.30% <96.42%> (+0.20%) ⬆️
fs 78.30% <96.42%> (+0.20%) ⬆️
gd 78.30% <96.42%> (+0.20%) ⬆️
unittests 78.30% <96.42%> (+0.20%) ⬆️
vo 78.30% <96.42%> (+0.20%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@alexskr alexskr marked this pull request as ready for review June 25, 2026 21:07
@alexskr alexskr requested a review from mdorf June 25, 2026 21:08

@mdorf mdorf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could use a bit of additional test coverage:

Not adequate for the new code as committed. The fix is cheap and the author has already written the assertions — commit them as test/middleware/test_trailing_slash_redirect.rb (the repo already has that convention via test_rack_attack.rb), as backend-free Rack::MockRequest unit tests covering:

POST/PUT/PATCH/DELETE /x/ → 308 with Location preserved
HEAD /x/ → 301 (and assert empty body, per the review finding)
root / and a no-trailing-slash path → pass through untouched
Optionally a 403 test on one annotator admin route. The verb-split test is the one I'd block on.

Data-driven test asserting every reachable admin_only!-guarded endpoint (slices create/update/delete, user delete, annotator dictionary/cache) returns 403 for an authenticated non-admin.
Adds test/middleware/test_trailing_slash_redirect.rb driven directly via
Rack::MockRequest (no app boot / backend) covering: the verb split (301 for
GET/HEAD, 308 for POST/PUT/PATCH/DELETE), HEAD redirect with an empty body,
root and non-trailing-slash pass-through, and Location building (query string,
percent-encoded segments, X-Forwarded-Proto scheme selection).

Also fixes the middleware to return an empty body for HEAD redirects (a HEAD
response must not carry a body).
@alexskr

alexskr commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Addressed in de5f8b3.

Added test/middleware/test_trailing_slash_redirect.rb — backend-free Rack::MockRequest unit tests (run in ~3ms, no triplestore/Solr/Redis), covering:

  • Verb split (the one you'd block on): 301 for GET/HEAD, 308 for POST/PUT/PATCH/DELETE, with Location preserved.
  • HEAD → 301 with empty body — and fixed the middleware to actually return an empty body for HEAD (it previously returned ["Moved Permanently"] for every verb).
  • Root / and a no-trailing-slash path → pass through untouched.
  • Location building: query string, percent-encoded path segments (%2F), and X-Forwarded-Proto scheme selection (incl. chained/invalid values).

The optional non-admin 403 coverage for an annotator admin route is in test/controllers/test_admin_only_endpoints.rb (the data-driven gate test).

alexskr added 2 commits June 26, 2026 13:07
The redirect Location is built from X-Forwarded-Proto, but an outer
Rack::Cache keys entries on path + query only. Without no-store a 301/308
generated for an https client could be cached and replayed to a plain-http
client (or vice versa), reintroducing the scheme-downgrade from issue
#217. Redirects are cheap, so keeping them out of the
shared cache costs nothing.
Settings like enable_security live on global singletons, so a suite that
flips one (or errors after flipping it) leaked the value into later tests.

Add a with_settings block helper that restores prior values even when the
block raises, plus per-test (before_setup/after_teardown) and per-suite
(before_all/after_all) snapshot/restore nets in the test base class. Suites
can now set what they need and never restore. Drop the ad-hoc
enable_security/disable_security/reset_security class helpers and convert
callers.
@alexskr alexskr changed the title Move trailing-slash redirect to Rack middleware; extract admin_only! helper Controller/routing cleanup: trailing-slash middleware, admin_only! helper, test settings isolation Jun 26, 2026
after_all ran after_suite before restoring the per-suite settings snapshot.
Suites that enable a setting in before_suite (e.g. test_rack_attack sets
enable_security) and do privileged cleanup in after_suite (deleting users)
then ran that teardown with the setting still active -> WriteAccessDeniedError.
Because after_suite raised, restore_settings never ran, so enable_security
leaked into every later suite (data-setup WriteAccessDenied, 401s on public
routes). Restore first so teardown runs with settings rolled back and a raising
after_suite cannot leak.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants