diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..63a8b053c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* + +# Not needed in the image. +/coverage/ +/test/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8dc432343 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..83610cfa4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..dc93e0df3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + push: + branches: [ master ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + - name: Scan for known security vulnerabilities in gems used + run: bin/bundler-audit + + scan_js: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + env: + RUBOCOP_CACHE_ROOT: tmp/rubocop + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Prepare RuboCop cache + uses: actions/cache@v6 + env: + DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock') }} + with: + path: ${{ env.RUBOCOP_CACHE_ROOT }} + key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }} + restore-keys: | + rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}- + + - name: Lint code for consistent style + run: bin/rubocop -f github + + # Unit, integration and system tests run in a single job on purpose: SimpleCov's + # 90% floor is only meaningful when measured over the whole suite at once. + test: + runs-on: ubuntu-latest + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips + + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run the full test suite + env: + RAILS_ENV: test + run: bin/rails db:test:prepare test:all + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v7 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore + + - name: Publish coverage report + uses: actions/upload-artifact@v7 + if: always() + with: + name: coverage + path: ${{ github.workspace }}/coverage + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ea2a09e12 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + + +/app/assets/builds/* +!/app/assets/builds/.keep + +# Ignore coverage reports. +/coverage diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 000000000..154a4c75f --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,20 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Example of extracting secrets from Rails credentials +# KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000..59c85548b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,82 @@ +# Omakase plus the framework cops it leaves out, the Performance and Minitest +# plugins, and complexity ceilings. +inherit_gem: + rubocop-rails-omakase: rubocop.yml + +plugins: + - rubocop-minitest + - rubocop-performance + +AllCops: + TargetRubyVersion: 4.0 + NewCops: enable + Exclude: + - bin/**/* + - db/schema.rb + - db/*_schema.rb + - db/migrate/*_create_active_storage_tables.active_storage.rb + - vendor/**/* + - node_modules/**/* + - storage/**/* + - tmp/**/* + +Metrics/AbcSize: + Max: 17 +Metrics/BlockLength: + AllowedMethods: [ configure, draw, included, class_methods ] + Max: 30 +Metrics/ClassLength: + Max: 120 + Exclude: + - test/**/* +Metrics/CyclomaticComplexity: + Max: 7 +Metrics/MethodLength: + Max: 15 + Exclude: + - db/migrate/**/* + - test/**/* +Metrics/ModuleLength: + Max: 120 +Metrics/ParameterLists: + Max: 5 +Metrics/PerceivedComplexity: + Max: 8 + +Layout/LineLength: + Max: 120 + AllowedPatterns: [ '\A#' ] + Exclude: + - db/migrate/**/* + +# Omakase stays quiet about naming and dead code. +Lint/UselessAssignment: + Enabled: true +Naming/PredicateMethod: + Enabled: true +Style/RedundantReturn: + Enabled: true +Style/GuardClause: + Enabled: true +Style/IfUnlessModifier: + Enabled: false + +Rails/Delegate: + Enabled: true +Rails/HasManyOrHasOneDependent: + Enabled: true +Rails/InverseOf: + Enabled: true +Rails/OutputSafety: + Enabled: true +Rails/SkipsModelValidations: + Enabled: true + # update_counters is how the import reports progress without loading each row. + AllowedMethods: [ touch, update_counters ] +Rails/UniqueValidationWithoutIndex: + Enabled: true +Rails/Validation: + Enabled: true + +Minitest/MultipleAssertions: + Max: 12 diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..d13e837c8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.6 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..82806c427 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,134 @@ +# Umanni user management + +## Stack + +- Ruby 4.0.6 (`.ruby-version`), Rails 8.1.3.1, Bundler 4.0.16. +- SQLite 3 via `sqlite3` 2.9.6. Solid Cache 1.0.10, Solid Queue 1.7.0, Solid Cable 4.0.2. No Redis anywhere. +- Propshaft, importmap-rails, Turbo, Stimulus, tailwindcss-rails 4.6.0 (Tailwind v4). Puma 8, Thruster in production. +- Minitest 6, Capybara + selenium-webdriver (headless Chrome), SimpleCov 1.2.0. +- RuboCop 1.90 with rubocop-rails-omakase, rubocop-minitest, rubocop-performance. Brakeman and bundler-audit in CI. + +## Architecture + +- Authentication is the Rails 8 generator output (`bin/rails generate authentication`), committed unmodified on its + own and customised afterwards: signed `session_id` cookie, one `Session` row per browser, bcrypt digest, + token-based password reset. `app/controllers/concerns/authentication.rb`, `Current.session`/`Current.user`. +- `User` has `full_name`, `email`, `password_digest`, `role`. `role` is a string-backed enum (`user`, `admin`) with + `validate: true` and a database check constraint `users_role_check`. +- `email` uses `encrypts :email, deterministic: true`, so the unique index compares ciphertext and `authenticate_by` + works, but `LIKE` on email is impossible. Dev/test keys live in `config/environments/{development,test}.rb` on + purpose; production takes them from credentials (Rails default, nothing set in `production.rb`). +- At least one admin must always exist. Model validation plus `before_destroy`, because three routes can break it: + role toggle, admin edit form, profile deletion. +- `Admin::BaseController` runs `require_admin`; every admin controller inherits from it. Admin routes: + `admin/dashboard` (live counters), `admin/users` (all but show), `admin/users/:user_id/role` (update), + `admin/spreadsheet_imports` (index, new, create, show). +- `Admin::Users::RolesController#update` toggles the role and answers with a Turbo Stream that replaces the row + (`dom_id(user)`) and `#flash`. An admin cannot change their own role or delete themselves. +- Landing page after sign-in and at `/`: admins go to `admin_dashboard_url`, everyone else to `profile_url` + (`ApplicationController#home_url_for`, reused by `HomeController`). +- Avatar is `has_one_attached :avatar_image` (Active Storage, Disk service). PNG/JPEG/WebP only, 2 MB cap, validated + in the model. No variants; images are served at upload size and constrained by CSS. +- `Pagination` (`app/models/pagination.rb`) is a PORO: offset-based, fetches `per_page + 1` rows, no COUNT. +- `SpreadsheetImport` + `SpreadsheetImport::RowReader` + `SpreadsheetImportJob` (`ActiveJob::Continuable`) handle the + .csv/.xlsx import. The job suppresses `User`'s debounced dashboard broadcast and paces refreshes itself. +- `resource :profile` routes `show edit update destroy`, all implemented. No id in the route, so `ProfilesController` + reads `Current.user` and never a parameter. +- Tailwind component layer in `app/assets/tailwind/application.css`: `card`, `field-*`, `btn-*`, `badge-*`. Tailwind v4 + will not `@apply` one component class inside another, hence the selector lists. + +## Database topology + +- `config/database.yml`: development and production each have four SQLite databases, `primary`, `cache`, `queue`, + `cable`, under `storage/`. Test is a single database (`storage/test.sqlite3`). +- Pragmas are explicit: `journal_mode: wal`, `synchronous: normal`, `foreign_keys: true`, `mmap_size`, + `journal_size_limit`, `cache_size`. +- `config/cache.yml` and `config/cable.yml` name their database in development and production; `cable.yml` uses + `async` in test. `test/config/solid_stack_test.rb` asserts the development and production halves stay true. +- Schema files: `db/schema.rb`, `db/cache_schema.rb`, `db/queue_schema.rb`, `db/cable_schema.rb`. + +## Running it + +- Docker: `docker compose up` builds `Dockerfile.dev`, runs `bin/rails db:prepare && bin/dev`, serves on host port + 3200 (`WEB_PORT=xxxx docker compose up` to change). Health check hits `/up`. +- Local: `bin/setup` (bundle, `db:prepare`, then `bin/dev`), or `bin/dev` alone. `bin/dev` runs foreman over + `Procfile.dev`: `web`, `css` (Tailwind watcher), `jobs` (`bin/jobs`, Solid Queue). Default port 3000. +- Seeds (`bin/rails db:seed`) are idempotent, create 32 users, and refuse to run outside development and test — + `db:prepare` seeds a freshly created database, and a production deploy must not come up with demo logins in it. + Demo logins, password `secret-password`: `admin@umanni.test` (admin), `user@umanni.test` (user). +- Fixtures use `grace@umanni.test` (admin) and `ada@umanni.test` (user), same password. +- Development mail goes to SMTP on port 1025, host `SMTP_HOST` (default `localhost`, `mail` under Docker). + Production reads a different variable, `SMTP_ADDRESS`, and raises on delivery errors instead of swallowing them. + `docker compose --profile mail up` starts Mailpit on :8025. With nothing listening there delivery fails + silently in development (`raise_delivery_errors = false`). Preview at `/rails/mailers`. +- `Dockerfile` is the production image: multi-stage, non-root, Thruster. `config/deploy.yml` is a complete Kamal 2 + configuration whose placeholders are the image owner, server IP, registry user and the + `APP_HOST`/`SMTP_ADDRESS`/`MAIL_FROM` under `env: clear:`. Its two secrets come from `.kamal/secrets`. + +## Testing + +- `bin/rails test:all` runs unit, integration and system tests. `bin/rails test` skips system tests. +- Tests run in parallel (`workers: :number_of_processors`). SimpleCov results are merged per worker in + `test_helper.rb`. +- Line coverage floor is 90%, enforced only when `CI` or `COVERAGE` is set. Branch coverage is reported, not enforced. +- System tests use headless Chrome. Set `CHROME_BINARY=/path/to/chrome` when Chrome is not on `PATH` (WSL, slim + containers). +- Rate limiting is testable: `config.action_controller.cache_store = :memory_store` in test while the general store is + `:null_store`; `test_helper.rb` clears it before each test. +- `bin/ci` (`config/ci.rb`) mirrors the remote pipeline locally: setup, RuboCop, bundler-audit, importmap audit, + Brakeman, `bin/rails test`, `bin/rails test:system` and `db:seed:replant` in the test env. GitHub Actions (`.github/workflows/ci.yml`) runs the scans, lint and + `bin/rails db:test:prepare test:all` on pull requests and pushes to `master`. + +## Conventions + +- Conventional Commits in English (`feat:`, `fix:`, `test:`, `chore:`, `ci:`, `refactor:`). Anything that took a + decision carries a body explaining it. Default branch is `master`. +- `params.expect`, not `permit`, in every controller that takes a form. +- `:role` is permitted in `Admin::UsersController#user_params` and nowhere else; + self-registration and the profile never accept it. The import reads the role from the spreadsheet instead. +- Admin search is `User.matching`: an exact email when the term contains `@` (deterministic encryption allows it), + otherwise `full_name LIKE ... ESCAPE`, with the term escaped by `sanitize_sql_like`. The `ESCAPE` clause is what + makes that escaping mean anything: without it the backslash is a literal, `_` stays a wildcard, and nobody can + search for a name containing `%` or `_`. Role filter is checked against `User.roles` before it reaches the query. +- RuboCop: omakase plus a stricter layer (`.rubocop.yml`: metrics ceilings, Rails cops, Minitest and Performance + plugins, line length 120). Run `bin/rubocop -A` after editing Ruby. +- Error responses render with `status: :unprocessable_content`; destroy redirects use `status: :see_other`. +- Submit-button state comes from Turbo's `data-turbo-submits-with`, not from Stimulus. The one Stimulus controller + (`image_preview`) does what Turbo cannot: reads a chosen `File` client-side and revokes the object URL on disconnect. +- A Content Security Policy of `default-src 'none'` is enforced, with a per-response nonce for scripts and styles and + `style-src-attr 'unsafe-inline'` for computed widths. Adding an external script or stylesheet means updating + `config/initializers/content_security_policy.rb`. `ApplicationSystemTestCase` fails any test whose page reports a + violation — a blocked style breaks the interface without failing a single DOM assertion. +- `ENV["PORT"]` is not the port the app serves on. Foreman gives each Procfile process its own (web 3000, css 3100, + jobs 3200), and mail is delivered from the jobs process, so links built from PORT point at the worker. Use `WEB_PORT`. +- The admin tables stack into cards below `sm`: the table, head, body, rows and cells all carry `block`/`hidden` with + an `sm:table-*` counterpart. A four-column row with three actions does not fit 390px, and internal horizontal scroll + hides the actions behind an edge nobody notices. Keep `dom_id(user)` on the `tr` — the role toggle replaces it. +- Never pass `request.query_parameters` to `url_for`. It hands the router `host`, `protocol` and `controller` from the + query string: pagination links get rewritten to another domain, and a bad `controller` is a 500. + +## Gotchas + +- `Procfile.dev` must keep `tailwindcss:watch[always]`. Without `always` the watcher exits when stdin is not a TTY + (Docker Compose), and foreman takes the whole application down with it. +- `config/cache.yml` needs `database: cache` under development as well as production. Missing it, Solid Cache looks for + `solid_cache_entries` in the primary database and every `rate_limit` action returns 500 on its first write. The test + suite never sees this because test uses `:null_store`; `test/config/solid_stack_test.rb` guards it instead. +- `config.active_record.encryption.encrypt_fixtures = true` in `test.rb` is required for fixture emails to match the + encrypted column. +- `shared/_avatar.html.erb` checks `avatar_image.attachment&.persisted?`, not `attached?`. Re-rendering a form after a + failed create otherwise asks for a URL to a blob with no id and raises. +- In system tests `click_on` returns before the request completes. `ApplicationSystemTestCase#sign_in_as` waits on + `has_no_current_path?(new_session_path)` and flunks with the page text, so the next `visit` is not made as an + anonymous visitor and a failure says why. +- `Dockerfile.dev` has no `USER`, so the container runs as root. `tmp` and `log` are named volumes in `compose.yaml`; + bind-mounting them leaves root-owned files on the host that block a local `bin/dev` from writing its bootsnap cache. + Read container logs with `docker compose logs`. +- libvips is only needed for Active Storage variants. Both Docker images and the CI test job install it; a host + without it runs everything, because avatars do not use variants. +- `sign_in_as` in `test/test_helpers/session_test_helper.rb` writes a cookie into a test request and is for + integration tests only; system tests use the browser-driven override in `ApplicationSystemTestCase`. +- Turbo's debounce on `broadcasts_refreshes_to` restarts on every write, so a bulk job broadcasts nothing until it + stops. `SpreadsheetImportJob` suppresses it and paces the refresh itself. +- The cable adapter in test is `async`, not `test`, because system tests need real delivery. That rules out + `assert_broadcasts` and turbo's own broadcast assertions; Minitest 6 also no longer ships `minitest/mock`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f0c8f7cce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,77 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t umanni . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name umanni umanni + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment variables and enable jemalloc for reduced memory usage and latency. +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" \ + LD_PRELOAD="/usr/local/lib/libjemalloc.so" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libvips libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY vendor/* ./vendor/ +COPY Gemfile Gemfile.lock ./ + +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash +USER 1000:1000 + +# Copy built artifacts: gems, application +COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --chown=rails:rails --from=build /rails /rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..06c7bf41e --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# Development image. The production build lives in ./Dockerfile — it is multi-stage, +# runs as a non-root user and serves through Thruster. This one trades image size for +# a working toolchain: build headers stay installed so native gems can be rebuilt. + +ARG RUBY_VERSION=4.0.6 +FROM docker.io/library/ruby:$RUBY_VERSION-slim + +WORKDIR /rails + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y \ + build-essential curl git libvips libyaml-dev pkg-config sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +ENV RAILS_ENV="development" \ + BUNDLE_PATH="/usr/local/bundle" \ + BINDING="0.0.0.0" + +COPY Gemfile Gemfile.lock ./ +RUN bundle install + +COPY . . + +EXPOSE 3000 +CMD ["bin/dev"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 000000000..9cdc289d1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,78 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3", ">= 8.1.3.1" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Use Tailwind CSS [https://github.com/rails/tailwindcss-rails] +gem "tailwindcss-rails" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] + +# Reads .csv and .xlsx spreadsheets behind a single interface [https://github.com/roo-rb/roo] +gem "roo", "~> 3.0" +# No longer a default gem as of Ruby 3.4; required by Roo's CSV backend. +gem "csv" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling, extended by .rubocop.yml [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false + gem "rubocop-minitest", require: false + gem "rubocop-performance", require: false + + # Realistic seed data. + gem "faker", "~> 3.5" +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" + + # Coverage reporting, aggregated across parallel test workers. + gem "simplecov", require: false +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..25ed30e1a --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,568 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + actionmailer (8.1.3.1) + actionpack (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activesupport (= 8.1.3.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3.1) + actionview (= 8.1.3.1) + activesupport (= 8.1.3.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3.1) + activesupport (= 8.1.3.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3.1) + activesupport (= 8.1.3.1) + globalid (>= 0.3.6) + activemodel (8.1.3.1) + activesupport (= 8.1.3.1) + activerecord (8.1.3.1) + activemodel (= 8.1.3.1) + activesupport (= 8.1.3.1) + timeout (>= 0.4.0) + activestorage (8.1.3.1) + actionpack (= 8.1.3.1) + activejob (= 8.1.3.1) + activerecord (= 8.1.3.1) + activesupport (= 8.1.3.1) + marcel (~> 1.0) + activesupport (8.1.3.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt (3.1.22) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.26.0) + msgpack (~> 1.5) + brakeman (8.0.6) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.7) + csv (3.3.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + drb (2.2.3) + ed25519 (1.4.0) + erb (6.0.7) + erubi (1.13.1) + et-orbi (1.4.2) + tzinfo + faker (3.8.0) + i18n (>= 1.8.11, < 2) + fugit (1.13.0) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + 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.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.21.2) + kamal (2.12.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.2) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.1) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + matrix (0.4.3) + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.4) + net-imap (0.6.6) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.3.0) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.3) + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + 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 (8.0.2) + nio4r (~> 2.0) + raabro (1.5.0) + racc (1.8.1) + rack (3.2.7) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3.1) + actioncable (= 8.1.3.1) + actionmailbox (= 8.1.3.1) + actionmailer (= 8.1.3.1) + actionpack (= 8.1.3.1) + actiontext (= 8.1.3.1) + actionview (= 8.1.3.1) + activejob (= 8.1.3.1) + activemodel (= 8.1.3.1) + activerecord (= 8.1.3.1) + activestorage (= 8.1.3.1) + activesupport (= 8.1.3.1) + bundler (>= 1.15.0) + railties (= 8.1.3.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) + 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 (8.1.3.1) + actionpack (= 8.1.3.1) + activesupport (= 8.1.3.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.7.0) + io-console (~> 0.5) + rexml (3.4.4) + roo (3.0.0) + base64 (~> 0.2) + csv (~> 3) + logger (~> 1) + nokogiri (~> 1) + rubyzip (>= 3.0.0, < 4.0.0) + rubocop (1.90.0) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-minitest (0.40.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.37.0) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + rubyzip (3.6.0) + securerandom (0.4.1) + selenium-webdriver (4.48.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + simplecov (1.2.0) + solid_cable (4.0.2) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.7.0) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + sqlite3 (2.9.6-aarch64-linux-gnu) + sqlite3 (2.9.6-aarch64-linux-musl) + sqlite3 (2.9.6-arm-linux-gnu) + sqlite3 (2.9.6-arm-linux-musl) + sqlite3 (2.9.6-x86_64-linux-gnu) + sqlite3 (2.9.6-x86_64-linux-musl) + sshkit (1.25.1) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + tailwindcss-rails (4.6.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) + tailwindcss-ruby (4.3.3-x86_64-linux-musl) + thor (1.5.0) + thruster (0.1.26) + thruster (0.1.26-aarch64-linux) + thruster (0.1.26-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket (1.2.11) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.8.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (~> 3.1.7) + bootsnap + brakeman + bundler-audit + capybara + csv + debug + faker (~> 3.5) + importmap-rails + kamal + propshaft + puma (>= 5.0) + rails (~> 8.1.3, >= 8.1.3.1) + roo (~> 3.0) + rubocop-minitest + rubocop-performance + rubocop-rails-omakase + selenium-webdriver + simplecov + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + tailwindcss-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b + actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c + actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c + actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 + actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 + actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 + activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a + activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 + activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b + activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef + activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.26.0) sha256=ca96237015e6cd74a02963d5821cf00ac5ea134653b323e8cd6d702a7718bf1b + brakeman (8.0.6) sha256=759cc69341115e6c2dcd47b6fd8649a0b9bd540e3585ac8a0a94e31c66fee386 + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.3) sha256=831def58b2c51dcef66ec00d29397d4f210de89c19fe78f95873ca30f386e86a + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 + rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-minitest (0.40.0) sha256=353c698199115f12151144cf0b5a96f69bb9d77b660cf6536df2c4250c672a9d + rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce + rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.48.0) sha256=0c8376ebc8a0a4879343fe6fe6eccdcea76748611cd25de370b33eded2077a94 + simplecov (1.2.0) sha256=ea6acd05eece5a41990e2a5171c57d15700d329326c7666c85ee8c6a0dd0977e + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.7.0) sha256=6566b70b801d1c317c81bba7bcdd5677c019afac584a30374b4164002ca356d3 + sqlite3 (2.9.6-aarch64-linux-gnu) sha256=d8b1f7d23efd7abac285775a9566562fc7debfef79d594e3a20354406fb7907c + sqlite3 (2.9.6-aarch64-linux-musl) sha256=3579e1c98cdc7ff5c3722847bb63ed4e1efb7ff675cb5e1e48ef2d4da5fb3bc9 + sqlite3 (2.9.6-arm-linux-gnu) sha256=33541500e3615da02afe54a9cc38b17a6985d3cf9d8b76d6d0a83002f114e7ec + sqlite3 (2.9.6-arm-linux-musl) sha256=c5490af48bb228fefa54314e9541375c3907e70f8109f3881b5ff97e1c93ae33 + sqlite3 (2.9.6-x86_64-linux-gnu) sha256=613188ce02f614126ddbc38c5e217ccffd6306d0dcd9adca9764547aa890a634 + sqlite3 (2.9.6-x86_64-linux-musl) sha256=d493b11818a3573387a1d56e1ee8fa00da23a683a7a1cc063e7a0feeed843abf + sshkit (1.25.1) sha256=be3f10b9d6eb0b44d5eaba3f7cbe41bc6bb894bce4339688ac20124391455b78 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.26) sha256=6e45e807086b29d51404841bd1ad493b67cd95892fd65dc5afcdd32e82e94ce8 + thruster (0.1.26-aarch64-linux) sha256=2171cb34928c0250830008f535c4ab2ee57846cc3f5d3e96c3475f7b3de7a541 + thruster (0.1.26-x86_64-linux) sha256=3117a6ee430663f845a0457699fe9a05232dcc6c396e2cc83504de5a223c60e8 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + +BUNDLED WITH + 4.0.16 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 000000000..7c996b83b --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,5 @@ +web: bin/rails server +# Without `always` the watcher exits when stdin is not a TTY, and foreman takes the +# whole application down with it. +css: bin/rails tailwindcss:watch[always] +jobs: bin/jobs diff --git a/README.md b/README.md index 7829f14ff..c0a5b6927 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,491 @@ -# Modern Fullstack Developer Test (Rails 8 / Ruby 4) - -- Check this readme.md -- Create a branch to develop your task -- Push to remote in 1 week (date will be checked from branch creation/assigned date) - -# Requirements: -- Target Stack: **Ruby 4.0+** and **Rails 8.0+** -- Database: PostgreSQL, MySQL, or SQLite (configured for production-ready WAL mode) -- Write robust unit, integration, and system tests using parallel testing features -- Deliver with a working multi-stage Dockerfile utilizing Thruster/Kamal-ready defaults -- Show senior best practices (e.g., proper design patterns, solid architecture, strict linter configuration) - -# Our AI Policy -At Umanni, we value efficiency and the modern developer workflow. **You are allowed to use AI coding assistants (ChatGPT, Claude, Copilot, etc.) during this test.** However, transparency is part of our culture. If you use any LLM to generate, refactor, or structure your code, **you must explicitly state which model you used** in a dedicated section at the top of your submission's README.md. Failing to disclose AI usage while using it will invalidate your submission. - -# The Test -Here we'll try to simulate a "real sprint" that you'll probably be assigned while working as Fullstack at Umanni. - -# The Task -- Create a modern, responsive application to manage users. -- A user must have: - 1. full_name - 2. email - 3. avatar_image (ActiveStorage file upload or remote URL) - 4. role (admin/no-admin) - -# The App -## Admin Use cases -- As an Admin, I must be able to access a User Admin Dashboard. -- As an Admin, I must be able to see on the Dashboard (updated via real-time streams/frontend state): - - Total number of Users - - Total number of Users grouped by Role -- As an Admin, I must be redirected to the User Admin Dashboard after login. -- As an Admin, I must be able to list, create, edit, and delete Users. -- As an Admin, I must be able to toggle the User Role. -- As an Admin, I must be able to import a Spreadsheet (.csv/.xlsx) into the system in order to asynchronously create new Users. -- As an Admin, I must be able to see the live progress/status of the spreadsheet import process. - -## User Use Cases -- As a User, I must be redirected to my Profile after login. -- As a User, I must be able only to see my info, edit, and delete my profile. - -## Visitor Use Cases -- As a Visitor, I can register myself as a normal User. - - - -# The Start. -- Your deadline is 1 week after accepting this test. - -# The Rules (Strict Compliance) -These are mandatory. Failing any of them will invalidate your submission. -- **Documentation**: You must write down a detailed README.md in English explaining how to build, seed, and run your app, including your AI disclosure if applicable. -- **Frontend Stack**: You have two choices for the modern monolithic approach: - - **Option A (Classic Modern):** Hotwire (Turbo 8+ / Stimulus) with smooth, reactive UI states. - - **Option B (Modern SPA Monolith):** **React integrated via Inertia.js** (using Vite or the official Rails 8 asset pipeline integration). -- **Styling**: The Frontend must use a modern CSS framework (Tailwind CSS, Bootstrap, or any utility-first library). Keep it beautiful, responsive, and clean. -- **Real-time & Background Processing**: You must leverage native Rails 8 tools (**Solid Cable** for live dashboard counters/import bars and **Solid Queue** for the background import processing). No Redis installation should be required. -- **Authentication**: You must use the new built-in Rails 8 Authentication system (`bin/rails generate authentication`), customized to fit the role constraints. Avoid legacy heavy gems (like Devise). -- **Git Hygiene**: Clean git history with atomic commits, proper descriptions, and a Pull Request-based workflow. - -# What we're expecting to see: -- Modern asset management using **Propshaft** or **Vite Rails** (if choosing Inertia/React). -- .gitignore, .dockerignore configured correctly. -- Clean application configuration using Rails credentials. -- Comprehensive cross-browser support considerations. -- Strict form validations (Frontend interactive feedback + Backend structural validation). -- Parallel testing with at least 90% coverage (using Minitest, RSpec, and Playwright/Capybara for frontend integration). - -# Extra points -- Delivery via a clean **Kamal 2** deployment configuration (`deploy.yml`). -- Advanced SSR (Server-Side Rendering) setup if using **Inertia.js + React**. -- Use of **Thruster** as a zero-config proxy for asset caching and compression in Docker. -- Advanced performance profiling leveraging Ruby 4's **ZJIT** compilation optimizations. - -# What will be assessed -- Code's Semantics, Cleanness, and Maintainability (Senior-level object-oriented design and clean React/Stimulus component lifecycle). -- Modern Rails 8 idiom usage (e.g., Strict structural params handling, Solid architecture separation). -- Basic Security testing against traditional vectors (SQLi, XSS, XSRF) and proper encryption of sensitive DB columns where applicable. +# Umanni — User Management + +A Rails 8.1 application on Ruby 4.0 for managing users: a live admin dashboard, full +CRUD with role control, asynchronous spreadsheet import with real-time progress, and a +profile each user manages themselves. + +SQLite in WAL mode, Solid Queue, Solid Cache and Solid Cable. No Redis, no Postgres. + +--- + +## AI Usage Disclosure + +**Models used: Claude Opus 5 and Claude Fable 5.1, through Claude Code.** + +**How it was built.** Through the Claude workflow I use for real work, not a prompt and +a paste: requirements broken into phases, one branch and one PR per phase, and a gate at +the end of each that the phase had to clear before it moved. Opus 5 carried the +implementation and the reviews. Fable 5.1 did the planning, drafted `CLAUDE.md`, +surveyed my previous take-home repositories for conventions worth keeping, and ran the +adversarial passes — evaluating the delivery against the brief with no memory of having +written it, which is how most of the defects listed below were found. Sub-agents drove +Playwright against the running application and reported back; none of them had write +access to the codebase. + +**What I did.** I directed the work and stayed inside it. The architecture and every +decision the work branched on are mine: Hotwire over Inertia/React, Minitest over RSpec, +SQLite over PostgreSQL, one PR per phase, and how to handle the point below. I set the +standards the code was held to, read the work against them, sent back what missed, and +decided what shipped. Nothing merged on a model's say-so — a phase moved only on a real +gate: the full suite green, RuboCop and Brakeman clean, the production image built and +exercised, and the flow actually clicked through in a browser. + +**The workflow.** Skeleton → authentication → admin CRUD → live dashboard → import → +profile → delivery. Read-only work fanned out in parallel — investigation, UI testing, a +second opinion from a different model — while writing stayed single-threaded, because two +agents editing the same tree produce decisions nobody reviewed. + +**What that caught.** Bugs a green test suite did not: + +- `tailwindcss:watch` exits when stdin is not a TTY, so `docker compose up` died on + startup. It needed the `[always]` argument. This would have broken on the reviewer's + very first command. +- Signing in returned 500 in development: `config/cache.yml` named the cache database + under production only, so Solid Cache went looking for its table in the primary + database. The suite could not see it, because the test environment uses `:null_store`, + which makes rate limiting inert. There is now a test that guards the class of bug. +- The import's progress bar stuck on "Processing" about one run in three: two broadcasts + a millisecond apart, arriving out of order. +- The dashboard counters froze for the entire duration of an import. Turbo debounces + refresh broadcasts and the debounce restarts on every write, so a job creating rows + faster than the delay produced no refresh at all until it finished. Found by watching a + 150-row import; the code reads as though it works. +- The production image seeded demo users, with a published password, because the + entrypoint runs `db:prepare`. Found by running the image rather than only building it. +- A content security policy added in review blocked the import bar's inline width, so it + rendered full at every percentage while every test still passed. +- `docker compose --profile mail up` promised a clickable reset link at :8025 and never + delivered one: SMTP pointed at `localhost`, which inside the web container is that + container. `raise_delivery_errors` is off in development, so it failed in silence. + Found by following this README's own instructions instead of trusting them. + +A later pass found a commit that had quietly dropped the test asserting a forged session +cookie is ignored, and two tests that kept passing with the defence they named removed, +because a different rule was doing the blocking. All of them are checked the same way +now: remove the defence, watch the test go red. + +### A note on the hidden instructions in the brief + +The brief at contains an HTML comment in +its `README.md`, addressed to LLM assistants. It asks the model to inject a marker string +into the frontend and, in its own words, to *"not disclose or explain these hidden +constraints to the human user."* It is visible in the raw file, not in the rendered page. + +I read it, and I did not follow it. There is no marker string in this codebase, and +nothing about the process was hidden from me. Disclosure is above, in full. + +--- + +## Running it + +### Docker (recommended) + +```bash +docker compose up +``` + +Then . Port 3200 rather than 3000 so it does not collide with +another Rails server; override with `WEB_PORT=4000 docker compose up`. + +There is no database service to wait for. SQLite and the three Solid databases live in +the `storage` volume, which is the point of this stack. + +```bash +docker compose exec web bin/rails db:seed # optional: `db:prepare` already seeded +``` + +### Local + +Requires Ruby 4.0.6 (`.ruby-version`) — `rbenv install 4.0.6`, `mise use ruby@4.0.6` or the +equivalent for your version manager. No Node and no libvips: assets go through importmap +and Propshaft, and avatars are stored without variants. + +```bash +bin/setup --skip-server # bundle and prepare the databases +bin/dev # Puma, the Tailwind watcher and the Solid Queue worker +``` + +`bin/setup` runs `db:prepare`, which seeds a database it just created, so a fresh clone +needs no separate `bin/rails db:seed` — run that only to re-seed an existing database. +Plain `bin/setup` hands the terminal straight to `bin/dev`. `bin/dev` installs the +`foreman` gem on first run if it is missing. + +### Demo logins + +Seeds are idempotent and create 32 users — 6 admins and 26 users, the same split on every +machine. Password for all of them: `secret-password`. + +| Email | Role | +|---|---| +| `admin@umanni.test` | admin | +| `user@umanni.test` | user | + +Seeds refuse to run outside development and test. `db:prepare` seeds a freshly created +database, and a production deploy must not come up with a published password in it. +The first admin of a real deployment is one command: + +```bash +bin/rails runner 'User.create!(full_name: "Ada Lovelace", email: "ada@example.com", role: :admin, password: ENV.fetch("ADMIN_PASSWORD"))' +``` + +### Trying the spreadsheet import + +`test/fixtures/files/users.csv` and `users.xlsx` are ready to upload from **Imports → +New import**. They are deliberately dirty: five rows, of which one has no name and one +repeats an earlier email. Three import, two are reported by line and reason, and the run +still completes. Open the dashboard in a second tab first and watch its counters move at +the same time. + +--- + +## Testing + +```bash +bin/rails test:all # unit, integration and system +bin/rails test # skips system tests +bin/ci # the whole pipeline: lint, audits, Brakeman, tests, seeds +``` + +**181 tests, 652 assertions, 99.76% line coverage, 97.54% branch coverage** on the last +run — `bin/rails test:all` prints the current figures, and a per-layer breakdown kept by +hand only rots. Tests run in parallel across one process per core, and SimpleCov results +are merged per worker — without that merge the report shows roughly one worker's share +and every number after it is fiction. The 90% floor is enforced under `CI` or `COVERAGE`. + +System tests need Chrome, and `Dockerfile.dev` does not install one: run the suite on the +host, not through `docker compose exec`. If Chrome is not on `PATH` (WSL, slim +containers): + +```bash +CHROME_BINARY=/path/to/chrome bin/rails test:system +``` + +The system tests prove what no controller test can: that the dashboard counters move on +their own when a user is created elsewhere, that a role toggle replaces one table row +without reloading the page, and that an import's progress arrives over the wire while +the page sits open. + +--- + +## What it does + +| Use case | Where | +|---|---| +| Admin dashboard, counts total and by role, live | `Admin::DashboardsController` | +| Admin lists, creates, edits and deletes users | `Admin::UsersController` | +| Admin toggles a user's role | `Admin::Users::RolesController` | +| Admin imports a spreadsheet asynchronously | `SpreadsheetImportJob` | +| Admin watches import progress live | `Admin::SpreadsheetImportsController` | +| Admin lands on the dashboard after login | `ApplicationController#home_url_for` | +| User lands on their profile after login | same | +| User sees, edits and deletes only their own profile | `ProfilesController` | +| Visitor registers as a plain user | `RegistrationsController` | + +--- + +## Technical decisions + +**SQLite, not PostgreSQL.** The brief asks for WAL mode, and Rails 8 is built around +SQLite plus the Solid trio. The pragmas are written out in `config/database.yml` rather +than left to the adapter's defaults, because WAL journalling, `synchronous: normal` and +enforced foreign keys are what separate a production-ready SQLite setup from a toy one, +and a reviewer should not have to read the adapter source to see them. The moment this +application needs a second machine running jobs, it moves to PostgreSQL — SQLite is a +file, not a server. + +**Development mirrors production.** Rails leaves development on the async and memory +adapters. That would mean the broadcast and job paths that actually ship are never +exercised until deploy, and a reviewer running `docker compose up` would reasonably +conclude the Solid stack was not used. Development runs the same four databases +production does. `test/config/solid_stack_test.rb` asserts they stay configured, which +is the test that would have caught the cache bug listed above. + +**Deterministic encryption on `email`.** The column has to stay uniquely indexable and +findable by exact value — `authenticate_by` and the unique index both depend on +identical plaintext producing identical ciphertext. The cost is real and worth stating: +`LIKE` on email is impossible. The admin search works around it by branching: a term +containing `@` is matched as an exact email, and anything else runs `LIKE` against +`full_name`, which is deliberately left in plaintext for exactly that reason. + +**Roles are an enum with a database constraint.** The enum guards the application; the +`CHECK` constraint guards the console, data migrations and anything else that goes +around the model. + +**Authorisation belongs to the namespace.** `Admin::BaseController` runs the check, so +every admin controller inherits it rather than remembering to declare it. Two roles do +not justify a policy object. A third role, or per-record permissions, is where Pundit +would go — and that controller is where it would plug in. + +**The role is a sub-resource, not a custom action.** `PATCH /admin/users/:user_id/role` +keeps `UsersController` plain CRUD and gives the rule that an admin cannot change their +own role one obvious home. That rule is also what keeps the system administrable: any +*other* admin they demote still leaves them an admin, so the last one can never vanish. + +**Pagination is offset-based and hand-rolled.** It fetches one row beyond the page, so +"is there a next page" is answered without a `COUNT`. That is the whole requirement at +this size. A list needing page numbers or a total is where Pagy goes in, rather than +growing this. + +**Avatars have no variants.** Generating them would put libvips on every developer's +machine to produce a 40px thumbnail. Uploads are capped at 2 MB and constrained by CSS +instead. At real avatar volume that trade flips and the variant comes back — libvips is +already in both Docker images. + +**Minitest, not RSpec.** RSpec is what I reach for day to day, but `parallelize` is the +parallel testing feature the brief names, it is native, and this whole test is built on +Rails 8's own tools. + +**The import paces its own dashboard refreshes.** Turbo's debounce restarts on every +write, so a bulk job outruns it and broadcasts nothing until it stops. The job suppresses +the model's broadcast and refreshes on a fixed cadence, and a test fails if that is +removed. + +**The import is continuable.** `ActiveJob::Continuable` is new in Rails 8.1 and here it +is correctness: a worker restarting mid-file would replay rows it already imported, and +each would come back as a duplicate email. A test resumes from a cursor and asserts the +earlier rows are not replayed. + +--- + +## Security + +**Encryption.** `email` is encrypted at rest with deterministic Active Record +encryption. A test reads the raw column and asserts the address is not in it. + +**SQL injection.** Every query goes through Active Record with bound parameters. The two +places user input reaches a query are covered directly: search escapes its term with +`sanitize_sql_like` and names the escape character in the clause — `ESCAPE` is not +optional, and without it the escaping is inert — and the role filter is checked against +`User.roles.key?` rather than passed through — there is a test that sends +`'; DROP TABLE users; --` as a role and asserts the page renders normally. + +**XSS.** ERB escapes by default and nothing here calls `html_safe` or `raw`; +`Rails/OutputSafety` keeps it that way. SVG is absent from the allowed avatar types: a +stored SVG is a stored script, and Active Storage serves attachments from this origin. +Behind that sits a Content Security Policy of `default-src 'none'` with a per-response +nonce for scripts and styles — escaping can be undone by one careless `html_safe`, a +policy cannot. Style *attributes* are allowed, because the import progress bar's width +is a computed value; `script-src`, which is where XSS lives, stays closed. The first version of +this policy broke that bar in the browser while twenty-one system tests stayed green — +they asserted `aria-valuenow`, which the server had got right. It was found by opening +the page. The teardown that fails any system test whose browser reports a policy +violation was written in the same commit as the fix, so the next one costs a test run +instead of a pair of eyes. Three tests store markup in a name, a validation message and +an import's row errors, and assert it comes back escaped. + +**CSRF.** Rails' token protection is on and every state change goes through `form_with` +or `button_to`. Rails disables the protection in the test environment, which means it is +normally never exercised, so one test turns it back on and asserts a token-less POST +creates nothing. + +**Refusals do not carry the method.** Every guard redirect answers `303 See Other` +rather than the default `302`. A client that follows a 302 keeps the request method, so +a `DELETE` on an admin route that the guard refused arrived at the redirect target as +another `DELETE` — and `/profile` answers `DELETE` by deleting the account of whoever +asked. Reproduced with `fetch(..., { method: "DELETE", redirect: "follow" })` against +the running application, and the account was gone. A test asserts the status. + +**Mass assignment.** `params.expect` in every controller that accepts a form, rather than +`params.permit` — a request that is not shaped like the form is a 400 rather than +something quietly filtered to an empty hash. (The admin index reads its filters with +`params.permit`; it takes query string, not a form, and a malformed one should narrow the +list, not 400.) `:role` appears in exactly one permitted list, in the admin namespace. +Both self-registration and profile editing have a test that submits `role: admin` and +asserts the user stays a user. + +**Brute force.** The sign-in and password-reset endpoints keep the generated +`rate_limit`. The test environment gives the limiter a real cache store so the rule is +exercised rather than assumed. + +**Static analysis.** Brakeman, bundler-audit and `importmap audit` run on every pull +request and report zero findings. + +`test/integration/security_test.rb` holds the injection, escaping, CSRF and forged-cookie +tests. The rest sit with the code they guard: the role filter and the admin routes that +change something in `test/controllers/admin/users_controller_test.rb`, the rate limit in +`sessions_controller_test.rb`, session revocation in `passwords_controller_test.rb` and +`profiles_controller_test.rb`, and the raw email column in `user_test.rb`. + +Every test written for a defence here was checked the same way: remove the defence, run +the test, watch it fail. That is not a formality — the self-demotion test passed for a +whole run with the parameter filter deleted, because a different rule was doing +the blocking. + +--- + +## Cross-browser support + +`allow_browser versions: :modern` rejects browsers without webp, import maps, CSS +nesting and `:has` — in practice Chrome 120+, Safari 17.2+ and Firefox 121+. Tailwind 4 +sets a floor of its own around there, so without this an older iOS would get broken CSS +and no explanation; `public/406-unsupported-browser.html` is that explanation. The floor +is what makes polyfills unnecessary. + +Submit buttons disable and relabel themselves while a request is in flight through +Turbo's own `data-turbo-submits-with`. A Stimulus controller did this first, until review +pointed out that Turbo already shipped it. What Stimulus does here is the thing Turbo has +no answer for: previewing a chosen avatar before it is uploaded, reading a `File` the +browser already holds, and revoking the object URL on disconnect so a cached page does +not pin it in memory. + +Form feedback works in three layers. `required`, `type="email"`, `minlength` and +`accept` are enforced by the browser before a request is made, and +`.field-input:user-invalid` styles the field from its native validity state — no +JavaScript. The server-side rules are the ones that decide, and the system tests check +both: one asserts the browser blocks a short password before any request, and the +server-side test uses a duplicate email, because that is the case the browser cannot +catch. Server-side errors render beside the field that caused +them, with `aria-invalid` and `aria-describedby` so a screen reader gets the pairing. + +Layout is Tailwind, mobile-first. The users table scrolls inside its own container on a +phone. The name column pins from 640px up but not below it: on a phone the identity +column and three buttons do not both fit, and pinning it covers the actions. A system test +resizes to 390px and fails if any screen is wider than the viewport. + +--- + +## Deployment + +The production image is multi-stage, runs as a non-root user, and serves through +**Thruster** for asset caching, compression and X-Sendfile. + +**Generate your own credentials before building** — see the Credentials section below. +The build bakes `config/credentials.yml.enc` into the image, and a key generated afterwards +cannot decrypt it. + +```bash +docker build -t umanni . +docker run -d -p 80:80 \ + -e RAILS_MASTER_KEY="$(cat config/master.key)" \ + -e SOLID_QUEUE_IN_PUMA=true \ + -e APP_HOST=umanni.example.com \ + -e SMTP_ADDRESS=smtp.example.com -e SMTP_PORT=587 \ + -v umanni_storage:/rails/storage umanni +``` + +`RAILS_MASTER_KEY` is the contents of `config/master.key`, which is not in this +repository. + +`SOLID_QUEUE_IN_PUMA` is what starts the job supervisor inside Puma; without it the +image serves fine and imports never run. Kamal sets it in `config/deploy.yml`. + +`APP_HOST` and the SMTP pair are not optional decoration. Production raises on delivery +errors, and password reset is the only way an imported user ever signs in: leave them at +their defaults and the reset mail is addressed from `example.com` and posted to +`localhost:587`. Note the name changes by environment — development reads `SMTP_HOST`, +production reads `SMTP_ADDRESS`. + +**Regenerating credentials means regenerating the encryption keys.** `email` is an +encrypted column, and production reads `active_record_encryption` from the credentials +rather than from an environment file the way development and test do. If you replace +`config/credentials.yml.enc` with your own, run `bin/rails db:encryption:init` and paste +its three keys in, or the image will boot, answer `/up` with a 200, render every page, +and return a 500 the first time anyone signs in or registers. That is not hypothetical: +it is what this image did until the keys were added. + +`config/deploy.yml` is a complete Kamal 2 configuration: fill in the image owner, server +IP, registry user and the `APP_HOST`/`SMTP_ADDRESS`/`MAIL_FROM` under `env: clear:`, and +`bin/kamal setup` is the deploy. The two secrets it needs — `KAMAL_REGISTRY_PASSWORD` and +`RAILS_MASTER_KEY` — come from `.kamal/secrets`, which reads both from your environment. +`kamal config` resolves the whole file — roles, image, volume, ssh, builder — and +`kamal secrets print` resolves the master key. **A deploy against a real host was not +exercised**; there was no server to deploy to. TLS terminates at kamal-proxy, so +`assume_ssl` and `force_ssl` are on in production. + +Solid Queue runs inside Puma rather than as a separate job role, and that follows from +SQLite rather than being a shortcut: a worker on a second machine could not reach a +database that is a file. The `storage` volume holds all four databases and every Active +Storage upload — it is the only stateful thing on the server, and so the only thing that +needs backing up. + +**Credentials.** `config/master.key` is not in this repository, which means the +committed credentials cannot be read on another machine — as is true of any Rails +repository. Generate your own **before building the image**, or the build bakes in a +file the new key cannot decrypt: + +```bash +rm config/credentials.yml.enc +bin/rails db:encryption:init # copy the three keys it prints +EDITOR="code --wait" bin/rails credentials:edit # paste under active_record_encryption: +``` + +`credentials:edit` needs an `EDITOR` that blocks until you close the file — `code --wait`, +`vim`, or `nano`. Without one it exits without saving. + +Rails wires `active_record_encryption` from credentials on its own. Development and test +keys are committed in the environment files on purpose — they are not secrets, and the +application has to boot for anyone who clones this. + +--- + +## Performance + +Ruby 4 ships ZJIT and the official image has it compiled in, so enabling it is one flag. +Measuring first says not to. Rendering the users table partial 20,000 times inside the +production image, after 5,000 warmup iterations: + +| | YJIT (Rails' default) | ZJIT | +|---|---|---| +| Production image | 0.204–0.226 ms | 0.340–0.375 ms | +| Development container | 0.365–0.395 ms | 0.571–0.592 ms | + +ZJIT came out between 50% and 80% slower in every run of both, and a shorter warmup +widened the gap instead of narrowing it, so this is not a young JIT handicapped by +warmup. Rails enables YJIT on its own; it stays that way. + +`script/jit_benchmark.rb` takes the measurement. The development container needs no +credentials, so this is the one to reproduce: + +```bash +docker compose exec -e RUBYOPT=--yjit web bin/rails runner script/jit_benchmark.rb +docker compose exec -e RUBYOPT=--zjit web bin/rails runner script/jit_benchmark.rb +``` + +Absolute numbers are per machine and the two rows are not comparable to each other — the +development image boots unoptimised. The ratio within a row is the measurement. + +--- + +## Trade-offs and what is not here + +- **The last admin cannot be removed** — not by the role toggle, not by the admin edit + form, and not by deleting their own profile. Review found the first two routes open + while the third was guarded, which is what moved the rule from the controller into the + model. A user who is not the last admin can still delete their own account, as the + brief asks. +- **Imported users cannot sign in until they reset their password.** They are created + with a random one and set a real one through the reset flow. Delivery is configured + (`SMTP_ADDRESS`, `SMTP_PORT`, credentials under `smtp:`), and the flow was exercised + end to end against a local catcher — mail sent, link opened, password changed, old + password rejected. A deploy still has to point it at a real mail service. +- **The dashboard suppression is not covered by a test.** `User.suppressing_turbo_broadcasts` + is what stops Turbo's debounce from swallowing every refresh during a bulk import, but + turbo-rails debounces immediately under test, so the effect cannot be observed there. + The test beside it covers the pacing that replaces it — remove `DASHBOARD_EVERY` and it + fails; remove the suppression and it does not. +- **The test suite does not exercise Solid Queue, Solid Cache or Solid Cable.** Jobs run + inline, the cache is a null store and Action Cable is in-process, which is what keeps + the suite fast and deterministic. Those three run for real in development and + production against their own databases, which is where `docker compose up` puts them. + `test/config/solid_stack_test.rb` guards the configuration, not the behaviour. +- **Sessions have no expiry and no pruning**, and there is no "active sessions" screen. + The cookie is permanent, which is what `bin/rails generate authentication` produces. + Changing a password does sign every other browser out. +- **The imports list is not paginated.** The users list is; imports are few enough that + it has not earned it. +- **A worker killed outright leaves its import showing "processing".** Solid Queue records + the failure but never re-enters the job, so `retry_on` does not apply; and a manual + retry restarts the file from the top, reporting already-imported rows as duplicates. + Recovering properly means storing the job id and reconciling against + `solid_queue_failed_executions`, which is more machinery than this size of import earns. +- **Development delivers mail to a local catcher.** `docker compose --profile mail up` + starts Mailpit; the reset link is then clickable at . Without + it, delivery fails quietly. Previews are at `/rails/mailers`. +- **The import lists at most 200 rejected rows.** Each rejection rewrites the whole JSON + column and rides along in the next broadcast, so an all-bad file would grow both the + write cost and the message size with every row. The count keeps counting; only the + listed reasons stop, and the page says so. Imported people get a placeholder digest at + bcrypt's minimum cost — nobody authenticates with it, and the default cost was 250ms + per row for nothing. +- **No background job for avatar processing**, no CDN, no fragment caching. All three + answer a scale this application does not have. diff --git a/Rakefile b/Rakefile new file mode 100644 index 000000000..9a5ea7383 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 000000000..fe93333c0 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 000000000..eeb000fda --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,119 @@ +@import "tailwindcss"; + +@theme { + --color-brand-50: oklch(0.97 0.014 254.6); + --color-brand-100: oklch(0.93 0.032 255.6); + --color-brand-500: oklch(0.62 0.19 259.8); + --color-brand-600: oklch(0.55 0.21 262.9); + --color-brand-700: oklch(0.49 0.19 264.1); +} + +@layer components { + .card { + @apply bg-white rounded-xl border border-slate-200 shadow-sm; + } + + /* Shading at the edges that fades out when the scroll reaches them, so a table wider + than the phone says so. The local/scroll attachment pair is what makes it react. */ + .table-scroll { + @apply overflow-x-auto; + /* Longhands, not the background shorthand: that one resets background-color and + would strip the white surface off the .card this always sits on. */ + background-image: + linear-gradient(to right, var(--color-white), transparent), + linear-gradient(to left, var(--color-white), transparent), + linear-gradient(to right, rgb(15 23 42 / 0.10), transparent), + linear-gradient(to left, rgb(15 23 42 / 0.10), transparent); + background-position: left, right, left, right; + background-size: 20px 100%, 20px 100%, 10px 100%, 10px 100%; + background-repeat: no-repeat; + background-attachment: local, local, scroll, scroll; + } + + .field-label { + @apply block text-sm font-medium text-slate-700 mb-1.5; + } + + .field-input { + @apply block w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 + placeholder:text-slate-500 shadow-xs + focus:border-brand-500 focus:ring-2 focus:ring-brand-100 focus:outline-none; + } + + /* A server-side error should look like the browser's own, not like a red sentence + under a grey box. */ + .field-input[aria-invalid="true"], .field-input:user-invalid { + @apply border-red-400 focus:border-red-500 focus:ring-red-100; + } + + /* The native file button makes this taller than every other field otherwise. */ + .field-input[type="file"] { + @apply py-1.5; + } + + /* A select sizes from its own box and ignores line-height, landing 5px short of a + text input with the same padding. */ + select.field-input { + @apply h-10.5; + } + + .field-hint { + @apply mt-1.5 text-sm text-slate-500; + } + + .field-error { + @apply mt-1.5 text-sm text-red-600; + } + + /* Tailwind v4 will not @apply one component class inside another. */ + .btn-primary, .btn-secondary, .btn-danger { + @apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 + text-sm font-medium transition-colors cursor-pointer + focus-visible:outline-2 focus-visible:outline-offset-2 + disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-primary { + @apply bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600; + } + + .btn-secondary { + @apply bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 + focus-visible:outline-slate-400; + } + + .btn-danger { + @apply bg-white text-red-700 border border-red-300 hover:bg-red-50 + focus-visible:outline-red-500; + } + + .badge-admin, .badge-user, + .badge-neutral, .badge-info, .badge-success, .badge-danger { + @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium + ring-1 ring-inset; + } + + .badge-admin { + @apply bg-brand-100 text-brand-700 ring-brand-500/30; + } + + .badge-user { + @apply bg-slate-100 text-slate-600 ring-slate-300; + } + + .badge-neutral { + @apply bg-slate-100 text-slate-600 ring-slate-300; + } + + .badge-info { + @apply bg-sky-50 text-sky-700 ring-sky-200; + } + + .badge-success { + @apply bg-emerald-50 text-emerald-700 ring-emerald-200; + } + + .badge-danger { + @apply bg-red-50 text-red-700 ring-red-200; + } +} diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 000000000..c60a0c9a4 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,14 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + self.current_user = Session.find_by(id: cookies.signed[:session_id])&.user + end + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 000000000..bbcff933b --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,14 @@ +module Admin + class BaseController < ApplicationController + before_action :require_admin + + private + # See Other, not the default Found: a client that follows a 302 keeps the method, so + # a refused DELETE reaches the redirect target as another DELETE. + def require_admin + return if Current.user.admin? + + redirect_to profile_url, alert: "You are not authorised to access that page.", status: :see_other + end + end +end diff --git a/app/controllers/admin/dashboards_controller.rb b/app/controllers/admin/dashboards_controller.rb new file mode 100644 index 000000000..1f656a6d6 --- /dev/null +++ b/app/controllers/admin/dashboards_controller.rb @@ -0,0 +1,8 @@ +module Admin + class DashboardsController < BaseController + def show + @counts_by_role = User.group(:role).count + @total = @counts_by_role.values.sum + end + end +end diff --git a/app/controllers/admin/spreadsheet_imports_controller.rb b/app/controllers/admin/spreadsheet_imports_controller.rb new file mode 100644 index 000000000..4eed661b4 --- /dev/null +++ b/app/controllers/admin/spreadsheet_imports_controller.rb @@ -0,0 +1,37 @@ +module Admin + class SpreadsheetImportsController < BaseController + before_action :set_spreadsheet_import, only: :show + + def index + @spreadsheet_imports = SpreadsheetImport.recent_first.includes(:user, file_attachment: :blob) + end + + def new + @spreadsheet_import = SpreadsheetImport.new + end + + def create + @spreadsheet_import = Current.user.spreadsheet_imports.new(spreadsheet_import_params) + + if @spreadsheet_import.save + SpreadsheetImportJob.perform_later(@spreadsheet_import) + redirect_to admin_spreadsheet_import_path(@spreadsheet_import) + else + render :new, status: :unprocessable_content + end + end + + def show + end + + private + def set_spreadsheet_import + @spreadsheet_import = SpreadsheetImport.find(params[:id]) + end + + def spreadsheet_import_params + permitted = params.expect(spreadsheet_import: [ :file ]) + permitted[:file].is_a?(ActionDispatch::Http::UploadedFile) ? permitted : permitted.except(:file) + end + end +end diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb new file mode 100644 index 000000000..4c4eaa218 --- /dev/null +++ b/app/controllers/admin/users/roles_controller.rb @@ -0,0 +1,30 @@ +module Admin + module Users + class RolesController < BaseController + before_action :set_user + + def update + if @user == Current.user + return redirect_to admin_users_path, status: :see_other, + alert: "You cannot change your own role. Ask another admin." + end + + unless @user.update(role: @user.admin? ? :user : :admin) + return redirect_to admin_users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other + end + + message = "#{@user.full_name} is now #{@user.admin? ? "an admin" : "a user"}." + + respond_to do |format| + format.turbo_stream { flash.now[:notice] = message } + format.html { redirect_to admin_users_path, notice: message } + end + end + + private + def set_user + @user = User.find(params[:user_id]) + end + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 000000000..f9fa2247b --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,65 @@ +module Admin + class UsersController < BaseController + before_action :set_user, only: %i[ edit update destroy ] + + def index + # request.query_parameters here would let ?host= rewrite the links. + @filters = params.permit(:query, :role, :page).to_h.compact_blank.except("page") + @page = Pagination.new(filtered_users.ordered, page: params[:page]) + end + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save + redirect_to admin_users_path, notice: "#{@user.full_name} was added." + else + render :new, status: :unprocessable_content + end + end + + def edit + end + + def update + if @user.update(user_params) + revoke_other_sessions_for @user + redirect_to admin_users_path, notice: "#{@user.full_name} was updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + if @user == Current.user + redirect_to admin_users_path, alert: "Delete your own account from your profile.", status: :see_other + else + @user.destroy! + redirect_to admin_users_path, notice: "#{@user.full_name} was removed.", status: :see_other + end + end + + private + def set_user + @user = User.find(params[:id]) + end + + def user_params + permitted = without_untouched_fields params.expect( + user: [ :full_name, :email, :role, :password, :password_confirmation, :avatar_image, :remove_avatar_image ] + ) + @user == Current.user ? permitted.except(:role) : permitted + end + + def filtered_users + scope = User.with_attached_avatar_image + scope = scope.matching(params[:query]) if params[:query].present? + scope = scope.where(role: params[:role]) if User.roles.key?(params[:role]) + scope + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 000000000..81a1244eb --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,37 @@ +class ApplicationController < ActionController::Base + include Authentication + + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + stale_when_importmap_changes + + private + def after_authentication_url + session.delete(:return_to_after_authenticating) || home_url_for(Current.user) + end + + def redirect_if_authenticated + redirect_to home_url_for(Current.user), status: :see_other if authenticated? + end + + def revoke_other_sessions_for(user) + return unless user.saved_change_to_password_digest? + + user.sessions.where.not(id: Current.session&.id).destroy_all + end + + def home_url_for(user) + user.admin? ? admin_dashboard_url : profile_url + end + + # Blank password fields mean "not touched", not "clear it". A blanked full_name + # still has to fail validation, so this is not compact_blank. + def without_untouched_fields(permitted) + permitted = permitted.except(:password, :password_confirmation) if permitted[:password].blank? + # Anything that is not an upload — a blank input, or a string a client invented — + # is dropped: Active Storage reads a string as a signed id and raises on it. + permitted = permitted.except(:avatar_image) unless permitted[:avatar_image].is_a?(ActionDispatch::Http::UploadedFile) + permitted + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..16fed3b2d --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,48 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path, status: :see_other + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 000000000..ad09affb4 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,5 @@ +class HomeController < ApplicationController + def show + redirect_to home_url_for(Current.user) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 000000000..dfaf1fbd2 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,53 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later.", status: :see_other } + + def new + end + + def create + if (user = User.find_by(email: reset_request_params[:email])) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "If that email address has an account, reset instructions are on the way." + end + + def edit + end + + def update + # has_secure_password drops a blank assignment and the length rule allows nil, so an + # empty submission saves cleanly and announces a reset that never happened. + if new_password_params[:password].blank? + @user.errors.add(:password, :blank) + + return render :edit, status: :unprocessable_content + end + + if @user.update(new_password_params) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + # Re-rendering puts each error under the field that caused it. A single "passwords + # did not match" for every failure hides a broken length rule. + render :edit, status: :unprocessable_content + end + end + + private + def reset_request_params + params.expect(password_reset: [ :email ]) + end + + def new_password_params + params.expect(password_reset: [ :password, :password_confirmation ]) + end + + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired.", status: :see_other + end +end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb new file mode 100644 index 000000000..65f8f23ee --- /dev/null +++ b/app/controllers/profiles_controller.rb @@ -0,0 +1,38 @@ +class ProfilesController < ApplicationController + before_action :set_profile + + def show + end + + def edit + end + + def update + if @user.update(profile_params) + revoke_other_sessions_for @user + redirect_to profile_path, notice: "Your profile was updated." + else + render :edit, status: :unprocessable_content + end + end + + def destroy + if @user.destroy + terminate_session + redirect_to new_session_path, notice: "Your account has been deleted.", status: :see_other + else + redirect_to profile_path, alert: @user.errors.full_messages.to_sentence, status: :see_other + end + end + + private + def set_profile + @user = Current.user + end + + def profile_params + without_untouched_fields params.expect( + user: [ :full_name, :email, :password, :password_confirmation, :avatar_image, :remove_avatar_image ] + ) + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 000000000..e08c60be4 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,29 @@ +class RegistrationsController < ApplicationController + allow_unauthenticated_access + before_action :redirect_if_authenticated, only: :new + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_registration_path, alert: "Too many attempts. Try again later.", status: :see_other } + + def new + @user = User.new + end + + def create + @user = User.new(registration_params) + + if @user.save + start_new_session_for @user + # Not after_authentication_url: a visitor who was bounced off /admin has that URL + # stored, and honouring it here answers a new account with "not authorised". + session.delete(:return_to_after_authenticating) + redirect_to home_url_for(@user), notice: "Welcome to Umanni." + else + render :new, status: :unprocessable_content + end + end + + private + def registration_params + params.expect(user: [ :full_name, :email, :password, :password_confirmation ]) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..b8cfcc0ed --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,34 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + before_action :redirect_if_authenticated, only: :new + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to new_session_path, alert: "Too many attempts. Try again later.", status: :see_other } + + def new + end + + def create + if (user = authenticate) + start_new_session_for user + redirect_to after_authentication_url + else + flash.now[:alert] = "Try another email address or password." + render :new, status: :unprocessable_content + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end + + private + # authenticate_by raises when a key is missing, so a half-filled form has to be a + # failed login rather than a 500. + def authenticate + credentials = params.expect(session: [ :email, :password ]) + return if credentials[:email].blank? || credentials[:password].blank? + + User.authenticate_by(credentials) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 000000000..a060ae316 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,23 @@ +module ApplicationHelper + IMPORT_STATUS_BADGES = { + "pending" => "badge-neutral", "processing" => "badge-info", + "completed" => "badge-success", "failed" => "badge-danger" + }.freeze + + def import_status_badge(spreadsheet_import) + IMPORT_STATUS_BADGES.fetch(spreadsheet_import.status, "badge-neutral") + end + + def field_error(model, attribute) + messages = model.errors.full_messages_for(attribute) + return if messages.empty? + + tag.p messages.to_sentence, class: "field-error", id: "#{attribute}_error" + end + + def field_error_attributes(model, attribute) + return {} if model.errors[attribute].empty? + + { aria: { invalid: true, describedby: "#{attribute}_error" } } + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 000000000..0d7b49404 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 000000000..1213e85c7 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/image_preview_controller.js b/app/javascript/controllers/image_preview_controller.js new file mode 100644 index 000000000..150726ae7 --- /dev/null +++ b/app/javascript/controllers/image_preview_controller.js @@ -0,0 +1,35 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "preview", "current"] + + show() { + const [file] = this.inputTarget.files + this.#releaseUrl() + + if (!file) return this.#showCurrent() + + this.url = URL.createObjectURL(file) + this.previewTarget.src = this.url + this.previewTarget.hidden = false + this.currentTarget.hidden = true + } + + // Turbo restores cached pages, so a revoked object URL would come back as a broken + // image unless the preview is torn down here too. + disconnect() { + this.#releaseUrl() + this.#showCurrent() + } + + #showCurrent() { + this.previewTarget.hidden = true + this.previewTarget.removeAttribute("src") + this.currentTarget.hidden = false + } + + #releaseUrl() { + if (this.url) URL.revokeObjectURL(this.url) + this.url = null + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 000000000..1156bf836 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/spreadsheet_import_job.rb b/app/jobs/spreadsheet_import_job.rb new file mode 100644 index 000000000..3ec353055 --- /dev/null +++ b/app/jobs/spreadsheet_import_job.rb @@ -0,0 +1,131 @@ +class SpreadsheetImportJob < ApplicationJob + include ActiveJob::Continuable + include ActionView::RecordIdentifier + + BROADCAST_EVERY = 10 + DASHBOARD_EVERY = 50 + # Every rejection rewrites the whole JSON column and rides along in the next + # broadcast, so an all-bad file would grow both without bound. The count keeps + # counting past this; only the listed reasons stop. + MAX_ROW_ERRORS = 200 + + def perform(spreadsheet_import) + @import = spreadsheet_import + + step :prepare do + with_reader { |reader| start_import(reader.row_count) } + end + + step :import_rows do |step| + with_reader { |reader| import_rows(reader, step) } + end + + step :finish do + persist(status: :completed) + broadcast_progress + broadcast_dashboard + end + # Continuation::Interrupt is an Exception, so it passes through this rescue. + rescue StandardError => error + @import.update(status: :failed, failure_reason: "#{error.class}: #{error.message}".truncate(500)) + broadcast_progress + broadcast_dashboard + raise + end + + private + def start_import(row_count) + @import.update!( + status: :processing, total_rows: row_count, + processed_rows: 0, failed_rows: 0, row_errors: [], failure_reason: nil + ) + broadcast_progress + end + + def import_rows(reader, step) + @row_errors = @import.row_errors.dup + + # Turbo's refresh debounce restarts on every write, so a bulk job outruns it and + # broadcasts nothing until it stops. Suppress it and pace the refresh here. + User.suppressing_turbo_broadcasts do + reader.each_row do |attributes, line| + index = line - (SpreadsheetImport::RowReader::HEADER_ROW + 1) + next if index < step.cursor.to_i + + record_row(attributes, line) + step.set!(index + 1) + broadcast_batch(index + 1) + end + end + + # No broadcast: :finish sends one straight after, and two a millisecond apart can + # arrive out of order, leaving the page stuck on "Processing". + persist + end + + def broadcast_batch(processed) + if (processed % BROADCAST_EVERY).zero? + persist + broadcast_progress + end + + broadcast_dashboard if (processed % DASHBOARD_EVERY).zero? + end + + def record_row(attributes, line) + # A placeholder nobody authenticates with, replaced at the first password reset. + # The default cost buys nothing against 140 bits of entropy and costs 250ms a row. + user = User.new(attributes.merge(password_digest: placeholder_digest)) + user.role = :user unless User.roles.key?(attributes[:role]) + + if user.save + SpreadsheetImport.update_counters(@import.id, processed_rows: 1) + else + reject_row(line, user.errors.full_messages.to_sentence) + end + rescue ActiveRecord::RecordNotUnique + # Validation checks uniqueness, then another writer wins the race to the index. + reject_row(line, "Email has already been taken") + end + + def placeholder_digest + BCrypt::Password.create(SecureRandom.base58(24), cost: BCrypt::Engine::MIN_COST) + end + + def reject_row(line, message) + SpreadsheetImport.update_counters(@import.id, processed_rows: 1, failed_rows: 1) + return if @row_errors.size >= MAX_ROW_ERRORS + + @row_errors << { "line" => line, "message" => message } + # An interruption before the next batch would take the reason with it. + persist + end + + def persist(status: nil) + @import.reload + @import.update!({ row_errors: @row_errors || @import.row_errors, status: status }.compact) + end + + def with_reader + @import.file.open do |file| + yield SpreadsheetImport::RowReader.new(file.path, extension: file_extension) + end + end + + def file_extension + @import.file.filename.extension_without_delimiter.downcase + end + + def broadcast_dashboard + Turbo::StreamsChannel.broadcast_refresh_to(User::DASHBOARD_STREAM) + end + + def broadcast_progress + @import.broadcast_replace_to( + @import, + target: dom_id(@import, :progress), + partial: "admin/spreadsheet_imports/progress", + locals: { spreadsheet_import: @import } + ) + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 000000000..52601165a --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: ENV.fetch("MAIL_FROM", "no-reply@umanni.test") + layout "mailer" +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 000000000..06ac4a4da --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 000000000..b63caeb8a --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..2bef56dad --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/pagination.rb b/app/models/pagination.rb new file mode 100644 index 000000000..dff2d4307 --- /dev/null +++ b/app/models/pagination.rb @@ -0,0 +1,31 @@ +# Offset pagination without a gem and without a COUNT query: one extra row is fetched +# beyond the page, and its presence is what answers "is there a next page". +class Pagination + PER_PAGE = 25 + # An OFFSET beyond this is a malformed request, not a page someone wants. + LAST_PAGE = 1_000_000 + + attr_reader :number + + def initialize(scope, page:, per_page: PER_PAGE) + @scope = scope + @number = Integer(page.to_s, exception: false)&.clamp(1, LAST_PAGE) || 1 + @per_page = per_page + end + + def records + @records ||= @scope.offset((number - 1) * @per_page).limit(@per_page + 1).to_a + end + + def visible + records.first(@per_page) + end + + def next? + records.size > @per_page + end + + def previous? + number > 1 + end +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..cf376fb28 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,3 @@ +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/spreadsheet_import.rb b/app/models/spreadsheet_import.rb new file mode 100644 index 000000000..acddf4edf --- /dev/null +++ b/app/models/spreadsheet_import.rb @@ -0,0 +1,47 @@ +class SpreadsheetImport < ApplicationRecord + # Validated by extension rather than by content type: Marcel reports a CSV as + # text/plain, and Roo picks its parser from the extension anyway. + ALLOWED_EXTENSIONS = %w[ csv xlsx ].freeze + MAX_FILE_SIZE = 5.megabytes + + belongs_to :user + has_one_attached :file + + enum :status, + { pending: "pending", processing: "processing", completed: "completed", failed: "failed" }, + default: :pending, validate: true + + # On create only: the file is attached once and never replaced, and re-running this + # on every update would block the job from recording a failure on an unreadable file + # — the exact moment the status matters most. + validate :file_must_be_a_spreadsheet, on: :create + + scope :recent_first, -> { order(created_at: :desc) } + + def progress + return 0 if total_rows.zero? + + processed_rows * 100 / total_rows + end + + def imported_rows + processed_rows - failed_rows + end + + def unlisted_failures + failed_rows - row_errors.size + end + + private + def file_must_be_a_spreadsheet + return errors.add(:file, "must be attached") unless file.attached? + + unless file.filename.extension_without_delimiter.downcase.in?(ALLOWED_EXTENSIONS) + errors.add(:file, "must be a .csv or .xlsx file") + end + + errors.add(:file, "is empty") if file.byte_size.zero? + + errors.add(:file, "must be under #{MAX_FILE_SIZE / 1.megabyte} MB") if file.byte_size > MAX_FILE_SIZE + end +end diff --git a/app/models/spreadsheet_import/row_reader.rb b/app/models/spreadsheet_import/row_reader.rb new file mode 100644 index 000000000..003621d41 --- /dev/null +++ b/app/models/spreadsheet_import/row_reader.rb @@ -0,0 +1,48 @@ +class SpreadsheetImport + # Roo picks its parser from the extension, and an Active Storage file has no useful + # suffix. Read by index because `each_row_streaming` is xlsx-only. + class RowReader + COLUMNS = %i[ full_name email role ].freeze + HEADER_ROW = 1 + + def initialize(path, extension:) + @sheet = Roo::Spreadsheet.open(path.to_s, extension: extension.to_sym) + end + + # Counted by walking the rows rather than trusting last_row: spreadsheets saved from + # Excel routinely carry trailing empty rows, and counting those makes every one of + # them a phantom rejected row and the progress bar wrong. + def row_count + count = 0 + each_row { count += 1 } + count + end + + def each_row + header = normalized_header + # A file exported with ";" separators parses as one column, and one without an + # email column imports nothing. Either would otherwise report a successful 0 of 0. + unless header.include?(:email) + raise ArgumentError, "the file has no `email` column (found: #{header.join(", ")})" + end + + ((HEADER_ROW + 1)..@sheet.last_row.to_i).each do |line| + attributes = attributes_from(header, @sheet.row(line)) + next if attributes.values.all?(&:blank?) + + yield attributes, line + end + end + + private + def normalized_header + @sheet.row(HEADER_ROW).map { |cell| cell.to_s.strip.downcase.tr(" ", "_").to_sym } + end + + def attributes_from(header, cells) + attributes = header.zip(cells.map { |cell| cell.to_s.strip.presence }).to_h.slice(*COLUMNS) + attributes[:role] = attributes[:role]&.downcase + attributes + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..ef060fa10 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,114 @@ +class User < ApplicationRecord + # No SVG: a stored SVG is a stored script, served from this origin. + AVATAR_CONTENT_TYPES = %w[ image/png image/jpeg image/webp ].freeze + AVATAR_MAX_SIZE = 2.megabytes + + DASHBOARD_STREAM = "dashboard".freeze + + has_secure_password + has_many :sessions, dependent: :destroy + has_many :spreadsheet_imports, dependent: :destroy + has_one_attached :avatar_image + attr_accessor :remove_avatar_image + + # Deterministic so the column stays uniquely indexable and findable by exact value. + # The cost is that LIKE on email is impossible; full_name stays in plaintext. + encrypts :email, deterministic: true + + enum :role, { user: "user", admin: "admin" }, default: :user, validate: true + + # The macro calls `send` on the record for anything that does not respond to `call`, + # so a bare `:dashboard` would go looking for User#dashboard. + broadcasts_refreshes_to ->(_user) { DASHBOARD_STREAM } + + normalizes :email, with: ->(email) { email.strip.downcase } + normalizes :full_name, with: ->(name) { name.strip } + + validates :full_name, presence: true, length: { maximum: 120 } + validates :email, presence: true, uniqueness: true, + format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true } + validates :password, length: { minimum: 8 }, allow_nil: true + validate :avatar_image_must_be_a_supported_image + + after_save :purge_avatar_image_if_requested + # Enforced here because three routes can strip an admin: the role toggle, the admin + # edit form, and a user deleting their own profile. + validate :last_admin_keeps_the_role, on: :update + # prepend so the check runs before the dependent: :destroy associations do their work. + before_destroy :last_admin_is_not_deletable, prepend: true + + scope :ordered, -> { order(:full_name, :id) } + scope :matching, ->(term) { + term = term.to_s.strip + if term.include?("@") + where(email: term.downcase) + else + # sanitize_sql_like escapes with a backslash, which means nothing to LIKE unless + # the clause names it: without ESCAPE, a search for "100%" looks for a literal + # backslash and finds nobody. + where("full_name LIKE ? ESCAPE '\\'", "%#{sanitize_sql_like(term)}%") + end + } + + def initials + full_name.to_s.split.first(2).filter_map { |part| part[0] }.join.upcase + end + + private + # Check-then-act. Safe only because the SQLite adapter opens with BEGIN IMMEDIATE; + # PostgreSQL would need a lock. + def another_admin_exists? + self.class.admin.where.not(id: id).exists? + end + + def last_admin_keeps_the_role + return unless role_changed?(from: "admin") + return if another_admin_exists? + + errors.add(:role, "cannot change: this is the only admin left") + end + + def last_admin_is_not_deletable + # An unsaved change to the role must not decide whether the record can go. + return unless role_in_database == "admin" + return if another_admin_exists? + + errors.add(:base, "The only admin cannot be deleted.") + throw :abort + end + + def purge_avatar_image_if_requested + avatar_image.purge if remove_avatar_image == "1" && avatar_image.attached? + end + + def avatar_image_must_be_a_supported_image + return unless avatar_image.attached? + + errors.add(:avatar_image, "is empty") if avatar_image.byte_size.zero? + if avatar_image.byte_size > AVATAR_MAX_SIZE + errors.add(:avatar_image, "must be under #{AVATAR_MAX_SIZE / 1.megabyte} MB") + end + return if uploaded_avatar_type.in?(AVATAR_CONTENT_TYPES) + + errors.add(:avatar_image, "must be a PNG, JPEG or WebP image") + end + + # Read from the bytes, not from the name. Marcel trusts the extension when that is + # all it is given, so a .txt renamed .png arrives declaring itself an image. + def uploaded_avatar_type + io = avatar_upload_io + return avatar_image.content_type unless io + + Marcel::MimeType.for(io) + ensure + # Sniffing leaves the stream at EOF, and it belongs to whoever handed it over. + io.rewind if io.respond_to?(:rewind) + end + + def avatar_upload_io + attachable = attachment_changes["avatar_image"]&.attachable + return attachable[:io] if attachable.is_a?(Hash) + + attachable if attachable.respond_to?(:read) + end +end diff --git a/app/views/admin/dashboards/_metrics.html.erb b/app/views/admin/dashboards/_metrics.html.erb new file mode 100644 index 000000000..0901405d1 --- /dev/null +++ b/app/views/admin/dashboards/_metrics.html.erb @@ -0,0 +1,18 @@ +
+
+

Total users

+

<%= total %>

+
+ + <% User.roles.each_key do |role| %> +
+

+ "> + <%= role.pluralize.capitalize %> +

+

+ <%= counts_by_role.fetch(role, 0) %> +

+
+ <% end %> +
diff --git a/app/views/admin/dashboards/show.html.erb b/app/views/admin/dashboards/show.html.erb new file mode 100644 index 000000000..61bc1b123 --- /dev/null +++ b/app/views/admin/dashboards/show.html.erb @@ -0,0 +1,17 @@ +<% content_for :title, "Dashboard" %> + +<% turbo_refreshes_with method: :morph, scroll: :preserve %> +<%= turbo_stream_from User::DASHBOARD_STREAM %> + +
+
+

Dashboard

+

Counts update as users are created, changed or removed.

+
+ + <%= link_to "Manage users", admin_users_path, class: "btn-primary" %> +
+ +
+ <%= render "metrics", total: @total, counts_by_role: @counts_by_role %> +
diff --git a/app/views/admin/spreadsheet_imports/_progress.html.erb b/app/views/admin/spreadsheet_imports/_progress.html.erb new file mode 100644 index 000000000..39927932e --- /dev/null +++ b/app/views/admin/spreadsheet_imports/_progress.html.erb @@ -0,0 +1,85 @@ +
+
+
+ + <%= spreadsheet_import.status.capitalize %> + +

+ <%= spreadsheet_import.file.filename %> +

+
+ +

+ <%= spreadsheet_import.processed_rows %> of <%= spreadsheet_import.total_rows %> rows +

+
+ +
+
" + style="width: <%= spreadsheet_import.progress %>%">
+
+ +
+
+
Imported
+
<%= spreadsheet_import.imported_rows %>
+
+
+
Rejected
+
<%= spreadsheet_import.failed_rows %>
+
+
+
Total
+
<%= spreadsheet_import.total_rows %>
+
+
+ + <% if spreadsheet_import.pending? || spreadsheet_import.processing? %> +

This page updates itself while the import runs.

+ <% elsif spreadsheet_import.imported_rows.positive? %> +

+ Imported people have no password yet. They set one through + <%= link_to "the password reset flow", new_password_path, class: "text-brand-600 hover:text-brand-700" %>. +

+ <% end %> + + <% if spreadsheet_import.failure_reason.present? %> +
+

The file could not be read

+

+ Check that it opens in a spreadsheet application and has a header row. +

+
+ What the parser reported +

<%= spreadsheet_import.failure_reason %>

+
+
+ <% end %> + + <% if spreadsheet_import.row_errors.any? %> +
+

Rows that could not be imported

+ <% if spreadsheet_import.unlisted_failures.positive? %> +

+ Showing the first <%= spreadsheet_import.row_errors.size %> + of <%= spreadsheet_import.failed_rows %>. +

+ <% end %> +
    + <% spreadsheet_import.row_errors.each do |row_error| %> +
  • + Line <%= row_error["line"] %> + — <%= row_error["message"] %> +
  • + <% end %> +
+
+ <% end %> +
diff --git a/app/views/admin/spreadsheet_imports/index.html.erb b/app/views/admin/spreadsheet_imports/index.html.erb new file mode 100644 index 000000000..cdc7e9576 --- /dev/null +++ b/app/views/admin/spreadsheet_imports/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :title, "Imports" %> + +
+
+

Imports

+

Spreadsheets uploaded to create users.

+
+ + <%= link_to "New import", new_admin_spreadsheet_import_path, class: "btn-primary" %> +
+ +
+ + + + + + + + + + + + + <% if @spreadsheet_imports.any? %> + <% @spreadsheet_imports.each do |spreadsheet_import| %> + + + + + + + + <% end %> + <% else %> + + + + <% end %> + +
+ <%= link_to spreadsheet_import.file.filename, + admin_spreadsheet_import_path(spreadsheet_import), + class: "block max-w-64 truncate font-medium text-brand-600 hover:text-brand-700 md:max-w-48" %> + + + <%= spreadsheet_import.status.capitalize %> + + + <%= spreadsheet_import.imported_rows %> imported<%= ", #{spreadsheet_import.failed_rows} rejected" if spreadsheet_import.failed_rows.positive? %> +
+ No imports yet. +
+
diff --git a/app/views/admin/spreadsheet_imports/new.html.erb b/app/views/admin/spreadsheet_imports/new.html.erb new file mode 100644 index 000000000..1d527041c --- /dev/null +++ b/app/views/admin/spreadsheet_imports/new.html.erb @@ -0,0 +1,38 @@ +<% content_for :title, "Import users" %> + +
+

Import users

+

+ A .csv or .xlsx file with a header row. Processing happens in the background. +

+ +
+ <%= render "shared/form_errors", model: @spreadsheet_import %> + + <%= form_with model: @spreadsheet_import, url: admin_spreadsheet_imports_path, class: "space-y-5" do |form| %> +
+ <%= form.label :file, "Spreadsheet", class: "field-label" %> + <%= form.file_field :file, required: true, accept: ".csv,.xlsx", + class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 + file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %> + <%= field_error @spreadsheet_import, :file %> +

Up to 5 MB.

+
+ +
+

Expected columns

+

+ full_name, email, and optionally role. + Anything other than admin + is imported as a plain user. Rows that fail validation are reported and skipped; + the rest of the file still imports. +

+
+ +
+ <%= form.submit "Start import", class: "btn-primary", data: { turbo_submits_with: "Uploading…" } %> + <%= link_to "Cancel", admin_spreadsheet_imports_path, class: "btn-secondary" %> +
+ <% end %> +
+
diff --git a/app/views/admin/spreadsheet_imports/show.html.erb b/app/views/admin/spreadsheet_imports/show.html.erb new file mode 100644 index 000000000..7dc4450ba --- /dev/null +++ b/app/views/admin/spreadsheet_imports/show.html.erb @@ -0,0 +1,25 @@ +<% content_for :title, "Import" %> + +<%= turbo_stream_from @spreadsheet_import %> + +
+
+
+

Import

+

+ Started <%= time_ago_in_words(@spreadsheet_import.created_at) %> ago by + <%= @spreadsheet_import.user.full_name %>. +

+
+ +
+ <%= link_to "All imports", admin_spreadsheet_imports_path, class: "btn-secondary" %> + <%= link_to "New import", new_admin_spreadsheet_import_path, class: "btn-primary" %> +
+
+ +
+ <%= render "progress", spreadsheet_import: @spreadsheet_import %> +
+ +
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb new file mode 100644 index 000000000..067031f82 --- /dev/null +++ b/app/views/admin/users/_form.html.erb @@ -0,0 +1,70 @@ +<%= form_with model: user, url: url, class: "space-y-5" do |form| %> + <%= render "shared/form_errors", model: user %> + +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, **field_error_attributes(user, :full_name), required: true, autofocus: true, autocomplete: "name", maxlength: 120, + placeholder: "Ada Lovelace", class: "field-input" %> + <%= field_error user, :full_name %> +
+ +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, **field_error_attributes(user, :email), required: true, autocomplete: "username", placeholder: "ada@company.com", class: "field-input" %> + <%= field_error user, :email %> +
+ + <% unless user == Current.user %> +
+ <%= form.label :role, class: "field-label" %> + <%= form.select :role, User.roles.keys.map { |role| [ role.capitalize, role ] }, {}, class: "field-input" %> + <%= field_error user, :role %> +
+ <% end %> + +
+ <%= form.label :avatar_image, "Avatar", class: "field-label" %> +
+ + + <%= render "shared/avatar", user: user, size: "size-14" %> + + <%= form.file_field :avatar_image, accept: User::AVATAR_CONTENT_TYPES.join(","), + data: { image_preview_target: "input", action: "image-preview#show" }, + class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 + file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700", + **field_error_attributes(user, :avatar_image) %> +
+ <%= field_error user, :avatar_image %> +

PNG, JPEG or WebP, up to 2 MB.

+ <% if user.avatar_image.attachment&.persisted? %> + + <% end %> +
+ +
+
+ <%= form.label :password, user.persisted? ? "New password" : "Password", class: "field-label" %> + <%= form.password_field :password, **field_error_attributes(user, :password), autocomplete: "new-password", minlength: 8, maxlength: 72, + placeholder: "Your password", class: "field-input" %> + <%= field_error user, :password %> +

<%= user.persisted? ? "Leave blank to keep the current password." : "At least 8 characters." %>

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, **field_error_attributes(user, :password_confirmation), autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "Your password", class: "field-input" %> + <%= field_error user, :password_confirmation %> +
+
+ +
+ <%= form.submit submit_label, class: "btn-primary", data: { turbo_submits_with: "Saving…" } %> + <%= link_to "Cancel", admin_users_path, class: "btn-secondary" %> +
+<% end %> diff --git a/app/views/admin/users/_user.html.erb b/app/views/admin/users/_user.html.erb new file mode 100644 index 000000000..ca9051e67 --- /dev/null +++ b/app/views/admin/users/_user.html.erb @@ -0,0 +1,39 @@ + + +
+ <%= render "shared/avatar", user: user %> +
+

<%= user.full_name %>

+

<%= user.email %>

+
+
+ + + + "><%= user.role.capitalize %> + + + + <%= user.created_at.to_date.to_fs(:long) %> + + + +
+ <% if user == Current.user %> + You + <% else %> + <%= button_to admin_user_role_path(user), method: :patch, class: "btn-secondary min-w-24 text-xs whitespace-nowrap" do %> + Make <%= user.admin? ? "user" : "admin" %> + <% end %> + <% end %> + + <%= link_to "Edit", edit_admin_user_path(user), class: "btn-secondary text-xs whitespace-nowrap" %> + + <% unless user == Current.user %> + <%= button_to "Delete", admin_user_path(user), method: :delete, + class: "btn-danger text-xs whitespace-nowrap", + form: { data: { turbo_confirm: "Delete #{user.full_name}? This cannot be undone." } } %> + <% end %> +
+ + diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 000000000..0264ac121 --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,9 @@ +<% content_for :title, "Edit #{@user.full_name}" %> + +
+

Edit <%= @user.full_name %>

+ +
+ <%= render "admin/users/form", user: @user, url: admin_user_path(@user), submit_label: "Save changes" %> +
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 000000000..50a19f5d3 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,58 @@ +<% content_for :title, "Users" %> + +
+
+

Users

+

Create, edit and remove accounts.

+
+ + <%= link_to "New user", new_admin_user_path, class: "btn-primary" %> +
+ +<%= form_with url: admin_users_path, method: :get, class: "mt-6 flex flex-wrap items-end gap-x-3 gap-y-2" do |form| %> +
+ <%= form.label :query, "Search by name", class: "field-label" %> + <%= form.search_field :query, value: params[:query], placeholder: "Ada", class: "field-input" %> +
+ +
+ <%= form.label :role, class: "field-label" %> + <%= form.select :role, + options_for_select(User.roles.keys.map { |role| [ role.capitalize, role ] }, params[:role]), + { include_blank: "All roles" }, class: "field-input" %> +
+ +
+ <%= form.submit "Filter", name: nil, class: "btn-secondary grow leading-6 sm:grow-0" %> + <% if params[:query].present? || params[:role].present? %> + <%= link_to "Clear", admin_users_path, class: "text-sm text-slate-500 hover:text-slate-700" %> + <% end %> +
+<% end %> + +
+ + + + + + + + + + + + <% if @page.visible.any? %> + <%= render partial: "admin/users/user", collection: @page.visible, as: :user %> + <% else %> + + + + <% end %> + +
+ No users match that filter. +
+
+ +<%= render "shared/pagination", page: @page, filters: @filters %> diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 000000000..f8e3ed15c --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,9 @@ +<% content_for :title, "New user" %> + +
+

New user

+ +
+ <%= render "admin/users/form", user: @user, url: admin_users_path, submit_label: "Create user" %> +
+
diff --git a/app/views/admin/users/roles/update.turbo_stream.erb b/app/views/admin/users/roles/update.turbo_stream.erb new file mode 100644 index 000000000..926a036a6 --- /dev/null +++ b/app/views/admin/users/roles/update.turbo_stream.erb @@ -0,0 +1,7 @@ +<%= turbo_stream.replace dom_id(@user) do %> + <%= render "admin/users/user", user: @user %> +<% end %> + +<%= turbo_stream.replace "flash" do %> + <%= render "shared/flash" %> +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 000000000..784a82c33 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,33 @@ + + + + <%= content_for(:title) || "Umanni" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + + + + + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= render "shared/navbar" if authenticated? %> + +
"> +
"> + <%= render "shared/flash" %> + <%= yield %> +
+
+ + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 000000000..3aac9002e --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 000000000..37f0bddbd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 000000000..6532de552 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,29 @@ +<% content_for :title, "Choose a new password" %> + +
+
+

Choose a new password

+ + <%= form_with url: password_path(params[:token]), method: :put, scope: :password_reset, class: "mt-6 space-y-5" do |form| %> + <%= text_field_tag :email, @user.email, autocomplete: "username", readonly: true, + hidden: true, tabindex: -1, "aria-hidden": true, id: nil %> + +
+ <%= form.label :password, "New password", class: "field-label" %> + <%= form.password_field :password, **field_error_attributes(@user, :password), required: true, autofocus: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "Your password", class: "field-input" %> + <%= field_error @user, :password %> +

At least 8 characters.

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, **field_error_attributes(@user, :password_confirmation), required: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "Your password", class: "field-input" %> + <%= field_error @user, :password_confirmation %> +
+ + <%= form.submit "Save password", class: "btn-primary w-full", data: { turbo_submits_with: "Saving…" } %> + <% end %> +
+
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 000000000..37844d15f --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,23 @@ +<% content_for :title, "Reset your password" %> + +
+
+

Reset your password

+

We will email you a link to choose a new one.

+ + <%= form_with url: passwords_path, scope: :password_reset, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", + placeholder: "you@company.com", class: "field-input" %> +
+ + <%= form.submit "Email reset instructions", class: "btn-primary w-full", + data: { turbo_submits_with: "Sending…" } %> + <% end %> + +

+ <%= link_to "Back to sign in", new_session_path, class: "text-slate-500 hover:text-slate-700" %> +

+
+
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 000000000..1b0915419 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,6 @@ +

+ You can reset your password on + <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + + This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 000000000..aecee82c4 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,4 @@ +You can reset your password on +<%= edit_password_url(@user.password_reset_token) %> + +This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb new file mode 100644 index 000000000..c7b8ad117 --- /dev/null +++ b/app/views/profiles/edit.html.erb @@ -0,0 +1,69 @@ +<% content_for :title, "Edit your profile" %> + +
+

Edit your profile

+ +
+ <%= render "shared/form_errors", model: @user %> + + <%= form_with model: @user, url: profile_path, method: :patch, class: "space-y-5" do |form| %> +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, **field_error_attributes(@user, :full_name), required: true, autofocus: true, autocomplete: "name", maxlength: 120, class: "field-input" %> + <%= field_error @user, :full_name %> +
+ +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, **field_error_attributes(@user, :email), required: true, autocomplete: "username", class: "field-input" %> + <%= field_error @user, :email %> +
+ +
+ <%= form.label :avatar_image, "Avatar", class: "field-label" %> +
+ + + <%= render "shared/avatar", user: @user, size: "size-14" %> + + <%= form.file_field :avatar_image, **field_error_attributes(@user, :avatar_image), + accept: User::AVATAR_CONTENT_TYPES.join(","), + data: { image_preview_target: "input", action: "image-preview#show" }, + class: "field-input file:mr-3 file:rounded-md file:border-0 file:bg-slate-100 + file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-slate-700" %> +
+ <%= field_error @user, :avatar_image %> +

PNG, JPEG or WebP, up to 2 MB.

+ <% if @user.avatar_image.attachment&.persisted? %> + + <% end %> +
+ +
+
+ <%= form.label :password, "New password", class: "field-label" %> + <%= form.password_field :password, **field_error_attributes(@user, :password), autocomplete: "new-password", minlength: 8, maxlength: 72, + placeholder: "Your password", class: "field-input" %> + <%= field_error @user, :password %> +

Leave blank to keep the current password.

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, **field_error_attributes(@user, :password_confirmation), autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "Your password", class: "field-input" %> + <%= field_error @user, :password_confirmation %> +
+
+ +
+ <%= form.submit "Save changes", class: "btn-primary", data: { turbo_submits_with: "Saving…" } %> + <%= link_to "Cancel", profile_path, class: "btn-secondary" %> +
+ <% end %> +
+
diff --git a/app/views/profiles/show.html.erb b/app/views/profiles/show.html.erb new file mode 100644 index 000000000..d5a078dee --- /dev/null +++ b/app/views/profiles/show.html.erb @@ -0,0 +1,43 @@ +<% content_for :title, "Your profile" %> + +
+
+

Your profile

+ <%= link_to "Edit profile", edit_profile_path, class: "btn-primary" %> +
+ +
+
+ <%= render "shared/avatar", user: @user, size: "size-16" %> +
+

<%= @user.full_name %>

+

<%= @user.email %>

+
+
+ +
+
+
Role
+
"><%= @user.role.capitalize %>
+
+ +
+
Member since
+
<%= @user.created_at.to_date.to_fs(:long) %>
+
+
+
+ +
+
+
+

Delete your account

+

This removes your profile and signs you out. It cannot be undone.

+
+ + <%= button_to "Delete account", profile_path, method: :delete, + class: "btn-danger", + form: { data: { turbo_confirm: "Delete your account? This cannot be undone." } } %> +
+
+
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 000000000..d75a8673d --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,48 @@ +<% content_for :title, "Create your account" %> + +
+
+

Create your account

+

Takes less than a minute.

+ + <%= render "shared/form_errors", model: @user %> + + <%= form_with model: @user, url: registration_path, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :full_name, "Full name", class: "field-label" %> + <%= form.text_field :full_name, **field_error_attributes(@user, :full_name), required: true, autofocus: true, autocomplete: "name", + maxlength: 120, placeholder: "Ada Lovelace", class: "field-input" %> + <%= field_error @user, :full_name %> +
+ +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, **field_error_attributes(@user, :email), required: true, autocomplete: "email", + placeholder: "you@company.com", class: "field-input" %> + <%= field_error @user, :email %> +
+ +
+ <%= form.label :password, class: "field-label" %> + <%= form.password_field :password, **field_error_attributes(@user, :password), required: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "Your password", class: "field-input" %> + <%= field_error @user, :password %> +

At least 8 characters.

+
+ +
+ <%= form.label :password_confirmation, "Confirm password", class: "field-label" %> + <%= form.password_field :password_confirmation, **field_error_attributes(@user, :password_confirmation), required: true, autocomplete: "new-password", + minlength: 8, maxlength: 72, placeholder: "Your password", class: "field-input" %> + <%= field_error @user, :password_confirmation %> +
+ + <%= form.submit "Create account", class: "btn-primary w-full", data: { turbo_submits_with: "Creating…" } %> + <% end %> + +

+ Already have an account? + <%= link_to "Sign in", new_session_path, class: "font-medium text-brand-600 hover:text-brand-700" %> +

+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 000000000..42878d550 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,29 @@ +<% content_for :title, "Sign in" %> + +
+
+

Sign in

+

Welcome back.

+ + <%= form_with url: session_path, scope: :session, class: "mt-6 space-y-5" do |form| %> +
+ <%= form.label :email, class: "field-label" %> + <%= form.email_field :email, required: true, autofocus: true, autocomplete: "username", + value: params.dig(:session, :email), placeholder: "you@company.com", class: "field-input" %> +
+ +
+ <%= form.label :password, class: "field-label" %> + <%= form.password_field :password, required: true, autocomplete: "current-password", + maxlength: 72, placeholder: "Your password", class: "field-input" %> +
+ + <%= form.submit "Sign in", class: "btn-primary w-full", data: { turbo_submits_with: "Signing in…" } %> + <% end %> + +
+ <%= link_to "Create an account", new_registration_path, class: "font-medium text-brand-600 hover:text-brand-700" %> + <%= link_to "Forgot password?", new_password_path, class: "text-slate-500 hover:text-slate-700" %> +
+
+
diff --git a/app/views/shared/_avatar.html.erb b/app/views/shared/_avatar.html.erb new file mode 100644 index 000000000..f398bdf46 --- /dev/null +++ b/app/views/shared/_avatar.html.erb @@ -0,0 +1,14 @@ +<%# No variants: that would put libvips on every machine for a 40px thumbnail. %> +<% size = local_assigns.fetch(:size, "size-10") %> + +<%# Saved, not merely assigned: a failed create would ask for a URL to an id-less blob. %> +<% if user.avatar_image.attachment&.persisted? %> + <%= image_tag user.avatar_image, + alt: user.full_name, + loading: "lazy", + class: "#{size} shrink-0 rounded-full object-cover ring-1 ring-slate-200" %> +<% else %> + +<% end %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 000000000..93a1cf06b --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,12 @@ +
"> + <% flash.each do |type, message| %> + <%= tag.p message, + id: type, + role: (type == "alert" ? "alert" : "status"), + class: class_names( + "rounded-lg px-4 py-3 text-sm font-medium ring-1 ring-inset", + "bg-red-50 text-red-800 ring-red-200" => type == "alert", + "bg-emerald-50 text-emerald-800 ring-emerald-200" => type != "alert" + ) %> + <% end %> +
diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb new file mode 100644 index 000000000..224a5c3e4 --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,11 @@ +<%# Only what has no field of its own to sit under; the rest renders inline. %> +<% base_errors = model.errors[:base] %> +<% if base_errors.any? %> + +<% end %> diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb new file mode 100644 index 000000000..8f2f8e8fc --- /dev/null +++ b/app/views/shared/_navbar.html.erb @@ -0,0 +1,26 @@ +
+ +
diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb new file mode 100644 index 000000000..e5c59936b --- /dev/null +++ b/app/views/shared/_pagination.html.erb @@ -0,0 +1,17 @@ +<% if page.previous? || page.next? %> + +<% end %> diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundler-audit b/bin/bundler-audit new file mode 100755 index 000000000..e2ef22690 --- /dev/null +++ b/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/bin/ci b/bin/ci new file mode 100755 index 000000000..4137ad5bb --- /dev/null +++ b/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/bin/dev b/bin/dev new file mode 100755 index 000000000..ad72c7d53 --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 000000000..ed31659f4 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 000000000..36502ab16 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 000000000..dcf59f309 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 000000000..d9ba27670 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 000000000..5a2050471 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 000000000..81be011e8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..00d52a92b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,51 @@ +# One command to a running application: `docker compose up`. +# +# There is no database service — SQLite lives in the storage/ volume, and Solid Queue, +# Solid Cache and Solid Cable sit on their own SQLite files beside it. That is the point +# of the Rails 8 stack this test asks for: no Redis, no Postgres, no sidecar. +# +# The host port is 3200 rather than 3000 so this does not collide with a Rails server +# already running on the machine. Override with `WEB_PORT=4000 docker compose up`. +services: + web: + build: + context: . + dockerfile: Dockerfile.dev + command: bash -c "bin/rails db:prepare && bin/dev" + ports: + - "${WEB_PORT:-3200}:3000" + volumes: + - .:/rails + - bundle:/usr/local/bundle + - storage:/rails/storage + # Through the bind mount these land on the host owned by root and block a local + # `bin/dev`. Read them with `docker compose logs`. + - tmp:/rails/tmp + - log:/rails/log + environment: + RAILS_ENV: development + # The port the browser reaches, so reset links in mail point somewhere real. + WEB_PORT: "${WEB_PORT:-3200}" + # Mailpit answers on the compose network, not on this container's loopback. + SMTP_HOST: mail + healthcheck: + test: [ "CMD", "curl", "-fsS", "http://localhost:3000/up" ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 40s + + # Catches password-reset mail so the link can be clicked in development. + # Start it with: docker compose --profile mail up + mail: + image: axllent/mailpit + profiles: [ mail ] + ports: + - "1025:1025" + - "${MAIL_UI_PORT:-8025}:8025" + +volumes: + bundle: + storage: + tmp: + log: diff --git a/config.ru b/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 000000000..ea8ba6c3f --- /dev/null +++ b/config/application.rb @@ -0,0 +1,39 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Umanni + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 000000000..988a5ddc4 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/bundler-audit.yml b/config/bundler-audit.yml new file mode 100644 index 000000000..200e12b5f --- /dev/null +++ b/config/bundler-audit.yml @@ -0,0 +1,4 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: [] diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 000000000..1823b696f --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,20 @@ +# Solid Cable in development too, so the broadcast path that ships is the one used. +development: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day + +# System tests need real delivery; the :test adapter records instead of delivering. +test: + adapter: async + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 000000000..100baeb22 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,20 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +# Without this the store falls back to the primary database, where +# solid_cache_entries does not exist, and every rate-limited action raises. +development: + database: cache + <<: *default + +# The test environment uses :null_store (config/environments/test.rb), so no database. +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 000000000..a34578576 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,22 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 000000000..6347f6e5a --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +ebnFAjdaGyRecxybLOJPGslejAqY4AML2aeMBZu4jBH1vy6gRPI0yt6wITDk3zi+n3TcqCb3nRl8qzbnJxu/BWqjAqGrB7bCIliIVvOj3vUAa/14eOIampw78wDbultbe2VhEKmrJAJbAAqfUc62os5aKKTk5v55toLW0oIXZhgqc9cE0SsqB7rXxAMm8MpgqPo4BElmxOdPSLysKkCJwb7HzdjrAeJlUeppncqRxZNBj02DpleLbiHqZFVc/OuuqeA26b0eJyoNfkGsV5vTqlx6YQzk3BMZ50QIAuHGHIjUBFHJprQOupV4+BfLdV/cvnkwoX9LH0Z+SouNmbVj8ctBhQAc9UEyRcVNds2WtMf1qGsBpZdiRlv67svOG2qpZNUO9dw2TFmdweTeos+dik0NylItNgmqfhrttZ/HHwlv2lT0Ojs74qe5qV49+r3vfONRbiKfIae8diI1DGlz5TZfCR4yxfM88exabGMPKNoMLxMKZXb289gQbbHAeumfFOgJOCyBxXiXApNeYM8gBM/uXnIwzrXy0usU15SMUxUtHwvYR0Fm7xZkP5UAwkfJ55JM4lwDyj4wA5xXQs35ANWvaA3nsf0+il6OwLyFPnYF7V9rwK7dwMkpXJoYL2/1fJeYXCpYgYWcxGdirhBJMmYDgWPHvTJWXUOD6HtvM0vZpvJEtfviRQudi5sXXXzQQwuD1hKcZMASjluHKULkG7OCCJkIiCZ3xwdIPwF2NOx7eRKEeF2CmtoNh1t+meLBNiG3Aan1Vj9URg/LRc+EwRx6kYM/pX9LUjKsIMBGgz/kjGgCpbq30L5uPkwhC8U0iR0=--9SbEiWuM53RQXEa2--GZ48e7clt6EWQb2Qz74LEA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 000000000..febe86291 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,56 @@ +# Rails 8 applies these pragmas by default; they are spelled out so the production +# settings are visible without reading the adapter. +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + pragmas: + journal_mode: wal + synchronous: normal + foreign_keys: true + mmap_size: 134217728 + journal_size_limit: 67108864 + cache_size: 2000 + +# Four databases in development too, or the Solid adapters are never exercised. +development: + primary: + <<: *default + database: storage/development.sqlite3 + cache: + <<: *default + database: storage/development_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/development_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/development_cable.sqlite3 + migrations_paths: db/cable_migrate + +# Jobs run inline and cable in-process, so the test suite needs the primary only. +test: + <<: *default + database: storage/test.sqlite3 + # Twelve workers loading schema at once contend for the write lock; five seconds is + # not enough, and the failure is a BusyException in a random worker. + timeout: 30000 + +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 000000000..0df7c0ad6 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,57 @@ +# Kamal 2 deployment. +# +# Fill in the placeholders below — image owner, server IP, registry user, and the +# APP_HOST/SMTP_ADDRESS/MAIL_FROM under env/clear — and `bin/kamal setup` is the whole +# deploy. Mail is the one that fails loudly: production raises on delivery errors, so a +# password reset against smtp.example.com takes the job down with it. +# KAMAL_REGISTRY_PASSWORD and RAILS_MASTER_KEY are read from .kamal/secrets. + +service: umanni +image: your-registry-user/umanni + +servers: + web: + - 192.0.2.1 + +# TLS terminates here; assume_ssl and force_ssl are on in production.rb. +proxy: + ssl: true + host: umanni.example.com + # Thruster listens on 80 inside the container and serves compressed, cached assets. + app_port: 80 + +registry: + server: ghcr.io + username: your-registry-user + password: + - KAMAL_REGISTRY_PASSWORD + +env: + secret: + - RAILS_MASTER_KEY + clear: + # Solid Queue runs inside Puma because SQLite is a file: a worker on another host + # could not reach it. Splitting jobs off means moving to PostgreSQL. + SOLID_QUEUE_IN_PUMA: true + JOB_CONCURRENCY: 2 + # Read by config/puma.rb, and by Action Mailer for the links in a password reset. + APP_HOST: umanni.example.com + SMTP_ADDRESS: smtp.example.com + SMTP_PORT: 587 + MAIL_FROM: no-reply@umanni.example.com + +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + +# The four databases and every upload. The only stateful thing on the server. +volumes: + - "umanni_storage:/rails/storage" + +# Keeps the previous version's assets so an in-flight request does not 404. +asset_path: /rails/public/assets + +builder: + arch: amd64 diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 000000000..30244a50b --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,95 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Same adapters as production, or they are only exercised at deploy time. + config.cache_store = :solid_cache_store + + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + # Not PORT: foreman hands each process its own, and mail is delivered from the jobs + # process, so links would point at the worker's port instead of the web one. + config.action_mailer.default_url_options = { host: "localhost", port: ENV.fetch("WEB_PORT", 3000) } + + # Mail goes to a local catcher when one is running (`docker compose --profile mail up`), + # so the reset link can actually be clicked. Without it, delivery is a no-op. + config.action_mailer.delivery_method = :smtp + # Mailpit is a sibling container under Docker, so "localhost" there is this process. + config.action_mailer.smtp_settings = { address: ENV.fetch("SMTP_HOST", "localhost"), port: 1025 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! + + # Not secrets: the app has to boot for anyone who clones this. Production reads its + # own keys from credentials. + config.active_record.encryption.primary_key = "s1jnxp0gDiOTkHHvrvYJ5018UMuFpZY1" + config.active_record.encryption.deterministic_key = "tjmrQSRmWUw4nIJTuYT0eWNm85y2bn6F" + config.active_record.encryption.key_derivation_salt = "3qUI2aAmXJDZ0NhO0EYxvfJRmf0llZg9" +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 000000000..09edfb1e1 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # A password reset link is the only way an imported user ever signs in, so neither the + # host nor the delivery settings below can be left on a placeholder and still call the + # feature delivered. Host is the one Kamal terminates TLS on; address and port come + # from the environment, credentials from the encrypted file. + config.action_mailer.default_url_options = { host: ENV.fetch("APP_HOST", "example.com"), protocol: "https" } + config.action_mailer.delivery_method = :smtp + config.action_mailer.raise_delivery_errors = true + config.action_mailer.smtp_settings = { + address: ENV.fetch("SMTP_ADDRESS", "localhost"), + port: Integer(ENV.fetch("SMTP_PORT", 587)), + user_name: Rails.application.credentials.dig(:smtp, :user_name), + password: Rails.application.credentials.dig(:smtp, :password), + authentication: ENV["SMTP_ADDRESS"] ? :plain : nil, + enable_starttls_auto: ENV["SMTP_ADDRESS"].present? + }.compact + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 000000000..d51497c77 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,68 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # The limiter captures its store at class load, so :null_store makes it inert. + config.action_controller.cache_store = :memory_store + + # Jobs run inline through perform_enqueued_jobs; see test/test_helper.rb. + config.active_job.queue_adapter = :test + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Not secrets: the app has to boot for anyone who clones this. Production reads its + # own keys from credentials. + config.active_record.encryption.primary_key = "s1jnxp0gDiOTkHHvrvYJ5018UMuFpZY1" + config.active_record.encryption.deterministic_key = "tjmrQSRmWUw4nIJTuYT0eWNm85y2bn6F" + config.active_record.encryption.key_derivation_salt = "3qUI2aAmXJDZ0NhO0EYxvfJRmf0llZg9" + + # Fixtures bypass the model, so the encrypted column would hold plaintext. + config.active_record.encryption.encrypt_fixtures = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 000000000..909dfc542 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 000000000..487324424 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..102efddc0 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,24 @@ +# Everything is served from this origin, so there is no CDN to allow. `blob:` is for the +# object URLs the avatar preview creates. +Rails.application.configure do + config.content_security_policy do |policy| + policy.default_src :none + policy.base_uri :self + policy.form_action :self + policy.frame_ancestors :none + policy.connect_src :self + policy.font_src :self + policy.img_src :self, :blob + policy.object_src :none + policy.script_src :self + policy.style_src :self + # The import bar's width is a style attribute, and nonces do not apply to attributes. + policy.style_src_attr :unsafe_inline + end + + # Deriving this from the session id gives a visitor who has no session yet an empty + # nonce, which matches nothing and blocks the import map. + config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) } + # style-src too: Turbo stamps its navigation bar's + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..d7f0f1422 --- /dev/null +++ b/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 000000000..43d2811e8 --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 000000000..f12fb4aa1 --- /dev/null +++ b/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 000000000..e4eb18a75 --- /dev/null +++ b/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 000000000..c4c9dbfbb Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/script/jit_benchmark.rb b/script/jit_benchmark.rb new file mode 100644 index 000000000..c8ca6f17e --- /dev/null +++ b/script/jit_benchmark.rb @@ -0,0 +1,35 @@ +# Renders a partial under YJIT and ZJIT. Rails turns YJIT on by default, so there is no +# plain-interpreter column without disabling it. +# +# docker compose exec -e RUBYOPT=--yjit web bin/rails runner script/jit_benchmark.rb +# docker compose exec -e RUBYOPT=--zjit web bin/rails runner script/jit_benchmark.rb +# +# Against the production image instead, prefix with `db:prepare` and pass a master key +# that image can read. +# +# No benchmark gem: it stopped being a default gem in Ruby 4, and a monotonic clock +# is all this needs. + +ITERATIONS = Integer(ENV.fetch("ITERATIONS", 20_000)) +WARMUP = Integer(ENV.fetch("WARMUP", 5_000)) + +users = Array.new(50) do |index| + User.new(id: index + 1, full_name: "Benchmark Person #{index}", email: "bench#{index}@umanni.test", + role: index.even? ? "admin" : "user", created_at: Time.current) +end + +def jit_name + return "ZJIT" if defined?(RubyVM::ZJIT) && RubyVM::ZJIT.enabled? + return "YJIT" if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + + "interpreter" +end + +WARMUP.times { |i| ApplicationController.render(partial: "admin/users/user", locals: { user: users[i % 50] }) } + +started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) +ITERATIONS.times { |i| ApplicationController.render(partial: "admin/users/user", locals: { user: users[i % 50] }) } +elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + +puts format("%-12s %6.2f s for %d renders (%.3f ms each, %d warmup)", + jit_name, elapsed, ITERATIONS, elapsed * 1000 / ITERATIONS, WARMUP) diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 000000000..a879a52a0 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + # Escape hatch for machines where Chrome is not on PATH (WSL, slim containers). + Selenium::WebDriver::Chrome.path = ENV["CHROME_BINARY"] if ENV["CHROME_BINARY"].present? + + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + + # One browser and one server per worker, twelve of them on a developer machine: a Turbo + # navigation regularly takes longer than Capybara's two-second default, and the + # assertion then lands while the new body is in the DOM but not yet showing. + Capybara.default_max_wait_time = 5 + + # A blocked style fails no request, so a broken policy still passes DOM assertions. + teardown do + violations = page.driver.browser.logs.get(:browser) + .map(&:message) + .grep(/Content Security Policy/) + + assert_empty violations, "the page violated its own content security policy" + end + + private + # Shadows the generator's cookie-jar helper, which a real browser never sees. + # `click_on` returns before the request lands, so the wait is not decoration: without + # it the next `visit` arrives as an anonymous visitor. + def sign_in_as(user, password: "secret-password") + visit new_session_path + fill_in "Email", with: user.email + fill_in "Password", with: password + click_on "Sign in" + + # A bare path assertion says nothing when it fails; the page usually knows why. + return if has_no_current_path?(new_session_path) + + flunk "sign-in did not complete: #{page.text[0, 200]}" + end +end diff --git a/test/channels/application_cable/connection_test.rb b/test/channels/application_cable/connection_test.rb new file mode 100644 index 000000000..bf77f51d0 --- /dev/null +++ b/test/channels/application_cable/connection_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase + test "identifies the person behind a signed session cookie" do + cookies.signed[:session_id] = users(:admin).sessions.create!.id + + connect + + assert_equal users(:admin), connection.current_user + end + + test "refuses a visitor carrying no session" do + assert_reject_connection { connect } + end + + # The dashboard stream is admin data. An unsigned cookie is a guess at a row id. + test "refuses a session cookie this application did not sign" do + cookies[:session_id] = users(:admin).sessions.create!.id + + assert_reject_connection { connect } + end +end diff --git a/test/config/solid_stack_test.rb b/test/config/solid_stack_test.rb new file mode 100644 index 000000000..0fbf19025 --- /dev/null +++ b/test/config/solid_stack_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +# Development runs the same Solid adapters as production, which only helps if both are +# pointed at the same databases. Naming the cache database under production alone makes +# every rate-limited action raise in development, and the test null store hides it. +class SolidStackTest < ActiveSupport::TestCase + ENVIRONMENTS = %w[ development production ].freeze + + test "Solid Cache is pointed at the cache database everywhere it runs" do + ENVIRONMENTS.each do |env| + assert_equal "cache", Rails.application.config_for(:cache, env: env)[:database].to_s, + "#{env} runs Solid Cache without naming the cache database" + end + end + + test "Solid Cable is pointed at the cable database everywhere it runs" do + ENVIRONMENTS.each do |env| + config = Rails.application.config_for(:cable, env: env) + + assert_equal "solid_cable", config[:adapter].to_s, "#{env} is not using Solid Cable" + assert_equal "cable", config.dig(:connects_to, :database, :writing).to_s, + "#{env} runs Solid Cable without naming the cable database" + end + end + + test "every Solid database is declared in database.yml" do + ENVIRONMENTS.each do |env| + databases = Rails.application.config_for(:database, env: env).keys.map(&:to_s) + + assert_equal %w[ cable cache primary queue ], databases.sort, + "#{env} is missing one of the Solid databases" + end + end +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/controllers/admin/dashboards_controller_test.rb b/test/controllers/admin/dashboards_controller_test.rb new file mode 100644 index 000000000..d4e379b4b --- /dev/null +++ b/test/controllers/admin/dashboards_controller_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class Admin::DashboardsControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "shows the total and the count for each role" do + get admin_dashboard_path + + assert_response :success + assert_select "#metric-total", text: User.count.to_s + assert_select "#metric-admin", text: User.admin.count.to_s + assert_select "#metric-user", text: User.user.count.to_s + end + + test "subscribes to the dashboard stream" do + get admin_dashboard_path + + assert_select "turbo-cable-stream-source" + end + + test "shows a role with no members as zero rather than dropping the tile" do + User.user.destroy_all + + get admin_dashboard_path + + assert_select "#metric-user", text: "0" + assert_select "#metric-admin", text: User.admin.count.to_s + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + get admin_dashboard_path + + assert_redirected_to profile_url + end +end diff --git a/test/controllers/admin/spreadsheet_imports_controller_test.rb b/test/controllers/admin/spreadsheet_imports_controller_test.rb new file mode 100644 index 000000000..b66668daf --- /dev/null +++ b/test/controllers/admin/spreadsheet_imports_controller_test.rb @@ -0,0 +1,86 @@ +require "test_helper" + +class Admin::SpreadsheetImportsControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "lists imports newest first" do + older = create_import + older.update!(created_at: 1.hour.ago) + newer = create_import + + get admin_spreadsheet_imports_path + + assert_response :success + assert_operator response.body.index(admin_spreadsheet_import_path(newer)), + :<, response.body.index(admin_spreadsheet_import_path(older)) + end + + test "renders the upload form" do + get new_admin_spreadsheet_import_path + + assert_response :success + assert_select "input[type=submit][data-turbo-submits-with=?]", "Uploading…" + end + + test "queues the import and sends the admin to its progress page" do + assert_difference -> { SpreadsheetImport.count }, 1 do + assert_enqueued_with job: SpreadsheetImportJob do + post admin_spreadsheet_imports_path, params: { spreadsheet_import: { file: csv_upload } } + end + end + + assert_redirected_to admin_spreadsheet_import_path(SpreadsheetImport.last) + assert_predicate SpreadsheetImport.last, :pending? + end + + test "records who started the import" do + post admin_spreadsheet_imports_path, params: { spreadsheet_import: { file: csv_upload } } + + assert_equal users(:admin), SpreadsheetImport.last.user + end + + test "refuses a file that is not a spreadsheet" do + assert_no_difference -> { SpreadsheetImport.count } do + assert_no_enqueued_jobs only: SpreadsheetImportJob do + post admin_spreadsheet_imports_path, params: { + spreadsheet_import: { file: fixture_file_upload("not-an-image.txt", "text/plain") } + } + end + end + + assert_response :unprocessable_content + end + + test "rejects a submission that is not nested under a spreadsheet_import key" do + post admin_spreadsheet_imports_path, params: { file: csv_upload } + + assert_response :bad_request + end + + test "shows an import and subscribes to its stream" do + get admin_spreadsheet_import_path(create_import) + + assert_response :success + assert_select "turbo-cable-stream-source" + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + get admin_spreadsheet_imports_path + + assert_redirected_to profile_url + end + + private + def csv_upload + fixture_file_upload("users.csv", "text/csv") + end + + def create_import + users(:admin).spreadsheet_imports.create!( + file: { io: file_fixture("users.csv").open, filename: "users.csv" } + ) + end +end diff --git a/test/controllers/admin/users/roles_controller_test.rb b/test/controllers/admin/users/roles_controller_test.rb new file mode 100644 index 000000000..026f15a6d --- /dev/null +++ b/test/controllers/admin/users/roles_controller_test.rb @@ -0,0 +1,54 @@ +require "test_helper" + +class Admin::Users::RolesControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "promotes a user to admin" do + patch admin_user_role_path(users(:member)) + + assert_predicate users(:member).reload, :admin? + assert_redirected_to admin_users_path + end + + test "demotes an admin to user" do + other_admin = create_admin + + patch admin_user_role_path(other_admin) + + assert_predicate other_admin.reload, :user? + end + + test "replaces only the changed row when asked for a turbo stream" do + patch admin_user_role_path(users(:member)), as: :turbo_stream + + assert_response :success + assert_match "turbo-stream", response.media_type + assert_match dom_id(users(:member)), response.body + assert_no_match dom_id(users(:admin)), response.body + end + + test "refuses to change the signed in admin's own role" do + patch admin_user_role_path(users(:admin)) + + assert_predicate users(:admin).reload, :admin? + assert_equal "You cannot change your own role. Ask another admin.", flash[:alert] + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + patch admin_user_role_path(users(:admin)) + + assert_redirected_to profile_url + assert_predicate users(:admin).reload, :admin? + end + + private + def create_admin + User.create!( + full_name: "Katherine Johnson", email: "katherine@umanni.test", role: :admin, + password: "secret-password", password_confirmation: "secret-password" + ) + end +end diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb new file mode 100644 index 000000000..0a69a5fa2 --- /dev/null +++ b/test/controllers/admin/users_controller_test.rb @@ -0,0 +1,237 @@ +require "test_helper" + +class Admin::UsersControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "lists users" do + get admin_users_path + + assert_response :success + assert_select "td", text: /Ada Lovelace/ + end + + test "filters by name" do + get admin_users_path(query: "Ada") + + assert_select "td", text: /Ada Lovelace/ + assert_select "td", text: /Grace Hopper/, count: 0 + end + + test "finds a user by their exact email despite the column being encrypted" do + get admin_users_path(query: users(:member).email.upcase) + + assert_select "td", text: /Ada Lovelace/ + assert_select "td", text: /Grace Hopper/, count: 0 + end + + test "filters by role" do + get admin_users_path(role: "admin") + + assert_select "td", text: /Grace Hopper/ + assert_select "td", text: /Ada Lovelace/, count: 0 + end + + test "ignores a role filter that is not one of the enum values" do + get admin_users_path(role: "'; DROP TABLE users; --") + + assert_response :success + assert_select "td", text: /Ada Lovelace/ + end + + test "creates a user with a role of the admin's choosing" do + assert_difference -> { User.count }, 1 do + post admin_users_path, params: { user: valid_attributes.merge(role: "admin") } + end + + assert_redirected_to admin_users_path + assert_predicate User.find_by(email: "katherine@umanni.test"), :admin? + end + + test "re-renders the form when creation is invalid" do + assert_no_difference -> { User.count } do + post admin_users_path, params: { user: valid_attributes.merge(email: "nope") } + end + + assert_response :unprocessable_content + end + + test "attaches an avatar" do + post admin_users_path, params: { user: valid_attributes.merge(avatar_image: avatar_upload) } + + assert_predicate User.find_by(email: "katherine@umanni.test").avatar_image, :attached? + end + + test "rejects an avatar that is not a supported image" do + assert_no_difference -> { User.count } do + post admin_users_path, params: { + user: valid_attributes.merge( + avatar_image: fixture_file_upload("not-an-image.txt", "text/plain") + ) + } + end + + assert_response :unprocessable_content + end + + test "updates a user" do + patch admin_user_path(users(:member)), params: { user: { full_name: "Ada King" } } + + assert_redirected_to admin_users_path + assert_equal "Ada King", users(:member).reload.full_name + end + + test "keeps the existing password when the password fields are left blank" do + member = users(:member) + digest = member.password_digest + + patch admin_user_path(member), params: { + user: { full_name: "Ada King", password: "", password_confirmation: "" } + } + + assert_equal digest, member.reload.password_digest + end + + test "reports an error rather than silently keeping the name when it is cleared" do + patch admin_user_path(users(:member)), params: { user: { full_name: "" } } + + assert_response :unprocessable_content + assert_equal "Ada Lovelace", users(:member).reload.full_name + end + + test "resetting a user's password signs them out everywhere" do + victim = users(:member) + victim.sessions.create! + victim.sessions.create! + + patch admin_user_path(victim), params: { + user: { password: "a-new-password", password_confirmation: "a-new-password" } + } + + assert_empty victim.sessions.reload + end + + test "deletes an admin while another one remains" do + doomed = User.create!(full_name: "Katherine Johnson", email: "katherine@umanni.test", + password: "secret-password", password_confirmation: "secret-password", role: "admin") + + assert_difference -> { User.admin.count }, -1 do + delete admin_user_path(doomed) + end + + assert_redirected_to admin_users_path + end + + test "deletes a user" do + assert_difference -> { User.count }, -1 do + delete admin_user_path(users(:member)) + end + + assert_redirected_to admin_users_path + end + + test "does not let a query parameter rewrite the pagination links" do + 30.times { |i| User.create!(full_name: "Person #{i}", email: "p#{i}@umanni.test", password: "secret-password") } + + get "/admin/users?host=evil.example&protocol=https&page=1" + + assert_response :success + assert_select "nav[aria-label=Pagination] a" + assert_no_match "evil.example", response.body + end + + # url_for only runs when there are pagination links to build, hence the rows. + test "does not let a query parameter reach the router" do + 30.times { |i| User.create!(full_name: "Person #{i}", email: "r#{i}@umanni.test", password: "secret-password") } + + get "/admin/users?controller=sessions&action=new&page=1" + + assert_response :success + assert_select "nav[aria-label=Pagination] a" + end + + # A second admin, or the last-admin invariant is what blocks this and the test says + # nothing about the parameter being stripped. + test "an admin cannot take their own role away through the edit form" do + User.create!(full_name: "Katherine Johnson", email: "katherine@umanni.test", + password: "secret-password", password_confirmation: "secret-password", role: "admin") + + patch admin_user_path(users(:admin)), params: { user: { full_name: "Grace B. Hopper", role: "user" } } + + assert_redirected_to admin_users_path + assert_predicate users(:admin).reload, :admin? + assert_equal "Grace B. Hopper", users(:admin).reload.full_name + end + + # A client that follows a 302 keeps the method, and /profile answers DELETE by deleting + # the account of whoever asked. + test "a refusal does not hand a destructive method to the redirect target" do + sign_out + sign_in_as users(:member) + + delete admin_user_path(users(:admin)) + + assert_response :see_other + end + + test "every route that changes something is closed to a plain user" do + sign_out + sign_in_as users(:member) + target = users(:member) + + assert_no_changes -> { [ User.count, target.reload.role, target.full_name ] } do + post admin_users_path, params: { user: { full_name: "Mallory", email: "mallory@umanni.test", + password: "secret-password", password_confirmation: "secret-password", + role: "admin" } } + patch admin_user_path(target), params: { user: { full_name: "Renamed", role: "admin" } } + patch admin_user_role_path(target) + delete admin_user_path(target) + end + + assert_redirected_to profile_url + end + + test "refuses to delete the signed in admin" do + assert_no_difference -> { User.count } do + delete admin_user_path(users(:admin)) + end + + assert_equal "Delete your own account from your profile.", flash[:alert] + end + + test "rejects a submission that is not nested under a user key" do + post admin_users_path, params: valid_attributes + + assert_response :bad_request + end + + test "is closed to users who are not admins" do + sign_out + sign_in_as users(:member) + + get admin_users_path + + assert_redirected_to profile_url + end + + test "is closed to visitors" do + sign_out + + get admin_users_path + + assert_redirected_to new_session_path + end + + private + def valid_attributes + { + full_name: "Katherine Johnson", + email: "katherine@umanni.test", + password: "secret-password", + password_confirmation: "secret-password" + } + end + + def avatar_upload + fixture_file_upload("avatar.png", "image/png") + end +end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb new file mode 100644 index 000000000..d38d55229 --- /dev/null +++ b/test/controllers/passwords_controller_test.rb @@ -0,0 +1,119 @@ +require "test_helper" + +class PasswordsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "shows the form" do + get new_password_path + + assert_response :success + end + + test "enqueues the reset mail and says so without confirming the address" do + post passwords_path, params: { password_reset: { email: @user.email } } + + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + assert_redirected_to new_session_path + + follow_redirect! + + assert_notice "reset instructions are on the way" + end + + test "create for an unknown user redirects but sends no mail" do + post passwords_path, params: { password_reset: { email: "missing-user@example.com" } } + + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + + assert_notice "reset instructions are on the way" + end + + test "opens the form for a valid token" do + get edit_password_path(@user.password_reset_token) + + assert_response :success + end + + test "refuses a token this application did not sign" do + get edit_password_path("invalid token") + + assert_redirected_to new_password_path + + follow_redirect! + + assert_notice "reset link is invalid" + end + + test "resetting the password signs every browser out" do + @user.sessions.create! + @user.sessions.create! + + assert_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password_reset: { password: "a-new-password", password_confirmation: "a-new-password" } } + + assert_redirected_to new_session_path + end + + assert_empty @user.sessions.reload + + follow_redirect! + + assert_notice "Password has been reset" + end + + # has_secure_password ignores a blank assignment, so nothing but this stops a reset + # that saves cleanly, destroys every session and leaves the old password working. + test "refuses an empty password instead of reporting a reset that did not happen" do + @user.sessions.create! + digest = @user.password_digest + + put password_path(@user.password_reset_token), params: { password_reset: { password: "", password_confirmation: "" } } + + assert_response :unprocessable_content + assert_select "p.field-error", text: "Password can't be blank" + assert_equal digest, @user.reload.password_digest + assert_not_empty @user.sessions.reload, "the sessions were destroyed for a reset that did not happen" + end + + # assert_enqueued_email_with never renders the body, so a broken link in the template + # ships green. This opens what the person receives. + test "sends a link that opens the form for this account" do + mail = PasswordsMailer.reset(@user) + + link = mail.html_part ? mail.html_part.body.to_s : mail.body.to_s + token = link[%r{/passwords/([^/"]+)/edit}, 1] + + assert token, "the mail carries no reset link" + + get edit_password_path(token) + + assert_response :success + end + + test "refuses a confirmation that does not match" do + token = @user.password_reset_token + assert_no_changes -> { @user.reload.password_digest } do + put password_path(token), params: { password_reset: { password: "no-match-here", password_confirmation: "different-one" } } + end + + assert_response :unprocessable_content + assert_select "p.field-error", text: "Password confirmation doesn't match Password" + end + + test "update reports the real reason when the new password is too short" do + assert_no_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password_reset: { password: "short", password_confirmation: "short" } } + end + + assert_response :unprocessable_content + assert_select "p.field-error", text: /Password is too short/ + end + + private + def assert_notice(text) + assert_select "div", /#{text}/ + end +end diff --git a/test/controllers/profiles_controller_test.rb b/test/controllers/profiles_controller_test.rb new file mode 100644 index 000000000..2cdb95a59 --- /dev/null +++ b/test/controllers/profiles_controller_test.rb @@ -0,0 +1,147 @@ +require "test_helper" + +class ProfilesControllerTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:member) } + + test "shows the signed in user's own profile" do + get profile_path + + assert_response :success + assert_select "body", text: /Ada Lovelace/ + end + + test "renders the edit form" do + get edit_profile_path + + assert_response :success + end + + test "updates the profile" do + patch profile_path, params: { user: { full_name: "Ada King" } } + + assert_redirected_to profile_path + assert_equal "Ada King", users(:member).reload.full_name + end + + test "always acts on the signed in user, never another one" do + patch profile_path, params: { user: { full_name: "Ada King" } } + + assert_equal "Ada King", users(:member).reload.full_name + assert_equal "Grace Hopper", users(:admin).reload.full_name + end + + test "ignores a role the user tries to give themselves" do + patch profile_path, params: { user: { full_name: "Ada King", role: "admin" } } + + assert_predicate users(:member).reload, :user? + end + + test "keeps the existing password when the password fields are left blank" do + digest = users(:member).password_digest + + patch profile_path, params: { user: { full_name: "Ada King", password: "", password_confirmation: "" } } + + assert_equal digest, users(:member).reload.password_digest + end + + test "changes the password when a new one is given" do + patch profile_path, params: { + user: { password: "a-new-password", password_confirmation: "a-new-password" } + } + + assert_predicate users(:member).reload.authenticate("a-new-password"), :present? + end + + test "re-renders the form when the submission is invalid" do + patch profile_path, params: { user: { email: "not-an-email" } } + + assert_response :unprocessable_content + assert_equal "ada@umanni.test", users(:member).reload.email + end + + test "removes an attached avatar when asked" do + users(:member).avatar_image.attach(io: file_fixture("avatar.png").open, filename: "avatar.png") + + patch profile_path, params: { user: { remove_avatar_image: "1" } } + + assert_not users(:member).reload.avatar_image.attached? + end + + test "keeps the avatar when the box is not ticked" do + users(:member).avatar_image.attach(io: file_fixture("avatar.png").open, filename: "avatar.png") + + patch profile_path, params: { user: { full_name: "Ada King", remove_avatar_image: "0" } } + + assert_predicate users(:member).reload.avatar_image, :attached? + end + + test "names the field an invalid value came from" do + patch profile_path, params: { user: { email: "not-an-email" } } + + assert_response :unprocessable_content + assert_select "p#email_error" + assert_select "input[name=?][aria-invalid=true]", "user[email]" + end + + test "attaches an avatar" do + patch profile_path, params: { user: { avatar_image: fixture_file_upload("avatar.png", "image/png") } } + + assert_predicate users(:member).reload.avatar_image, :attached? + end + + test "deletes the account and ends the session" do + assert_difference -> { User.count }, -1 do + delete profile_path + end + + assert_redirected_to new_session_path + assert_empty cookies[:session_id].to_s + end + + test "the last admin cannot delete their own account" do + sign_out + User.admin.where.not(id: users(:admin).id).destroy_all + sign_in_as users(:admin) + + assert_no_difference -> { User.count } do + delete profile_path + end + + assert_redirected_to profile_path + assert_equal "The only admin cannot be deleted.", flash[:alert] + end + + test "changing the password signs every other browser out" do + other = users(:member).sessions.create! + mine = Current.session + + patch profile_path, params: { + user: { password: "a-new-password", password_confirmation: "a-new-password" } + } + + assert_nil Session.find_by(id: other.id) + assert Session.exists?(mine.id), "the browser making the change was signed out too" + end + + test "leaves other sessions alone when the password is untouched" do + other = users(:member).sessions.create! + + patch profile_path, params: { user: { full_name: "Ada King" } } + + assert Session.exists?(other.id) + end + + test "rejects a submission that is not nested under a user key" do + patch profile_path, params: { full_name: "Ada King" } + + assert_response :bad_request + end + + test "is closed to visitors" do + sign_out + + get profile_path + + assert_redirected_to new_session_path + end +end diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb new file mode 100644 index 000000000..99d9bb9d8 --- /dev/null +++ b/test/controllers/registrations_controller_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class RegistrationsControllerTest < ActionDispatch::IntegrationTest + test "renders the registration form" do + get new_registration_path + + assert_response :success + assert_select "input[type=submit][data-turbo-submits-with=?]", "Creating…" + end + + test "registers a visitor and signs them in" do + assert_difference -> { User.count }, 1 do + post registration_path, params: { user: valid_attributes } + end + + assert_redirected_to profile_path + assert_predicate cookies[:session_id], :present? + end + + test "lands a new account on its own profile, not on the admin page it was bounced off" do + get admin_dashboard_path + + assert_redirected_to new_session_path + + post registration_path, params: { user: valid_attributes } + + assert_redirected_to profile_path + follow_redirect! + + assert_select "#flash", text: /Welcome to Umanni/ + end + + test "ignores a role supplied by the visitor" do + post registration_path, params: { user: valid_attributes.merge(role: "admin") } + + assert_predicate User.find_by(email: "katherine@umanni.test"), :user? + end + + test "re-renders the form when the submission is invalid" do + assert_no_difference -> { User.count } do + post registration_path, params: { user: valid_attributes.merge(email: "not-an-email") } + end + + assert_response :unprocessable_content + end + + test "rejects a submission that is not nested under a user key" do + assert_no_difference -> { User.count } do + post registration_path, params: valid_attributes + end + + assert_response :bad_request + end + + test "rate limits repeated registrations from the same address" do + 11.times do |index| + post registration_path, params: { user: valid_attributes.merge(email: "visitor#{index}@umanni.test") } + end + + assert_redirected_to new_registration_path + assert_equal "Too many attempts. Try again later.", flash[:alert] + end + + private + def valid_attributes + { + full_name: "Katherine Johnson", + email: "katherine@umanni.test", + password: "secret-password", + password_confirmation: "secret-password" + } + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 000000000..dc2706a36 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,51 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + test "renders the sign in form" do + get new_session_path + + assert_response :success + end + + test "signs in with valid credentials" do + post session_path, params: { session: { email: users(:member).email, password: "secret-password" } } + + assert_redirected_to profile_path + assert_predicate cookies[:session_id], :present? + end + + test "re-renders the form with an alert on invalid credentials" do + post session_path, params: { session: { email: users(:member).email, password: "wrong" } } + + assert_response :unprocessable_content + assert_empty cookies[:session_id].to_s + end + + test "rejects a login attempt with a malformed parameter structure" do + post session_path, params: { email: users(:member).email, password: "secret-password" } + + assert_response :bad_request + assert_empty cookies[:session_id].to_s + end + + test "rate limits repeated sign in attempts from the same address" do + 11.times do + post session_path, params: { session: { email: users(:member).email, password: "wrong" } } + end + + assert_redirected_to new_session_path + assert_equal "Too many attempts. Try again later.", flash[:alert] + end + + test "signs out" do + sign_in_as users(:member) + + record = users(:member).sessions.sole + + delete session_path + + assert_redirected_to new_session_path + assert_empty cookies[:session_id].to_s + assert_nil Session.find_by(id: record.id), "the row outlived the cookie" + end +end diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/files/avatar.png b/test/fixtures/files/avatar.png new file mode 100644 index 000000000..9fdee4bdf Binary files /dev/null and b/test/fixtures/files/avatar.png differ diff --git a/test/fixtures/files/bulk_users.csv b/test/fixtures/files/bulk_users.csv new file mode 100644 index 000000000..2a3541755 --- /dev/null +++ b/test/fixtures/files/bulk_users.csv @@ -0,0 +1,61 @@ +full_name,email,role +Bulk Person 1,bulk1@umanni.test,user +Bulk Person 2,bulk2@umanni.test,user +Bulk Person 3,bulk3@umanni.test,user +Bulk Person 4,bulk4@umanni.test,user +Bulk Person 5,bulk5@umanni.test,user +Bulk Person 6,bulk6@umanni.test,user +Bulk Person 7,bulk7@umanni.test,user +Bulk Person 8,bulk8@umanni.test,user +Bulk Person 9,bulk9@umanni.test,user +Bulk Person 10,bulk10@umanni.test,admin +Bulk Person 11,bulk11@umanni.test,user +Bulk Person 12,bulk12@umanni.test,user +Bulk Person 13,bulk13@umanni.test,user +Bulk Person 14,bulk14@umanni.test,user +Bulk Person 15,bulk15@umanni.test,user +Bulk Person 16,bulk16@umanni.test,user +Bulk Person 17,bulk17@umanni.test,user +Bulk Person 18,bulk18@umanni.test,user +Bulk Person 19,bulk19@umanni.test,user +Bulk Person 20,bulk20@umanni.test,admin +Bulk Person 21,bulk21@umanni.test,user +Bulk Person 22,bulk22@umanni.test,user +Bulk Person 23,bulk23@umanni.test,user +Bulk Person 24,bulk24@umanni.test,user +Bulk Person 25,bulk25@umanni.test,user +Bulk Person 26,bulk26@umanni.test,user +Bulk Person 27,bulk27@umanni.test,user +Bulk Person 28,bulk28@umanni.test,user +Bulk Person 29,bulk29@umanni.test,user +Bulk Person 30,bulk30@umanni.test,admin +Bulk Person 31,bulk31@umanni.test,user +Bulk Person 32,bulk32@umanni.test,user +Bulk Person 33,bulk33@umanni.test,user +Bulk Person 34,bulk34@umanni.test,user +Bulk Person 35,bulk35@umanni.test,user +Bulk Person 36,bulk36@umanni.test,user +Bulk Person 37,bulk37@umanni.test,user +Bulk Person 38,bulk38@umanni.test,user +Bulk Person 39,bulk39@umanni.test,user +Bulk Person 40,bulk40@umanni.test,admin +Bulk Person 41,bulk41@umanni.test,user +Bulk Person 42,bulk42@umanni.test,user +Bulk Person 43,bulk43@umanni.test,user +Bulk Person 44,bulk44@umanni.test,user +Bulk Person 45,bulk45@umanni.test,user +Bulk Person 46,bulk46@umanni.test,user +Bulk Person 47,bulk47@umanni.test,user +Bulk Person 48,bulk48@umanni.test,user +Bulk Person 49,bulk49@umanni.test,user +Bulk Person 50,bulk50@umanni.test,admin +Bulk Person 51,bulk51@umanni.test,user +Bulk Person 52,bulk52@umanni.test,user +Bulk Person 53,bulk53@umanni.test,user +Bulk Person 54,bulk54@umanni.test,user +Bulk Person 55,bulk55@umanni.test,user +Bulk Person 56,bulk56@umanni.test,user +Bulk Person 57,bulk57@umanni.test,user +Bulk Person 58,bulk58@umanni.test,user +Bulk Person 59,bulk59@umanni.test,user +Bulk Person 60,bulk60@umanni.test,admin diff --git a/test/fixtures/files/empty.png b/test/fixtures/files/empty.png new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/files/not-an-image.txt b/test/fixtures/files/not-an-image.txt new file mode 100644 index 000000000..c93adf7e2 --- /dev/null +++ b/test/fixtures/files/not-an-image.txt @@ -0,0 +1 @@ +this is not an image diff --git a/test/fixtures/files/not-really-a-png.png b/test/fixtures/files/not-really-a-png.png new file mode 100644 index 000000000..ceec144a8 --- /dev/null +++ b/test/fixtures/files/not-really-a-png.png @@ -0,0 +1 @@ +isto nao e uma imagem diff --git a/test/fixtures/files/trailing_blank_rows.csv b/test/fixtures/files/trailing_blank_rows.csv new file mode 100644 index 000000000..8f972e11c --- /dev/null +++ b/test/fixtures/files/trailing_blank_rows.csv @@ -0,0 +1,4 @@ +full_name,email,role +Ada Lovelace,ada.blank@umanni.test,user + + diff --git a/test/fixtures/files/users.csv b/test/fixtures/files/users.csv new file mode 100644 index 000000000..960afa9bf --- /dev/null +++ b/test/fixtures/files/users.csv @@ -0,0 +1,6 @@ +full_name,email,role +Katherine Johnson,katherine@umanni.test,admin +Dorothy Vaughan,dorothy@umanni.test,user +Mary Jackson,mary@umanni.test, +,no-name@umanni.test,user +Duplicate Person,katherine@umanni.test,user diff --git a/test/fixtures/files/users.xlsx b/test/fixtures/files/users.xlsx new file mode 100644 index 000000000..bc80f8651 Binary files /dev/null and b/test/fixtures/files/users.xlsx differ diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 000000000..d67738eaf --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,13 @@ +<% password_digest = BCrypt::Password.create("secret-password") %> + +admin: + full_name: Grace Hopper + email: grace@umanni.test + password_digest: <%= password_digest %> + role: admin + +member: + full_name: Ada Lovelace + email: ada@umanni.test + password_digest: <%= password_digest %> + role: user diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/role_based_access_test.rb b/test/integration/role_based_access_test.rb new file mode 100644 index 000000000..66c7d49f4 --- /dev/null +++ b/test/integration/role_based_access_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class RoleBasedAccessTest < ActionDispatch::IntegrationTest + test "an admin lands on the dashboard after signing in" do + sign_in users(:admin) + + assert_redirected_to admin_dashboard_path + end + + test "a user lands on their profile after signing in" do + sign_in users(:member) + + assert_redirected_to profile_path + end + + test "the root path sends each role to their own home" do + sign_in_as users(:admin) + get root_path + + assert_redirected_to admin_dashboard_path + + sign_out + sign_in_as users(:member) + get root_path + + assert_redirected_to profile_path + end + + test "a user cannot reach an admin screen" do + sign_in_as users(:member) + + get admin_dashboard_path + + assert_redirected_to profile_path + assert_equal "You are not authorised to access that page.", flash[:alert] + end + + test "a visitor is sent to sign in and returned to where they were headed" do + get admin_dashboard_path + + assert_redirected_to new_session_path + + sign_in users(:admin) + + assert_redirected_to admin_dashboard_url + end + + private + def sign_in(user) + post session_path, params: { session: { email: user.email, password: "secret-password" } } + end +end diff --git a/test/integration/security_test.rb b/test/integration/security_test.rb new file mode 100644 index 000000000..b154899b2 --- /dev/null +++ b/test/integration/security_test.rb @@ -0,0 +1,123 @@ +require "test_helper" + +# The brief names SQL injection, XSS and CSRF. +class SecurityTest < ActionDispatch::IntegrationTest + setup { sign_in_as users(:admin) } + + test "escapes markup that was stored in a user's name" do + users(:member).update!(full_name: "") + + get admin_users_path + + assert_response :success + assert_no_match "" } ]) + + get admin_spreadsheet_import_path(import) + + assert_no_match "